Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
516c4efc56 | ||
|
|
8e6b737894 | ||
|
|
31ab0d52e1 |
No files matched your search
@@ -37,6 +37,26 @@
|
||||
android:resource="@xml/flexera_picture_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- The clock widget that comes with the launcher: the time and the date. -->
|
||||
<receiver
|
||||
android:name="app.flexera.widget.ClockWidgetProvider"
|
||||
android:exported="false"
|
||||
android:label="@string/flexera_clock_widget">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/flexera_clock_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- The clock widget's options, a sheet over the home screen. -->
|
||||
<activity
|
||||
android:name="app.flexera.widget.ClockWidgetConfigActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Transparent" />
|
||||
|
||||
<!-- Opens Android's photo picker for the picture widget; it has no screen of its own. -->
|
||||
<activity
|
||||
android:name="app.flexera.widget.PictureWidgetConfigActivity"
|
||||
|
||||
+20
-10
@@ -251,23 +251,33 @@ $release = Read-Response ($client.PostAsync("$Api/releases", $content).Result) '
|
||||
|
||||
Step "6/7 Uploading the APK"
|
||||
|
||||
$assetName = "FlexeraLauncher-$Version.apk"
|
||||
$stream = [IO.File]::OpenRead($apk)
|
||||
# The APK goes up twice. Once under a name with its version, which is what a person looking at
|
||||
# the release sees and what the app's updater downloads. And once under a name that never
|
||||
# changes, so that <server>/<repo>/releases/download/latest/FlexeraLauncher.apk always leads to
|
||||
# the newest APK: the address in the QR code on the app's About screen (FlexeraUpdater.LATEST_APK_URL).
|
||||
# The versioned one goes first, because the updater takes the first .apk it finds.
|
||||
$assetNames = @("FlexeraLauncher-$Version.apk", 'FlexeraLauncher.apk')
|
||||
$uploaded = @()
|
||||
try {
|
||||
$file = New-Object System.Net.Http.StreamContent($stream)
|
||||
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
|
||||
$form = New-Object System.Net.Http.MultipartFormDataContent
|
||||
$form.Add($file, 'attachment', $assetName)
|
||||
$asset = Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) 'Uploading the APK'
|
||||
foreach ($assetName in $assetNames) {
|
||||
$stream = [IO.File]::OpenRead($apk)
|
||||
try {
|
||||
$file = New-Object System.Net.Http.StreamContent($stream)
|
||||
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
|
||||
$form = New-Object System.Net.Http.MultipartFormDataContent
|
||||
$form.Add($file, 'attachment', $assetName)
|
||||
$uploaded += Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) "Uploading $assetName"
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# A release without its APK would make every installed copy report a failed check.
|
||||
$client.DeleteAsync("$Api/releases/$($release.id)").Result | Out-Null
|
||||
$client.DeleteAsync("$Api/tags/v$Version").Result | Out-Null
|
||||
throw
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
Write-Host "Uploaded: $($asset.browser_download_url)"
|
||||
$uploaded | ForEach-Object { Write-Host "Uploaded: $($_.browser_download_url)" }
|
||||
|
||||
# --- 7. Source tag -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What Android is told the clock widget looks like before it has anything to show: a plain
|
||||
clock that keeps time by itself. Flexera Launcher never shows it; it draws the widget itself
|
||||
(app.flexera.widget.ClockWidgetView).
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<TextClock
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:format12Hour="h:mm"
|
||||
android:format24Hour="H:mm"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="48sp" />
|
||||
|
||||
<TextClock
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:format12Hour="EEEE, d MMMM"
|
||||
android:format24Hour="EEEE, d MMMM"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
@@ -66,7 +66,7 @@
|
||||
<string name="flexera_import_unreadable">This is not a Smart Launcher backup that can be read</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_share_qr_description">QR code that downloads the latest version</string>
|
||||
<string name="flexera_about_privacy">Privacy</string>
|
||||
<string name="flexera_about_privacy_title">Nothing about you leaves your phone</string>
|
||||
<string name="flexera_about_privacy_body">Flexera Launcher goes online for one thing: asking its release page whether a newer version exists, about once a day, and downloading that version when you ask. It sends nothing about you, your apps or your phone.</string>
|
||||
@@ -98,6 +98,20 @@
|
||||
<string name="flexera_quick_launch_first_page">Swipe past the first page</string>
|
||||
<string name="flexera_quick_launch_last_page">Swipe past the last page</string>
|
||||
|
||||
<!-- The clock widget that comes with the launcher (app.flexera.widget) -->
|
||||
<!-- Its name in the list of widgets -->
|
||||
<string name="flexera_clock_widget">Clock</string>
|
||||
<string name="flexera_clock_widget_description">The time and the date, lined up with your icons</string>
|
||||
<!-- The clock widget's options, reached from its edit button -->
|
||||
<string name="flexera_clock_widget_alignment">Alignment</string>
|
||||
<string name="flexera_clock_widget_left">Left</string>
|
||||
<string name="flexera_clock_widget_center">Center</string>
|
||||
<string name="flexera_clock_widget_right">Right</string>
|
||||
<string name="flexera_clock_widget_size">Size</string>
|
||||
<string name="flexera_clock_widget_small">Small</string>
|
||||
<string name="flexera_clock_widget_medium">Medium</string>
|
||||
<string name="flexera_clock_widget_large">Large</string>
|
||||
|
||||
<!-- The picture widget that comes with the launcher (app.flexera.widget) -->
|
||||
<!-- Its name in the list of widgets -->
|
||||
<string name="flexera_picture_widget">Pictures</string>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Flexera Launcher's clock widget (app.flexera.widget.ClockWidgetProvider). Four cells by two
|
||||
when first placed, resizable from two by one upwards. The launcher draws it itself, so the
|
||||
layouts named here are never shown on the home screen; previewLayout is what makes the widget
|
||||
list ask for a live preview, which the launcher also draws. Nothing has to be set up when it
|
||||
is added (configuration_optional); its edit button opens the options for alignment and size.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="40dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:updatePeriodMillis="0"
|
||||
android:description="@string/flexera_clock_widget_description"
|
||||
android:configure="app.flexera.widget.ClockWidgetConfigActivity"
|
||||
android:initialLayout="@layout/flexera_clock_widget_placeholder"
|
||||
android:previewLayout="@layout/flexera_clock_widget_placeholder"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:widgetFeatures="reconfigurable|configuration_optional" />
|
||||
@@ -200,7 +200,11 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
|
||||
groupRows(updateRows)
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
|
||||
item { PreferenceGroupItem { ShareCard(FlexeraUpdater.RELEASES_PAGE_URL) } }
|
||||
item {
|
||||
PreferenceGroupItem {
|
||||
ShareCard(scanUrl = FlexeraUpdater.LATEST_APK_URL, pageUrl = FlexeraUpdater.RELEASES_PAGE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
|
||||
groupRows(
|
||||
@@ -341,11 +345,12 @@ 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.
|
||||
* What the owner holds up for someone who wants the app: a QR code that downloads the newest
|
||||
* APK ([scanUrl]), with the address of the release page under it for anyone who would rather
|
||||
* type one ([pageUrl]); that one is shorter and shows all versions.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareCard(url: String) {
|
||||
private fun ShareCard(scanUrl: String, pageUrl: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -353,7 +358,7 @@ private fun ShareCard(url: String) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
QrCode(
|
||||
text = url,
|
||||
text = scanUrl,
|
||||
description = stringResource(id = R.string.flexera_about_share_qr_description),
|
||||
modifier = Modifier.size(QR_CODE_SIZE),
|
||||
)
|
||||
@@ -365,7 +370,7 @@ private fun ShareCard(url: String) {
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = url.removePrefix("https://"),
|
||||
text = pageUrl.removePrefix("https://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
|
||||
@@ -149,6 +149,13 @@ object FlexeraUpdater {
|
||||
/** The page listing every release, for the About screen's "All versions" link. */
|
||||
const val RELEASES_PAGE_URL = "$SERVER/$RELEASE_REPOSITORY/releases"
|
||||
|
||||
/**
|
||||
* Always the APK of the newest release: the server sends "latest" on to the newest release,
|
||||
* and every release carries its APK under this one name as well (flexera/release.ps1). What
|
||||
* the QR code on the About screen holds, so that scanning it starts the download.
|
||||
*/
|
||||
const val LATEST_APK_URL = "$SERVER/$RELEASE_REPOSITORY/releases/download/latest/FlexeraLauncher.apk"
|
||||
|
||||
private const val CONNECT_TIMEOUT_MS = 15_000
|
||||
private const val READ_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.flexera.widget.ClockWidgetStore.Align
|
||||
import app.flexera.widget.ClockWidgetStore.Size
|
||||
import app.lawnchair.ui.theme.EdgeToEdge
|
||||
import app.lawnchair.ui.theme.LawnchairTheme
|
||||
import com.android.launcher3.R
|
||||
|
||||
/**
|
||||
* The clock widget's options: where its text stands and how large its figures are. Reached from
|
||||
* the widget's edit button. It is a sheet over the home screen, which stays in view, and every
|
||||
* choice shows on the widget at once; closing the sheet is all there is to finishing.
|
||||
*/
|
||||
class ClockWidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val widget = intent?.getIntExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID,
|
||||
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
if (widget == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// The widget is fine with or without a visit here.
|
||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget))
|
||||
|
||||
setContent {
|
||||
LawnchairTheme {
|
||||
EdgeToEdge()
|
||||
var options by remember { mutableStateOf(ClockWidgetStore.options(this, widget)) }
|
||||
val change = { changed: ClockWidgetStore.Options ->
|
||||
options = changed
|
||||
ClockWidgetStore.set(this, widget, changed)
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { finish() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
// No dimming: the clock behind the sheet is what the choices are judged by.
|
||||
scrimColor = Color.Transparent,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.flexera_clock_widget),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Choice(
|
||||
heading = R.string.flexera_clock_widget_alignment,
|
||||
choices = listOf(
|
||||
Align.LEFT to R.string.flexera_clock_widget_left,
|
||||
Align.CENTER to R.string.flexera_clock_widget_center,
|
||||
Align.RIGHT to R.string.flexera_clock_widget_right,
|
||||
),
|
||||
chosen = options.align,
|
||||
onChoose = { change(options.copy(align = it)) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Choice(
|
||||
heading = R.string.flexera_clock_widget_size,
|
||||
choices = listOf(
|
||||
Size.SMALL to R.string.flexera_clock_widget_small,
|
||||
Size.MEDIUM to R.string.flexera_clock_widget_medium,
|
||||
Size.LARGE to R.string.flexera_clock_widget_large,
|
||||
),
|
||||
chosen = options.size,
|
||||
onChoose = { change(options.copy(size = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A heading with one row of choices under it, of which one is taken. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun <T> Choice(
|
||||
@StringRes heading: Int,
|
||||
choices: List<Pair<T, Int>>,
|
||||
chosen: T,
|
||||
onChoose: (T) -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = heading),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
choices.forEachIndexed { index, (value, label) ->
|
||||
SegmentedButton(
|
||||
selected = value == chosen,
|
||||
onClick = { onChoose(value) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
|
||||
) {
|
||||
Text(text = stringResource(id = label))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import com.android.launcher3.BuildConfig
|
||||
|
||||
/**
|
||||
* The launcher's own clock widget: the time, large, with the date under it. Android knows it as
|
||||
* an ordinary app widget; the launcher draws it itself with a [ClockWidgetView]. It needs no
|
||||
* setting up; what the owner may choose for it is kept in [ClockWidgetStore]. See [FlexeraWidgets].
|
||||
*/
|
||||
class ClockWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
appWidgetIds.forEach { ClockWidgetStore.delete(context, it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, ClockWidgetProvider::class.java.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
/**
|
||||
* What the owner has chosen for each clock widget on the home screen: where its text stands and
|
||||
* how large its figures are. Kept per widget, so that two clocks can differ.
|
||||
*/
|
||||
object ClockWidgetStore {
|
||||
|
||||
/** Which side of the widget the time and the date stand on. */
|
||||
enum class Align(val key: String) { LEFT("left"), CENTER("center"), RIGHT("right") }
|
||||
|
||||
/** How large the figures are, measured against the icons (see ClockWidgetView). */
|
||||
enum class Size(val key: String) { SMALL("small"), MEDIUM("medium"), LARGE("large") }
|
||||
|
||||
data class Options(val align: Align = Align.LEFT, val size: Size = Size.MEDIUM)
|
||||
|
||||
/** Told, on the thread that made the change, that a widget's options are different now. */
|
||||
fun interface Listener {
|
||||
fun onClockOptionsChanged(appWidgetId: Int)
|
||||
}
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<Listener>()
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
fun options(context: Context, appWidgetId: Int): Options {
|
||||
val prefs = prefs(context)
|
||||
val align = prefs.getString(ALIGN + appWidgetId, null)
|
||||
val size = prefs.getString(SIZE + appWidgetId, null)
|
||||
return Options(
|
||||
align = Align.entries.firstOrNull { it.key == align } ?: Align.LEFT,
|
||||
size = Size.entries.firstOrNull { it.key == size } ?: Size.MEDIUM,
|
||||
)
|
||||
}
|
||||
|
||||
fun set(context: Context, appWidgetId: Int, options: Options) {
|
||||
prefs(context).edit()
|
||||
.putString(ALIGN + appWidgetId, options.align.key)
|
||||
.putString(SIZE + appWidgetId, options.size.key)
|
||||
.apply()
|
||||
listeners.forEach { it.onClockOptionsChanged(appWidgetId) }
|
||||
}
|
||||
|
||||
/** Forgets the options of a widget that left the home screen. */
|
||||
fun delete(context: Context, appWidgetId: Int) {
|
||||
prefs(context).edit().remove(ALIGN + appWidgetId).remove(SIZE + appWidgetId).apply()
|
||||
}
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
private const val FILE = "flexera_clock_widget"
|
||||
private const val ALIGN = "align_"
|
||||
private const val SIZE = "size_"
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.provider.AlarmClock
|
||||
import android.text.TextPaint
|
||||
import android.text.format.DateFormat
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.flexera.widget.ClockWidgetStore.Align
|
||||
import app.flexera.widget.ClockWidgetStore.Size
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.util.Themes
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* What the clock widget ([ClockWidgetProvider]) looks like: the time in light figures and the
|
||||
* date under it, written straight onto the wallpaper in the colour of the icon labels.
|
||||
*
|
||||
* Like the picture widget it keeps to the box of the icons in its cells ([IconFrame]). The
|
||||
* owner chooses which side of that box the text stands on, or its middle, and how large the
|
||||
* figures are ([ClockWidgetStore], set from the widget's edit button). The sizes are measured
|
||||
* against the icons, so that the clock keeps its proportion to them on any grid: small figures
|
||||
* are about half an icon tall, medium ones most of an icon, large ones taller than an icon. The
|
||||
* widget's own height and width are the limit in every case.
|
||||
*
|
||||
* A tap opens the phone's clock app.
|
||||
*/
|
||||
class ClockWidgetView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs),
|
||||
ClockWidgetStore.Listener {
|
||||
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
/** True while this is only a sample of the widget, in the list of widgets. */
|
||||
private var sample = true
|
||||
private var options = ClockWidgetStore.Options()
|
||||
|
||||
private val frame = RectF()
|
||||
|
||||
/** What the figures are measured against: the height of what is seen of an icon. */
|
||||
private var iconHeight = 0f
|
||||
private val ink = Rect()
|
||||
private val now = Calendar.getInstance()
|
||||
private var time = ""
|
||||
private var date = ""
|
||||
|
||||
private val timePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = weight(TIME_WEIGHT, "sans-serif-light")
|
||||
// Figures of one width, so that the time does not shift sideways from minute to minute.
|
||||
fontFeatureSettings = "tnum"
|
||||
}
|
||||
private val datePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = weight(DATE_WEIGHT, "sans-serif-medium")
|
||||
}
|
||||
|
||||
// Where the two lines go; worked out in arrange().
|
||||
private var timeX = 0f
|
||||
private var timeBaseline = 0f
|
||||
private var dateX = 0f
|
||||
private var dateBaseline = 0f
|
||||
|
||||
private var listening = false
|
||||
private val clock = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) now.timeZone = TimeZone.getDefault()
|
||||
tell()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setWillNotDraw(false)
|
||||
// Says to Launcher3 that this widget shapes itself (see RoundedCornerEnforcement).
|
||||
id = android.R.id.background
|
||||
clipToOutline = true
|
||||
colour()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the home screen the text has the colour of the icon labels, which follows the
|
||||
* wallpaper. The list of widgets is a sheet of its own, so a sample there takes the sheet's
|
||||
* text colour.
|
||||
*/
|
||||
private fun colour() {
|
||||
val color = if (sample) {
|
||||
Themes.getAttrColor(context, android.R.attr.textColorPrimary)
|
||||
} else {
|
||||
Themes.getAttrColor(context, R.attr.workspaceTextColor)
|
||||
}
|
||||
timePaint.color = color
|
||||
datePaint.color = color
|
||||
// Light text on a wallpaper gets the soft shadow the icon labels have.
|
||||
if (!sample && Color.luminance(color) > 0.5f) {
|
||||
val density = resources.displayMetrics.density
|
||||
timePaint.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
|
||||
datePaint.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
|
||||
} else {
|
||||
timePaint.clearShadowLayer()
|
||||
datePaint.clearShadowLayer()
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the view which widget it shows. Ids that Android never hands out mean a sample. */
|
||||
fun bind(appWidgetId: Int) {
|
||||
this.appWidgetId = appWidgetId
|
||||
sample = appWidgetId <= 0
|
||||
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
|
||||
colour()
|
||||
if (sample) {
|
||||
// In the list of widgets a tap belongs to the list.
|
||||
setOnClickListener(null)
|
||||
isClickable = false
|
||||
} else {
|
||||
setOnClickListener { openClock() }
|
||||
}
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onClockOptionsChanged(appWidgetId: Int) {
|
||||
if (sample || appWidgetId != this.appWidgetId) return
|
||||
options = ClockWidgetStore.options(context, appWidgetId)
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
ClockWidgetStore.addListener(this)
|
||||
if (!listening) {
|
||||
val changes = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_TIME_TICK)
|
||||
addAction(Intent.ACTION_TIME_CHANGED)
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
addAction(Intent.ACTION_LOCALE_CHANGED)
|
||||
}
|
||||
ContextCompat.registerReceiver(context, clock, changes, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
listening = true
|
||||
}
|
||||
now.timeZone = TimeZone.getDefault()
|
||||
if (!sample) options = ClockWidgetStore.options(context, appWidgetId)
|
||||
tell()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
ClockWidgetStore.removeListener(this)
|
||||
if (listening) {
|
||||
context.unregisterReceiver(clock)
|
||||
listening = false
|
||||
}
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onVisibilityAggregated(isVisible: Boolean) {
|
||||
super.onVisibilityAggregated(isVisible)
|
||||
// Android does not announce the minutes to an app in the background; catch up.
|
||||
if (isVisible) tell()
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
val aligned = IconFrame.of(this)
|
||||
if (aligned != null) {
|
||||
frame.set(aligned.left, aligned.top, aligned.right, aligned.bottom)
|
||||
iconHeight = aligned.visibleIconSize
|
||||
} else {
|
||||
// In the list of widgets: the whole view with a little air, and a made-up icon.
|
||||
val margin = min(width, height) * LOOSE_MARGIN_SHARE
|
||||
frame.set(margin, margin, width - margin, height - margin)
|
||||
iconHeight = frame.height() * LOOSE_ICON_SHARE
|
||||
}
|
||||
arrange()
|
||||
}
|
||||
|
||||
/** Reads the clock and shows what it says. */
|
||||
private fun tell() {
|
||||
now.timeInMillis = System.currentTimeMillis()
|
||||
val locale = Locale.getDefault()
|
||||
// The phone's own choice of 12 or 24 hours, without "AM" and "PM": the date line and
|
||||
// the daylight say which half of the day it is.
|
||||
val hours = if (DateFormat.is24HourFormat(context)) "Hm" else "hm"
|
||||
val timePattern = DateFormat.getBestDateTimePattern(locale, hours).replace("a", "").trim()
|
||||
val datePattern = DateFormat.getBestDateTimePattern(locale, "EEEEdMMMM")
|
||||
val newTime = DateFormat.format(timePattern, now).toString()
|
||||
val newDate = DateFormat.format(datePattern, now).toString()
|
||||
if (newTime == time && newDate == date) return
|
||||
time = newTime
|
||||
date = newDate
|
||||
contentDescription = "$time, $date"
|
||||
arrange()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/** Sizes the two lines for the frame and the owner's choices, and works out where they go. */
|
||||
private fun arrange() {
|
||||
val width = frame.width()
|
||||
val height = frame.height()
|
||||
if (width <= 0f || height <= 0f || time.isEmpty()) return
|
||||
val scaled = resources.displayMetrics.scaledDensity
|
||||
|
||||
// How tall the figures would like to be.
|
||||
val wanted = iconHeight * when (options.size) {
|
||||
Size.SMALL -> SMALL_IN_ICONS
|
||||
Size.MEDIUM -> MEDIUM_IN_ICONS
|
||||
Size.LARGE -> LARGE_IN_ICONS
|
||||
}
|
||||
|
||||
// The date goes with the figures, within the sizes that read well, and is never wider
|
||||
// than the frame.
|
||||
var dateSize = (wanted * DATE_IN_FIGURES).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
|
||||
datePaint.textSize = dateSize
|
||||
val dateWidth = datePaint.measureText(date)
|
||||
if (dateWidth > width) {
|
||||
dateSize *= width / dateWidth
|
||||
datePaint.textSize = dateSize
|
||||
}
|
||||
val dateMetrics = datePaint.fontMetrics
|
||||
val dateHeight = dateMetrics.descent - dateMetrics.ascent
|
||||
val gap = dateSize * GAP_IN_DATE_SIZES
|
||||
|
||||
// The figures: as tall as wanted, unless the room above the date or the frame's width
|
||||
// is less. Measured on the figures themselves, not on the line they stand in.
|
||||
timePaint.textSize = MEASURE_SIZE
|
||||
timePaint.getTextBounds(time, 0, time.length, ink)
|
||||
val inkHeightAtMeasure = max(1, ink.height()).toFloat()
|
||||
val inkWidthAtMeasure = max(1, ink.width()).toFloat()
|
||||
val figures = min(wanted, min(height - dateHeight - gap, width * inkHeightAtMeasure / inkWidthAtMeasure))
|
||||
timePaint.textSize = max(1f, MEASURE_SIZE * figures / inkHeightAtMeasure)
|
||||
timePaint.getTextBounds(time, 0, time.length, ink)
|
||||
|
||||
// Both lines together, centred between the top and the bottom of the frame.
|
||||
val block = ink.height() + gap + dateHeight
|
||||
val top = frame.top + (height - block) / 2f
|
||||
timeBaseline = top - ink.top
|
||||
dateBaseline = top + ink.height() + gap - dateMetrics.ascent
|
||||
|
||||
// Sideways it is the figures' own edges that meet the frame's, not the space the font
|
||||
// leaves beside them.
|
||||
val dateLength = datePaint.measureText(date)
|
||||
when (options.align) {
|
||||
Align.LEFT -> {
|
||||
timeX = frame.left - ink.left
|
||||
dateX = frame.left
|
||||
}
|
||||
|
||||
Align.CENTER -> {
|
||||
timeX = frame.centerX() - ink.exactCenterX()
|
||||
dateX = frame.centerX() - dateLength / 2f
|
||||
}
|
||||
|
||||
Align.RIGHT -> {
|
||||
timeX = frame.right - ink.right
|
||||
dateX = frame.right - dateLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (frame.isEmpty || time.isEmpty()) return
|
||||
canvas.drawText(time, timeX, timeBaseline, timePaint)
|
||||
canvas.drawText(date, dateX, dateBaseline, datePaint)
|
||||
}
|
||||
|
||||
private fun openClock() {
|
||||
val alarms = Intent(AlarmClock.ACTION_SHOW_ALARMS)
|
||||
try {
|
||||
// The clock app's own front door; its alarm list needs a permission of its own.
|
||||
val clockApp = context.packageManager.resolveActivity(alarms, 0)?.activityInfo?.packageName
|
||||
val intent = clockApp?.let(context.packageManager::getLaunchIntentForPackage) ?: return
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "The clock app could not be opened", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun weight(weight: Int, family: String): Typeface =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Typeface.create(Typeface.DEFAULT, weight, false)
|
||||
} else {
|
||||
Typeface.create(family, Typeface.NORMAL)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ClockWidgetView"
|
||||
|
||||
const val TIME_WEIGHT = 300
|
||||
const val DATE_WEIGHT = 500
|
||||
|
||||
/**
|
||||
* How tall the figures are for each size, in icon heights. (The first version filled
|
||||
* the widget, about 1.7 icons on a widget two rows high, which the owner found huge.)
|
||||
*/
|
||||
const val SMALL_IN_ICONS = 0.5f
|
||||
const val MEDIUM_IN_ICONS = 0.8f
|
||||
const val LARGE_IN_ICONS = 1.25f
|
||||
|
||||
/** The date's text size as a share of the figures' height, and its limits. */
|
||||
const val DATE_IN_FIGURES = 0.26f
|
||||
const val DATE_MIN_SP = 12f
|
||||
const val DATE_MAX_SP = 18f
|
||||
|
||||
/** The space between the figures and the date, in date text sizes. */
|
||||
const val GAP_IN_DATE_SIZES = 0.6f
|
||||
|
||||
const val MEASURE_SIZE = 200f
|
||||
const val LOOSE_MARGIN_SHARE = 0.12f
|
||||
const val LOOSE_ICON_SHARE = 0.45f
|
||||
|
||||
const val SHADOW_RADIUS_DP = 3f
|
||||
const val SHADOW_DROP_DP = 1f
|
||||
const val SHADOW_COLOR = 0x59000000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.ComponentName
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
|
||||
/**
|
||||
* The widgets that come with the launcher: Pictures ([PictureWidgetProvider]) and Clock
|
||||
* ([ClockWidgetProvider]).
|
||||
*
|
||||
* Android knows each as an ordinary app widget, so they are added, moved, resized and removed
|
||||
* like any other. What they show is not sent through Android, though: the launcher draws them
|
||||
* itself with a view of its own, which is what lets them line up with the icons ([IconFrame]).
|
||||
* This is where Launcher3's widget code asks about them.
|
||||
*/
|
||||
object FlexeraWidgets {
|
||||
|
||||
private val noInset = Rect()
|
||||
|
||||
private fun isOwn(provider: ComponentName?): Boolean =
|
||||
provider == PictureWidgetProvider.componentName || provider == ClockWidgetProvider.componentName
|
||||
|
||||
/**
|
||||
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget the
|
||||
* same ([standard]); the launcher's own take none, because they place what they draw
|
||||
* against the icons of their cells by themselves.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, standard: Rect): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
|
||||
return if (isOwn(info.providerName)) noInset else standard
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the launcher's own view into a widget host view when the widget is one of the
|
||||
* launcher's, and returns it; returns null for every other widget. Launcher3's host view
|
||||
* calls this in place of asking Android for the widget's views.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun attachIfOwn(host: LauncherAppWidgetHostView, appWidgetId: Int, info: AppWidgetProviderInfo?): View? {
|
||||
val provider = info?.provider
|
||||
if (!isOwn(provider)) return null
|
||||
// A widget that still waits to be set up draws its own "tap to set up".
|
||||
if (host is PendingAppWidgetHostView) return null
|
||||
val pictures = provider == PictureWidgetProvider.componentName
|
||||
|
||||
var view: View? = null
|
||||
for (i in host.childCount - 1 downTo 0) {
|
||||
val child = host.getChildAt(i)
|
||||
val wanted = if (pictures) child is PictureWidgetView else child is ClockWidgetView
|
||||
if (wanted && view == null) view = child else host.removeViewAt(i)
|
||||
}
|
||||
if (view == null) {
|
||||
view = if (pictures) PictureWidgetView(host.context) else ClockWidgetView(host.context)
|
||||
host.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
when (view) {
|
||||
is PictureWidgetView -> view.bind(appWidgetId)
|
||||
is ClockWidgetView -> view.bind(appWidgetId)
|
||||
}
|
||||
return view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package app.flexera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.View
|
||||
import com.android.launcher3.ShortcutAndWidgetContainer
|
||||
import com.android.launcher3.celllayout.CellLayoutLayoutParams
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* The box around the icons that would sit in the cells a widget covers: from the left edge of
|
||||
* the first column's icon to the right edge of the last column's, and from the top of the first
|
||||
* row's icon to the bottom of the last row's. A widget of the launcher's own that keeps what it
|
||||
* draws inside this box lines up with the icons beside, above and below it.
|
||||
*/
|
||||
internal class IconFrame private constructor(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float,
|
||||
/** The size of the square an icon is drawn in. */
|
||||
val iconSize: Float,
|
||||
/** The size of what is seen of an icon; the rest of the square is room for its shadow. */
|
||||
val visibleIconSize: Float,
|
||||
) {
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* The box for the widget that [view] fills, in [view]'s own coordinates. Null when
|
||||
* [view] is not inside a widget on the home screen (in the list of widgets, say).
|
||||
*/
|
||||
fun of(view: View): IconFrame? {
|
||||
val host = view.parent as? View ?: return null
|
||||
val lp = host.layoutParams as? CellLayoutLayoutParams ?: return null
|
||||
val container = host.parent as? ShortcutAndWidgetContainer ?: return null
|
||||
val profile = activityContext(view.context)?.deviceProfile ?: return null
|
||||
if (lp.cellHSpan <= 0 || lp.cellVSpan <= 0) return null
|
||||
|
||||
// The cells the widget covers, measured from the corner of the host view. Undoes
|
||||
// what CellLayoutLayoutParams.setup took off them.
|
||||
val inset = FlexeraWidgets.cellInset(host, profile.widgetPadding)
|
||||
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
|
||||
val spanTop = -(lp.topMargin + inset.top).toFloat()
|
||||
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
|
||||
val spanHeight = (lp.height + lp.topMargin + lp.bottomMargin + inset.top + inset.bottom).toFloat()
|
||||
val gap = profile.cellLayoutBorderSpacePx
|
||||
val cellWidth = (spanWidth - (lp.cellHSpan - 1) * gap.x) / lp.cellHSpan
|
||||
val cellHeight = (spanHeight - (lp.cellVSpan - 1) * gap.y) / lp.cellVSpan
|
||||
|
||||
// Where an icon sits in its cell: centred sideways and a set distance below the
|
||||
// cell's top (ShortcutAndWidgetContainer.measureChild, BubbleTextView.getIconBounds).
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val iconLeft = (cellWidth - icon) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - min(container.measuredHeight, profile.cellHeightPx)) / 2f)
|
||||
}
|
||||
val visible = icon * ICON_VISIBLE_AREA_FACTOR
|
||||
val air = (icon - visible) / 2f
|
||||
|
||||
val l = spanLeft + iconLeft + air - view.left
|
||||
val r = spanLeft + spanWidth - iconLeft - air - view.left
|
||||
val t = spanTop + iconTop + air - view.top
|
||||
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - view.top
|
||||
if (r - l < 1f || b - t < 1f) return null
|
||||
return IconFrame(l, t, r, b, icon, visible)
|
||||
}
|
||||
|
||||
private fun activityContext(context: Context): ActivityContext? {
|
||||
var current: Context? = context
|
||||
while (current != null) {
|
||||
if (current is ActivityContext) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,7 @@ package app.flexera.widget
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import com.android.launcher3.BuildConfig
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
|
||||
/**
|
||||
* The launcher's own picture widget: one picture, or several that take turns.
|
||||
@@ -14,7 +11,7 @@ import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
* Android knows it as an ordinary app widget, so it is added, moved, resized and removed like
|
||||
* any other. What it shows is not sent through Android, though: the launcher draws it itself
|
||||
* with a [PictureWidgetView], which is what lets it line up with the icons around it. Its
|
||||
* pictures live in [PictureWidgetStore].
|
||||
* pictures live in [PictureWidgetStore]. See [FlexeraWidgets].
|
||||
*/
|
||||
class PictureWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
@@ -32,18 +29,5 @@ class PictureWidgetProvider : AppWidgetProvider() {
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, PictureWidgetProvider::class.java.name)
|
||||
|
||||
private val noInset = Rect()
|
||||
|
||||
/**
|
||||
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget
|
||||
* the same ([standard]); the picture widget takes none, because it places its picture
|
||||
* against the icons of its cells by itself.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, standard: Rect): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
|
||||
return if (info.providerName == componentName) noInset else standard
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
@@ -23,7 +22,6 @@ import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import app.lawnchair.icons.shape.CornerShapeCompat
|
||||
@@ -31,13 +29,7 @@ import app.lawnchair.icons.shape.IconShape
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.ShortcutAndWidgetContainer
|
||||
import com.android.launcher3.celllayout.CellLayoutLayoutParams
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.util.Executors
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
import java.io.File
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -365,50 +357,14 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
var corner = min(width, height) * LOOSE_CORNER_SHARE
|
||||
var iconSize = min(width, height).toFloat()
|
||||
|
||||
val host = parent as? View
|
||||
val lp = host?.layoutParams as? CellLayoutLayoutParams
|
||||
val container = host?.parent as? ShortcutAndWidgetContainer
|
||||
val profile = activityContext()?.deviceProfile
|
||||
if (host != null && lp != null && container != null && profile != null &&
|
||||
lp.cellHSpan > 0 && lp.cellVSpan > 0
|
||||
) {
|
||||
// The cells the widget covers, measured from the corner of the host view. Undoes
|
||||
// what CellLayoutLayoutParams.setup took off them.
|
||||
val inset = PictureWidgetProvider.cellInset(host, profile.widgetPadding)
|
||||
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
|
||||
val spanTop = -(lp.topMargin + inset.top).toFloat()
|
||||
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
|
||||
val spanHeight = (lp.height + lp.topMargin + lp.bottomMargin + inset.top + inset.bottom).toFloat()
|
||||
val gap = profile.cellLayoutBorderSpacePx
|
||||
val cellWidth = (spanWidth - (lp.cellHSpan - 1) * gap.x) / lp.cellHSpan
|
||||
val cellHeight = (spanHeight - (lp.cellVSpan - 1) * gap.y) / lp.cellVSpan
|
||||
|
||||
// Where an icon sits in its cell: centred sideways and a set distance below the
|
||||
// cell's top (ShortcutAndWidgetContainer.measureChild, BubbleTextView.getIconBounds).
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val iconLeft = (cellWidth - icon) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - min(container.measuredHeight, profile.cellHeightPx)) / 2f)
|
||||
}
|
||||
// What is seen of an icon is a little smaller than the square it is drawn in; the
|
||||
// rest is room for its shadow.
|
||||
val visible = icon * ICON_VISIBLE_AREA_FACTOR
|
||||
val air = (icon - visible) / 2f
|
||||
|
||||
val l = spanLeft + iconLeft + air - getLeft()
|
||||
val r = spanLeft + spanWidth - iconLeft - air - getLeft()
|
||||
val t = spanTop + iconTop + air - getTop()
|
||||
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - getTop()
|
||||
if (r - l >= 1f && b - t >= 1f) {
|
||||
left = l
|
||||
top = t
|
||||
right = r
|
||||
bottom = b
|
||||
corner = visible / 2f
|
||||
iconSize = icon
|
||||
}
|
||||
val aligned = IconFrame.of(this)
|
||||
if (aligned != null) {
|
||||
left = aligned.left
|
||||
top = aligned.top
|
||||
right = aligned.right
|
||||
bottom = aligned.bottom
|
||||
corner = aligned.visibleIconSize / 2f
|
||||
iconSize = aligned.iconSize
|
||||
}
|
||||
corner = min(corner, min(right - left, bottom - top) / 2f)
|
||||
|
||||
@@ -537,15 +493,6 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
|
||||
private fun currentIconShape(): IconShape = PreferenceManager2.getInstance(context).iconShape.firstCached()
|
||||
|
||||
private fun activityContext(): ActivityContext? {
|
||||
var current: Context? = context
|
||||
while (current != null) {
|
||||
if (current is ActivityContext) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PictureWidgetView"
|
||||
|
||||
@@ -580,38 +527,5 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
const val HINT_MAX_LINES = 2
|
||||
const val HINT_MAX_HEIGHT_SHARE = 0.4f
|
||||
const val HINT_CENTRE_SHARE = 0.36f
|
||||
|
||||
/**
|
||||
* Puts a [PictureWidgetView] into a widget host view when the widget is the picture
|
||||
* widget, and returns it; returns null for every other widget. Launcher3's host view
|
||||
* calls this in place of asking Android for the widget's views.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun attachIfOwn(
|
||||
host: LauncherAppWidgetHostView,
|
||||
appWidgetId: Int,
|
||||
info: AppWidgetProviderInfo?,
|
||||
): View? {
|
||||
if (info == null || info.provider != PictureWidgetProvider.componentName) return null
|
||||
// A widget that still waits to be set up draws its own "tap to set up".
|
||||
if (host is PendingAppWidgetHostView) return null
|
||||
var view: PictureWidgetView? = null
|
||||
for (i in host.childCount - 1 downTo 0) {
|
||||
val child = host.getChildAt(i)
|
||||
if (child is PictureWidgetView && view == null) view = child else host.removeViewAt(i)
|
||||
}
|
||||
if (view == null) {
|
||||
view = PictureWidgetView(host.context)
|
||||
host.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.bind(appWidgetId)
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.9.0
|
||||
versionName=0.10.0
|
||||
@@ -151,8 +151,8 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
final PointF appWidgetScale = profile.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the picture widget takes its cells whole.
|
||||
app.flexera.widget.PictureWidgetProvider.cellInset(child,
|
||||
// Flexera Launcher: the launcher's own widgets take their cells whole.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child,
|
||||
profile.widgetPadding));
|
||||
} else {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
@@ -179,8 +179,8 @@ public class ShortcutAndWidgetContainer extends ViewGroup implements FolderIcon.
|
||||
final PointF appWidgetScale = dp.getAppWidgetScale((ItemInfo) child.getTag());
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
appWidgetScale.x, appWidgetScale.y, mBorderSpace,
|
||||
// Flexera Launcher: the picture widget takes its cells whole.
|
||||
app.flexera.widget.PictureWidgetProvider.cellInset(child, dp.widgetPadding));
|
||||
// Flexera Launcher: the launcher's own widgets take their cells whole.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child, dp.widgetPadding));
|
||||
} else if (isChildQsb(child)) {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
|
||||
@@ -88,7 +88,7 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
private boolean mTrackingWidgetUpdate = false;
|
||||
|
||||
// Flexera Launcher: the view that draws this widget when it is one of the launcher's own
|
||||
// (the picture widget); null for every ordinary widget.
|
||||
// (app.flexera.widget.FlexeraWidgets); null for every ordinary widget.
|
||||
@Nullable
|
||||
private View mFlexeraOwnView;
|
||||
|
||||
@@ -162,9 +162,9 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView
|
||||
@Override
|
||||
public void setAppWidget(int appWidgetId, AppWidgetProviderInfo info) {
|
||||
super.setAppWidget(appWidgetId, info);
|
||||
// Flexera Launcher: the launcher's own picture widget is drawn by a view of the
|
||||
// launcher, not by views Android sends over; nothing more is loaded for it.
|
||||
mFlexeraOwnView = app.flexera.widget.PictureWidgetView.attachIfOwn(this, appWidgetId, info);
|
||||
// Flexera Launcher: the launcher's own widgets (pictures, clock) are drawn by a view of
|
||||
// the launcher, not by views Android sends over; nothing more is loaded for them.
|
||||
mFlexeraOwnView = app.flexera.widget.FlexeraWidgets.attachIfOwn(this, appWidgetId, info);
|
||||
if (mFlexeraOwnView != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user