Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8894271daa | ||
|
|
3cb262617c | ||
|
|
0959f36587 | ||
|
|
8aba905d9c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -39,6 +42,7 @@
|
||||
<!-- The drawer's wave alphabet -->
|
||||
<!-- Read by screen readers as the name of the alphabet strip, followed by the current letter -->
|
||||
<string name="flexera_alphabet_description">Alphabet</string>
|
||||
<string name="flexera_alphabet_search">Search</string>
|
||||
<!-- App drawer settings (app.flexera.ui.FlexeraDrawerSettings) -->
|
||||
<string name="flexera_notification_previews">Notification previews</string>
|
||||
<string name="flexera_notification_previews_description">Show an app\'s latest notification under its name in the app list. Works when notification dots are on (Settings, General).</string>
|
||||
|
||||
@@ -16,7 +16,10 @@ import com.android.launcher3.BubbleTextView
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.allapps.ActivityAllAppsContainerView
|
||||
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.android.launcher3.views.BaseDragLayer
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
|
||||
@@ -90,9 +93,10 @@ 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)
|
||||
icon.layoutParams.height = rowHeight(icon.context)
|
||||
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,12 +112,85 @@ object FlexeraDrawer {
|
||||
/** Shadow behind white drawer text. */
|
||||
const val TEXT_SHADOW_COLOR = 0x73000000
|
||||
|
||||
/**
|
||||
* How tall an app row is. 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.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun rowHeight(context: Context): Int {
|
||||
val res = context.resources
|
||||
return max(
|
||||
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
|
||||
(res.getDimension(R.dimen.flexera_drawer_row_text_size) * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
const val KEEP_SEARCH_FIELD_BACKGROUND = true
|
||||
|
||||
/**
|
||||
* Moves the search field up with the list, so that it scrolls away like the list's first
|
||||
* row and comes back when the list is at its top. The field is not a row of the list: it
|
||||
* lies over the list's top padding and is shifted by as much as the list has scrolled.
|
||||
* While a search is on, and where the drawer does not use this (work tabs, a hidden or a
|
||||
* floating search field), the field stays in place.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun moveSearchFieldWithList(appsView: ActivityAllAppsContainerView<*>) {
|
||||
val field = appsView.searchView ?: return
|
||||
val list = appsView.activeRecyclerView
|
||||
val scrolled = if (list == null || appsView.isSearching || !appsView.flexeraSearchFieldScrolls()) {
|
||||
0
|
||||
} else {
|
||||
scrolledFromTop(list)
|
||||
}
|
||||
// The field and the list's rows vanish at the same line, the top of the drawer's
|
||||
// content, which is the lower edge of the status bar. The rows are cut there by the
|
||||
// list's own top edge; the field is cut here, and travels no further than it takes to
|
||||
// be all above that line.
|
||||
val line = appsView.paddingTop
|
||||
val moved = min(scrolled, max(0, field.bottom - line))
|
||||
field.translationY = -moved.toFloat()
|
||||
val hidden = line - (field.top - moved)
|
||||
if (hidden > 0) {
|
||||
// The view copies the rectangle, so one scratch rectangle serves every call.
|
||||
fieldClip.set(0, hidden, field.width, max(hidden, field.height))
|
||||
field.clipBounds = fieldClip
|
||||
} else {
|
||||
field.clipBounds = null
|
||||
}
|
||||
}
|
||||
|
||||
private val fieldClip = Rect()
|
||||
|
||||
/**
|
||||
* How far [list] has scrolled from its top, in pixels, for as long as its first row is
|
||||
* still attached; after that, simply "far". Counting rows is exact, which the list's own
|
||||
* scroll offset, an estimate from row heights, is not.
|
||||
*/
|
||||
private fun scrolledFromTop(list: RecyclerView): Int {
|
||||
val first = list.getChildAt(0) ?: return 0
|
||||
return if (list.getChildAdapterPosition(first) == 0) {
|
||||
max(0, list.paddingTop - first.top)
|
||||
} else {
|
||||
Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the drawer panel sits for a given all-apps progress (0 = open, 1 = closed). Launcher3
|
||||
* slides the panel up from the bottom; here it stays in place and fades (see
|
||||
@@ -246,6 +323,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 +336,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
|
||||
@@ -37,13 +37,17 @@ import kotlin.math.roundToInt
|
||||
/**
|
||||
* The wave alphabet: a strip of letters down one edge of the drawer (PRD AD-4 to AD-6).
|
||||
*
|
||||
* Only letters that have apps are shown. Touching the strip and sliding along it jumps the list
|
||||
* Only letters that have apps are shown, under a magnifier that stands for the search field at
|
||||
* the top of the list. 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.
|
||||
@@ -60,7 +64,8 @@ class WaveAlphabetView(
|
||||
private val appsView: ActivityAllAppsContainerView<*>,
|
||||
) : View(context) {
|
||||
|
||||
private data class Letter(val label: String, val adapterPosition: Int)
|
||||
/** One stop on the strip: a letter, or the search field at the top of the list ([isSearch]). */
|
||||
private data class Letter(val label: String, val adapterPosition: Int, val isSearch: Boolean = false)
|
||||
|
||||
private val res = context.resources
|
||||
private val onLeft = FlexeraDrawer.alphabetOnLeft(context)
|
||||
@@ -68,11 +73,13 @@ 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)
|
||||
private val farMargin = res.getDimension(R.dimen.flexera_wave_far_margin)
|
||||
private val letterSize = res.getDimension(R.dimen.flexera_wave_letter_size)
|
||||
private val headerHeight = res.getDimensionPixelSize(R.dimen.flexera_drawer_header_height)
|
||||
|
||||
/** The section name Launcher3 gives the work profile's info card, an "i" in a circle. */
|
||||
private val workInfoSection = res.getString(R.string.work_profile_edu_section)
|
||||
@@ -94,21 +101,42 @@ 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(
|
||||
// For the magnifier: one stroke with the letters' shadow, one without for the bubble.
|
||||
private val markPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
setShadowLayer(
|
||||
res.getDimension(R.dimen.flexera_drawer_text_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),
|
||||
FlexeraDrawer.TEXT_SHADOW_COLOR,
|
||||
)
|
||||
}
|
||||
private val plainMarkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
// 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,
|
||||
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 +153,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
|
||||
@@ -161,10 +185,18 @@ class WaveAlphabetView(
|
||||
if (result.isNotEmpty() && result.last().label == label) continue
|
||||
result.add(Letter(label, section.position))
|
||||
}
|
||||
// The search field is the list's first row in all but name, and scrolls away with it.
|
||||
// A magnifier above the letters says it is there and jumps back to it.
|
||||
if (result.isNotEmpty() && hasSearchField()) result.add(0, Letter("", 0, isSearch = true))
|
||||
return result
|
||||
}
|
||||
|
||||
private fun hasSearchField(): Boolean =
|
||||
!PreferenceManager2.getInstance(context).hideAppDrawerSearchBar.firstCached()
|
||||
|
||||
private fun refreshIfChanged() {
|
||||
// Any new layout may have moved the list; keep the search field riding on it.
|
||||
FlexeraDrawer.moveSearchFieldWithList(appsView)
|
||||
val searching = appsView.isSearching
|
||||
val fresh = readLetters()
|
||||
if (fresh != letters || searching != wasSearching) {
|
||||
@@ -184,7 +216,8 @@ class WaveAlphabetView(
|
||||
appsView.activeRecyclerView?.let { rv ->
|
||||
getLocationInWindow(location)
|
||||
rv.getLocationInWindow(otherLocation)
|
||||
top = (otherLocation[1] - location[1]).toFloat().coerceAtLeast(0f)
|
||||
// The list's rows start below its top padding, which holds the search field.
|
||||
top = (otherLocation[1] - location[1] + rv.paddingTop).toFloat().coerceAtLeast(0f)
|
||||
}
|
||||
val bottomInset = rootWindowInsets?.systemWindowInsetBottom ?: 0
|
||||
val availableTop = top + verticalMargin
|
||||
@@ -199,8 +232,15 @@ 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 fullCount = FULL_ALPHABET + if (letters.first().isSearch) 1 else 0
|
||||
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * fullCount))
|
||||
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 +295,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 +315,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 +326,50 @@ 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)
|
||||
if (letters[i].isSearch) {
|
||||
drawSearchMark(canvas, x, y, paint.textSize * SEARCH_MARK_IN_LETTER_SIZES, paint.color, true)
|
||||
} else {
|
||||
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
|
||||
if (letters[selected].isSearch) {
|
||||
drawSearchMark(canvas, cx, fingerY, bubbleTextPaint.textSize, bubbleTextPaint.color, false)
|
||||
} else {
|
||||
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
|
||||
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a magnifier, the mark for the search field, [size] across and centred on
|
||||
* ([cx], [cy]): a ring with a short handle. Drawn by hand so that its line weight
|
||||
* matches letters of any size.
|
||||
*/
|
||||
private fun drawSearchMark(canvas: Canvas, cx: Float, cy: Float, size: Float, color: Int, shadow: Boolean) {
|
||||
val stroke = if (shadow) markPaint else plainMarkPaint
|
||||
stroke.color = color
|
||||
stroke.strokeWidth = size * 0.12f
|
||||
val radius = size * 0.29f
|
||||
val ringX = cx - size * 0.09f
|
||||
val ringY = cy - size * 0.09f
|
||||
canvas.drawCircle(ringX, ringY, radius, stroke)
|
||||
val rim = radius * 0.7071f
|
||||
val handle = size * 0.27f
|
||||
canvas.drawLine(ringX + rim, ringY + rim, ringX + rim + handle, ringY + rim + handle, stroke)
|
||||
}
|
||||
|
||||
private fun bell(distance: Float): Float = exp(-distance * distance)
|
||||
@@ -348,33 +422,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]. */
|
||||
@@ -388,7 +458,7 @@ class WaveAlphabetView(
|
||||
if (index != selected) {
|
||||
selected = index
|
||||
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
|
||||
scrollListTo(letters[index].adapterPosition)
|
||||
scrollListTo(index)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
@@ -402,10 +472,31 @@ class WaveAlphabetView(
|
||||
|
||||
val isScrubbing: Boolean get() = scrubbing
|
||||
|
||||
private fun scrollListTo(adapterPosition: Int) {
|
||||
/**
|
||||
* Brings the group of the letter at [index] to the middle of the screen, where the thumb
|
||||
* is, instead of to the top: the group's middle on the list's middle, though a long group
|
||||
* keeps its header in view. Near either end of the list the list simply stops at its end.
|
||||
* The magnifier goes to the very top, where the search field is.
|
||||
*/
|
||||
private fun scrollListTo(index: Int) {
|
||||
val rv = appsView.activeRecyclerView ?: return
|
||||
val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return
|
||||
rv.stopScroll()
|
||||
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(adapterPosition, 0)
|
||||
val letter = letters[index]
|
||||
if (letter.isSearch) {
|
||||
layoutManager.scrollToPositionWithOffset(0, 0)
|
||||
return
|
||||
}
|
||||
val groupEnd = letters.getOrNull(index + 1)?.adapterPosition
|
||||
?: rv.adapter?.itemCount
|
||||
?: (letter.adapterPosition + 2)
|
||||
val rows = (groupEnd - letter.adapterPosition - 1).coerceAtLeast(1)
|
||||
val groupHeight = headerHeight + rows * FlexeraDrawer.rowHeight(context)
|
||||
val top = max(rv.height * GROUP_HIGHEST_TOP, rv.height * GROUP_MIDDLE - groupHeight / 2f)
|
||||
layoutManager.scrollToPositionWithOffset(
|
||||
letter.adapterPosition,
|
||||
(top - rv.paddingTop).toInt().coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
private fun animatePress(target: Float, durationMs: Long) {
|
||||
@@ -466,7 +557,7 @@ class WaveAlphabetView(
|
||||
|
||||
private fun jumpTo(index: Int) {
|
||||
val rv = appsView.activeRecyclerView ?: return
|
||||
scrollListTo(letters[index].adapterPosition)
|
||||
scrollListTo(index)
|
||||
rv.doOnNextLayout {
|
||||
chosenIndex = index
|
||||
chosenAtPosition = firstVisiblePosition()
|
||||
@@ -491,7 +582,9 @@ class WaveAlphabetView(
|
||||
if (!stripVisible()) return
|
||||
|
||||
val index = currentIndex()
|
||||
node.stateDescription = letters[index].label
|
||||
node.stateDescription = letters[index].let {
|
||||
if (it.isSearch) context.getString(R.string.flexera_alphabet_search) else it.label
|
||||
}
|
||||
node.setRangeInfo(
|
||||
AccessibilityNodeInfoCompat.RangeInfoCompat.obtain(
|
||||
AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_INT,
|
||||
@@ -520,23 +613,33 @@ class WaveAlphabetView(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** How much larger than its resting size the selected letter grows. */
|
||||
const val SELECTED_GROWTH = 1.5f
|
||||
/** Where a chosen letter's group is centred, and the highest its header may go, as shares of the list's height. */
|
||||
const val GROUP_MIDDLE = 0.5f
|
||||
const val GROUP_HIGHEST_TOP = 0.22f
|
||||
|
||||
/** How much its neighbours grow, tapering with distance from the finger. */
|
||||
const val NEAR_GROWTH = 0.5f
|
||||
/** The magnifier's size next to a letter of the same text size. */
|
||||
const val SEARCH_MARK_IN_LETTER_SIZES = 1.1f
|
||||
|
||||
/** 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
|
||||
/** 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 larger than its resting size the selected letter is in the wave itself. */
|
||||
const val SELECTED_GROWTH = 0.25f
|
||||
|
||||
/** 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,4 +2,4 @@
|
||||
# build.gradle reads this and derives the Android version code from it
|
||||
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
|
||||
# flexera/release.ps1 raises it for every release.
|
||||
versionName=0.3.1
|
||||
versionName=0.5.0
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -192,6 +192,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
protected View mSearchContainer;
|
||||
protected SearchUiManager mSearchUiManager;
|
||||
protected boolean mUsingTabs;
|
||||
// Flexera Launcher: see replaceAppsRVContainer.
|
||||
private boolean mFlexeraSearchFieldScrolls;
|
||||
protected RecyclerViewFastScroller mTouchHandler;
|
||||
|
||||
/** {@code true} when rendered view is in search state instead of the scroll state. */
|
||||
@@ -849,9 +851,28 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
layoutBelowSearchContainer(getSearchRecyclerView(), /* tabs= */ false);
|
||||
}
|
||||
|
||||
// Flexera Launcher: the search field scrolls away with the list. For that the list
|
||||
// starts at the top of the drawer, not under the field, and keeps the field's room as
|
||||
// top padding (AdapterHolder.applyPadding); FlexeraDrawer.moveSearchFieldWithList then
|
||||
// moves the field as the list scrolls. Work tabs sit between the two and stay put, so
|
||||
// with tabs the field stays where it is, as before.
|
||||
mFlexeraSearchFieldScrolls =
|
||||
!showTabs && !isAppDrawerSearchBarHidden() && !isSearchBarFloating();
|
||||
if (mFlexeraSearchFieldScrolls
|
||||
&& rvContainer.getLayoutParams() instanceof RelativeLayout.LayoutParams lp) {
|
||||
lp.removeRule(RelativeLayout.ALIGN_TOP);
|
||||
lp.addRule(RelativeLayout.ALIGN_PARENT_TOP);
|
||||
lp.topMargin = 0;
|
||||
}
|
||||
|
||||
updateSearchResultsVisibility();
|
||||
}
|
||||
|
||||
/** Flexera Launcher: true while the search field scrolls away with the list. */
|
||||
public boolean flexeraSearchFieldScrolls() {
|
||||
return mFlexeraSearchFieldScrolls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up header rows and hides the header shell when neither tabs nor visible row content
|
||||
* requires it, keeping adapter top padding consistent with that visibility.
|
||||
@@ -917,6 +938,8 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
}
|
||||
|
||||
protected void updateHeaderScroll(int scrolledOffset) {
|
||||
// Flexera Launcher: the search field moves up with the list.
|
||||
app.flexera.drawer.FlexeraDrawer.moveSearchFieldWithList(this);
|
||||
if (isAppDrawerSearchBarHidden() && !mUsingTabs)
|
||||
return;
|
||||
|
||||
@@ -1421,6 +1444,11 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
setPadding(grid.allAppsLeftRightMargin, topPadding, grid.allAppsLeftRightMargin, 0);
|
||||
}
|
||||
InsettableFrameLayout.dispatchInsets(this, insets);
|
||||
// Flexera Launcher: the list's top padding follows the search field's place, which the
|
||||
// line above may just have changed.
|
||||
if (mFlexeraSearchFieldScrolls) {
|
||||
mAH.get(AdapterHolder.MAIN).applyPadding();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1905,7 +1933,17 @@ public class ActivityAllAppsContainerView<T extends Context & ActivityContext>
|
||||
if (isSearchBarFloating()) {
|
||||
bottomOffset += mSearchContainer.getHeight();
|
||||
}
|
||||
mRecyclerView.setPadding(mPadding.left, mPadding.top, mPadding.right,
|
||||
int top = mPadding.top;
|
||||
// Flexera Launcher: the room the search field takes at the top of the list, now
|
||||
// that the list starts above the field (see replaceAppsRVContainer): the
|
||||
// field's own distance from the top, and what used to be kept under its top edge.
|
||||
if (mFlexeraSearchFieldScrolls && isMain()) {
|
||||
top += getResources().getDimensionPixelSize(R.dimen.all_apps_header_top_margin);
|
||||
if (mSearchContainer.getLayoutParams() instanceof MarginLayoutParams field) {
|
||||
top += field.topMargin;
|
||||
}
|
||||
}
|
||||
mRecyclerView.setPadding(mPadding.left, top, mPadding.right,
|
||||
mPadding.bottom + bottomOffset);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user