15 Commits
Author SHA1 Message Date
TMC c93bb1df9c Release 0.14.0 2026-11-12 21:46:11 +01:00
TMCandClaude Opus 5.5 bfb70cab63 Make the update buttons stand out; install right after the download
The Updates section of About is now a card with the status on top and
the next step as a full-width filled button: Check for updates, Download
and install, Install now, or Try again. Before, each step was a plain
settings row that looked like every other row.

Download and install hands the APK to Android's installer as soon as the
download is done, without another tap. If Android first wants this app
to be allowed to install apps, its setting opens, and the installer
follows by itself when the owner comes back having allowed it. The
request is kept on disk for 30 minutes, so it survives Android ending
the process in between but does not pop an installer up hours later.

Tested on the emulator with a release build numbered 0.12.9: check,
download with progress, the Install unknown apps setting, the installer
opening by itself on return, and the update to 0.13.0 from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:45:01 +01:00
TMCandClaude Opus 5.5 4746d194f4 Website: no white lines between sections
The sections now meet with a plain change of colour: the white rule at
the bottom of the hero, story, details, privacy, playground and download
sections is gone, as is the white edge of the marquee bands (the tilted
pink band keeps a dark edge where it crosses the cyan one) and the white
line under the menu bar once the page scrolls. Lines inside sections
(cards, stats, FAQ) stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:31:13 +01:00
TMCandClaude Opus 5.5 12ec415e48 Website: fill the big background words instead of outlining them
Unbounded is a variable font whose letters are built from overlapping
shapes; an outline (text-stroke) traces every overlap, which drew stray
lines inside the hero's giant FLEXERA and the story's chapter number.
Both are now a faint solid fill (the hero's with a cyan-to-violet tint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:28:07 +01:00
TMCandClaude Opus 5.5 89fa6ef6ca Website: quick launch slides the camera in; no white flash
The camera now behaves as the page to the left of the first one: a swipe
right pushes the home screen (wallpaper included) to the right while the
camera follows the finger in from the left edge, and finishes the slide
when the finger lets go. Before, the home screen rubber-banded and the
camera was revealed by a clip from the edge. Same in the scroll story,
the playground gesture and its demo button; in the playground a short
swipe springs back, and a swipe left slides the camera out again.

The white flash at the end of the camera chapter was a shutter flash
over the whole screen; it read as a glitch while scrolling into the next
chapter. The shutter button now presses in instead.

Checked in Chrome: the camera chapters stepped through in 0.02 steps
show no brightness spike; the playground swipe in, swipe back, short
swipe and demo button; no console errors or sideways scrolling at
1440 x 900 and 390 x 844.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:25:36 +01:00
TMCandClaude Opus 5.5 d273543070 Add the landing page in WEBSITE
One page, English, plain HTML/CSS/JS with no build step and no third-party
requests: the four fonts (OFL) are served from the folder.

Brutal layout in the icon's colours (cyan, electric blue, violet, pink on
near black): hard 2px lines, solid slabs, offset shadows, marquee bands.
The centrepiece is a phone drawn in HTML at 390 x 844, used three times:
a looping teaser in the hero; a scroll-driven story of seven chapters
(home, swipe up, wave-alphabet scrub, folder blur, quick launch, clock
options, hide app) where the scroll position maps to a state, so it plays
both ways; and a playground that answers to touch and mouse (swipe up,
scrub, hold to hide with Undo, folder, quick launch, clock options, page
two, edge swipe home) plus buttons that run each demo.

Also: privacy section ("goes online for exactly two things"), download
with a QR code for the fixed-name latest APK, install steps, FAQ, link
preview image. Reduced motion shows each chapter's finished picture.
Checked in Chrome at 1440 x 900 and 390 x 844: no console errors, no
sideways scrolling, every playground gesture, and the QR as drawn scans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:13:33 +01:00
TMC c8da9f301c Release 0.13.0 2026-11-12 20:22:17 +01:00
TMCandClaude Fable 5.2 77278a11e0 Hide app from the app list's long-press menu; stop the restart on the first preference write
Hide app: a new entry in the menu a long press on an app opens in the app
list. It adds the app to Lawnchair's hidden list (hidden_apps), which the
list and its search already leave out and which Settings > App drawer >
Hidden apps already shows, so that page is where an app is brought back.
A message with Undo follows. The entry is offered in the app list only
(not for home screen icons, which hiding does not touch) and not while
the home screen is locked.

Preference collectors: five collectors in PreferenceManager2 (icon shape,
dock mode, dock on/off, dock search provider, labels in the dock) had
drop(1) before distinctUntilChanged. The first emission after the dropped
one then always passed, so the first write of any preference after the
launcher started reloaded the model, rebuilt the grid three times and
recreated the launcher. Hiding an app is such a write, made while the
launcher is on screen: the list went blank for a moment and the Undo
message was lost with the old activity. The operators are swapped, so
only a changed value passes.

Checked on the emulator: both menu layouts (labelled rows and the icon
row), hiding right after a fresh start (no relaunch in the log, message
stays), Undo, and unticking the apps under Hidden apps.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 20:21:59 +01:00
TMC 81c5c8914a Release 0.12.0 2026-11-12 18:05:19 +01:00
TMCandClaude Fable 5.2 3b7388c473 Blur behind open folders; hide the icons of a switched-off dock
Folder blur: while a folder is open on the home screen, what is behind it
is blurred, icons and widgets through a RenderEffect and the wallpaper
through the launcher surface's background blur. It is a new input of the
depth controller (folderBlur, 0 to 1), because that controller already
owns both kinds of blur and cleared anything set past it on every frame
of the folder's wallpaper zoom. The folder's spring animation drives it in
step with the dimming, and the places that reset the dimming reset it too.
It works with the wallpaper depth effect switched off, without bringing
the folder's wallpaper zoom along. Setting: Folders > Blur behind open
folders, on by default (flexera_folder_blur). Folders in the app list are
left as they were.

Dock: with the dock switched off the hotseat has no height, but its icons
were still drawn just below the screen edge, and the 97.5% zoom-out behind
an open folder brought their tops into view. One more alpha channel on
the icons hides them.

Checked on the emulator (Android 17 image, window blur available): open,
close by tap outside, Back and Home, an app started from the folder, sleep
and wake, the setting off, and the depth effect off.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 18:01:33 +01:00
TMC 4f687957fb Release 0.11.0 2026-11-12 16:34:02 +01:00
TMCandClaude Fable 5.2 b79579a8dd Clock widget: weather line, font weight, and centring in the widget's own frame
Weather: a clock for which the owner picks a place shows a third line, the
temperature and a word for the sky there. The place is looked up by name in the
clock's options; the data comes from Open-Meteo, which needs no account. It is
off until a place is picked, asks at most every 45 minutes while a clock with
weather is in view, sends only the place's coordinates rounded to about a
kilometre, and needs no location permission. The About screen's note on what
goes online says so.

Font weight: thin, light (as before), regular or bold figures.

Centring: the text stood in the middle of the icons' box, which lies in the
upper part of the widget's cells, so it looked too high inside the outline shown
while editing. It now stands in the middle of the widget's own cells, measured
on what is seen of the text.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 16:33:49 +01:00
TMC 516c4efc56 Release 0.10.0 2026-11-12 15:58:08 +01:00
TMCandClaude Fable 5.2 8e6b737894 Clock widget: smaller by default, with a choice of alignment and size
The first version filled its widget with the figures, which the owner found
huge. The figures are now measured against the icons: small is half an icon
tall, medium (the default) most of an icon, large taller than an icon, with the
widget's own height and width as the limit.

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

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:57:55 +01:00
TMCandClaude Fable 5.2 31ab0d52e1 Add the Clock widget; the QR code leads to the newest APK
Clock is a second widget that comes with the launcher: the time in large light
figures and the date under it, written onto the wallpaper in the colour of the
icon labels. It follows the phone's 12 or 24 hour setting, updates with the
minute, and a tap opens the phone's clock app. Like Pictures it is drawn by the
launcher itself and keeps to the box of the icons in its cells.

What Pictures and Clock share moved into two places: IconFrame works out that
box, and FlexeraWidgets is where Launcher3's widget code asks about the
launcher's own widgets.

The QR code on the About screen now holds the address of the newest APK, so
that scanning it starts the download; the release page's address stays under it
for typing. For that address to work, the release script uploads each APK a
second time under a name that does not change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:42:52 +01:00
51 changed files with 4823 additions and 218 deletions

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
# Flexera Launcher website
A one-page landing site for Flexera Launcher. Plain HTML, CSS and JavaScript: no build step, no
libraries, and nothing is loaded from anyone else's server.
| File | What it is |
| --- | --- |
| `index.html` | The page and all its copy |
| `styles.css` | Layout, colours and the phone's look |
| `script.js` | The phone model, the scroll story, the playground, the scroll effects |
| `assets/` | App icon (WebP and PNG favicons), QR code, link-preview image (`og.jpg`) |
| `fonts/` | Unbounded, Space Grotesk, JetBrains Mono and Outfit, Latin subset, each with its licence |
## Look at it
Open `index.html` in a browser. To see it as a server would serve it:
```sh
cd WEBSITE
python -m http.server 8080
```
and open <http://localhost:8080>.
## After a release
- Change `VERSION` at the top of `script.js`. The page fills every version number from it.
`index.html` carries the same number as a fallback for browsers without JavaScript; replace
`0.13.0` there too.
- The download buttons and the QR code point at
`.../releases/download/latest/FlexeraLauncher.apk`, the fixed-name copy that every release
uploads, so they never need changing.
- If the hero changes, retake `assets/og.jpg` (1200 x 630, the top of the page).
## The phone
The phone is drawn in HTML at 390 x 844 and scaled to fit, three times over: a looping teaser in
the hero, one driven by the scroll position through the seven "How it works" chapters
(`storyState()` in `script.js`), and one in the playground that answers to touch and mouse. All three
render the same state object, described in `blank()`. App names and icons on it are generic
stand-ins, not real apps.
## Fonts
All four are under the SIL Open Font License 1.1 (`fonts/*-OFL.txt`), downloaded from Google Fonts
and served from this folder.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="41" height="41" class="segno"><path class="qrline" stroke="currentColor" d="M0 0.5h7m2 0h2m1 0h1m2 0h2m2 0h3m1 0h1m4 0h1m2 0h2m1 0h7m-41 1h1m5 0h1m2 0h1m1 0h3m2 0h1m1 0h1m1 0h2m3 0h1m1 0h1m2 0h1m1 0h1m1 0h1m5 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m1 0h3m6 0h3m2 0h3m5 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m2 0h1m4 0h1m3 0h6m6 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m3 0h1m1 0h4m1 0h5m4 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-41 1h1m5 0h1m1 0h1m1 0h4m2 0h2m2 0h3m1 0h1m2 0h1m1 0h1m4 0h1m5 0h1m-41 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-33 1h1m1 0h2m3 0h1m4 0h3m1 0h1m1 0h3m-29 1h1m1 0h5m2 0h1m3 0h1m2 0h2m4 0h5m2 0h1m1 0h1m2 0h5m-38 1h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m1 0h1m1 0h7m1 0h3m-40 1h1m1 0h2m1 0h2m1 0h1m3 0h2m5 0h3m4 0h2m3 0h1m6 0h1m-39 1h3m4 0h1m2 0h1m3 0h2m1 0h2m1 0h4m1 0h3m2 0h2m3 0h2m1 0h1m-39 1h2m2 0h4m2 0h1m2 0h1m1 0h2m4 0h1m1 0h3m2 0h1m1 0h1m4 0h3m-38 1h1m1 0h1m1 0h1m1 0h10m1 0h1m1 0h4m1 0h2m1 0h1m2 0h2m1 0h7m-40 1h1m1 0h1m2 0h1m4 0h4m4 0h1m1 0h1m2 0h4m1 0h2m2 0h1m1 0h1m1 0h1m-37 1h1m5 0h3m2 0h1m1 0h1m3 0h6m2 0h1m1 0h1m2 0h1m2 0h2m1 0h1m2 0h1m-40 1h1m3 0h3m1 0h1m1 0h3m1 0h3m1 0h1m2 0h5m2 0h1m3 0h1m1 0h1m2 0h2m-40 1h4m1 0h1m2 0h1m4 0h3m1 0h5m1 0h1m1 0h2m1 0h5m1 0h4m1 0h2m-41 1h3m2 0h2m2 0h1m3 0h1m3 0h1m3 0h1m2 0h1m2 0h2m3 0h1m3 0h2m-37 1h1m1 0h1m4 0h1m1 0h3m2 0h4m1 0h2m6 0h1m1 0h1m1 0h3m1 0h2m-34 1h4m1 0h1m1 0h1m3 0h2m2 0h1m2 0h5m2 0h1m2 0h1m4 0h2m-39 1h1m1 0h1m5 0h1m5 0h7m2 0h1m3 0h14m-38 1h5m1 0h2m3 0h1m6 0h2m2 0h1m1 0h2m1 0h1m1 0h1m3 0h3m-37 1h2m1 0h1m7 0h1m1 0h2m2 0h1m1 0h4m2 0h2m3 0h1m2 0h1m1 0h1m-36 1h1m2 0h4m8 0h1m2 0h1m4 0h2m2 0h1m5 0h1m-35 1h1m2 0h2m2 0h1m2 0h2m2 0h1m3 0h3m1 0h1m4 0h1m2 0h1m1 0h5m2 0h1m-39 1h1m2 0h2m1 0h1m1 0h1m3 0h1m1 0h1m3 0h1m5 0h2m1 0h2m1 0h4m-36 1h2m1 0h1m1 0h1m1 0h2m2 0h1m1 0h4m3 0h2m1 0h1m2 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m-40 1h1m3 0h3m5 0h2m2 0h1m5 0h4m2 0h1m1 0h2m4 0h3m-40 1h1m3 0h2m4 0h4m4 0h1m2 0h1m1 0h2m3 0h1m1 0h5m1 0h2m2 0h1m-41 1h1m1 0h1m1 0h1m1 0h1m7 0h1m1 0h1m4 0h2m1 0h5m3 0h4m1 0h1m-38 1h1m3 0h2m4 0h2m2 0h2m5 0h1m1 0h2m1 0h3m3 0h3m1 0h2m-38 1h1m1 0h2m2 0h5m6 0h1m6 0h4m1 0h1m2 0h8m-32 1h2m3 0h2m4 0h2m2 0h1m8 0h1m3 0h1m2 0h2m-41 1h7m3 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m3 0h1m1 0h3m1 0h3m1 0h1m1 0h3m-39 1h1m5 0h1m1 0h1m1 0h2m1 0h1m1 0h2m2 0h5m2 0h1m4 0h2m3 0h1m3 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m1 0h1m3 0h1m1 0h4m1 0h5m1 0h1m2 0h5m1 0h2m-40 1h1m1 0h3m1 0h1m1 0h2m1 0h2m1 0h1m2 0h1m2 0h1m2 0h1m1 0h2m6 0h1m1 0h1m1 0h1m1 0h2m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m3 0h5m1 0h1m1 0h1m1 0h1m2 0h2m2 0h6m1 0h1m-39 1h1m5 0h1m2 0h3m2 0h3m1 0h1m1 0h2m1 0h1m2 0h2m2 0h1m1 0h1m1 0h1m1 0h2m1 0h1m-40 1h7m1 0h1m1 0h1m2 0h2m2 0h1m1 0h1m2 0h6m1 0h3m1 0h1m1 0h2m1 0h1"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+391
View File
@@ -0,0 +1,391 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Flexera Launcher · One thumb. Every app.</title>
<meta name="description" content="Flexera is an Android launcher with a calm home screen and an app list you scrub with one thumb. No ads, no account, no tracking.">
<meta name="theme-color" content="#07070d">
<meta property="og:type" content="website">
<meta property="og:title" content="Flexera Launcher · One thumb. Every app.">
<meta property="og:description" content="An Android launcher with a calm home screen and an app list you scrub with one thumb. No ads, no account, no tracking.">
<meta property="og:image" content="assets/og.jpg">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/png" sizes="64x64" href="assets/favicon-64.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png">
<link rel="apple-touch-icon" href="assets/icon-180.png">
<link rel="stylesheet" href="styles.css">
<script src="script.js" defer></script>
</head>
<body>
<!-- Line icons used by the phone and the page. 24 x 24, drawn with strokes. -->
<svg class="sprite" aria-hidden="true" focusable="false">
<symbol id="g-camera" viewBox="0 0 24 24"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></symbol>
<symbol id="g-phone" viewBox="0 0 24 24"><path d="M6.6 3.5l3 3.6-1.8 2.3a12 12 0 0 0 6.8 6.8l2.3-1.8 3.6 3-1.6 2.4c-.5.7-1.4 1-2.2.8C10.3 18.8 5.2 13.7 3.4 7.3c-.2-.8.1-1.7.8-2.2z"/></symbol>
<symbol id="g-msg" viewBox="0 0 24 24"><path d="M4 5h16v11H9.5L4 20z"/></symbol>
<symbol id="g-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5L12 13l8.5-6.5"/></symbol>
<symbol id="g-map" viewBox="0 0 24 24"><path d="M12 21s-6.5-6.2-6.5-11.2a6.5 6.5 0 0 1 13 0C18.5 14.8 12 21 12 21z"/><circle cx="12" cy="9.8" r="2.3"/></symbol>
<symbol id="g-music" viewBox="0 0 24 24"><path d="M9 18V5l11-2v13"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="17.5" cy="16" r="2.5"/></symbol>
<symbol id="g-cal" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M8 3v4M16 3v4"/></symbol>
<symbol id="g-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
<symbol id="g-notes" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14H6z"/><path d="M9 11h7M9 15h7M9 7h3"/></symbol>
<symbol id="g-files" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="g-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.5 2.6 3.5 5.5 3.5 8.5s-1 5.9-3.5 8.5c-2.5-2.6-3.5-5.5-3.5-8.5s1-5.9 3.5-8.5z"/></symbol>
<symbol id="g-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5V5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8"/></symbol>
<symbol id="g-wallet" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M15.5 14.5h2"/></symbol>
<symbol id="g-sliders" viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></symbol>
<symbol id="g-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></symbol>
<symbol id="g-pulse" viewBox="0 0 24 24"><path d="M3 12h4l2-4.5 3.5 9 2.5-4.5h6"/></symbol>
<symbol id="g-translate" viewBox="0 0 24 24"><path d="M4 5h9M8.5 3v2M6 5c.6 3 2.5 5.6 5 7M11 5c-.6 3.2-3 6-6.5 7.5"/><path d="M13 21l4-9 4 9M14.5 17.5h5"/></symbol>
<symbol id="g-calc" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8.5 7.5h7M8.5 12h.01M12 12h.01M15.5 12h.01M8.5 16.5h.01M12 16.5h.01M15.5 16.5h.01"/></symbol>
<symbol id="g-wave" viewBox="0 0 24 24"><path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/></symbol>
<symbol id="g-book" viewBox="0 0 24 24"><path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3z"/><path d="M5 17a3 3 0 0 1 3-3h11"/></symbol>
<symbol id="g-news" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h6M7 13h10M7 16h7"/></symbol>
<symbol id="g-play" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10 9v6l5-3z"/></symbol>
<symbol id="g-bag" viewBox="0 0 24 24"><path d="M5 8h14l-1 12H6z"/><path d="M9 8a3 3 0 0 1 6 0"/></symbol>
<symbol id="g-bank" viewBox="0 0 24 24"><path d="M3 9l9-5 9 5M5 10v7M9.5 10v7M14.5 10v7M19 10v7M3 20h18"/></symbol>
<symbol id="g-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 5-3 8.5-7 10-4-1.5-7-5-7-10V6z"/><path d="M9 12l2 2 4-4"/></symbol>
<symbol id="g-person" viewBox="0 0 24 24"><circle cx="12" cy="8.5" r="3.5"/><path d="M5 20c.8-3.8 3.6-6 7-6s6.2 2.2 7 6"/></symbol>
<symbol id="g-image" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 16l5-5 4 4 3-3 6 6"/><circle cx="15.5" cy="8.5" r="1.5"/></symbol>
<symbol id="g-plane" viewBox="0 0 24 24"><path d="M2.5 13.5l19-8-6.5 15-3-6.5z"/></symbol>
<symbol id="g-moon" viewBox="0 0 24 24"><path d="M19 14.5A7.5 7.5 0 1 1 9.5 5a6 6 0 0 0 9.5 9.5z"/></symbol>
<symbol id="g-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/></symbol>
<symbol id="g-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5.5M12 7.6v.01"/></symbol>
<symbol id="g-widgets" viewBox="0 0 24 24"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><path d="M16.5 3.5L20 7l-3.5 3.5L13 7z"/></symbol>
<symbol id="g-edit" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z"/></symbol>
<symbol id="g-eyeoff" viewBox="0 0 24 24"><path d="M9.9 5.2C10.6 5.07 11.3 5 12 5c4.2 0 7.7 2.6 9.5 7-.6 1.4-1.4 2.6-2.4 3.6M16.4 17.6C15.1 18.5 13.6 19 12 19c-4.2 0-7.7-2.6-9.5-7 .8-2 2-3.6 3.5-4.8"/><path d="M14.1 14.1A3 3 0 0 1 9.9 9.9"/><path d="M4 4l16 16"/></symbol>
<symbol id="g-flip" viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3"/><path d="M18 3v4h-4M6 21v-4h4"/></symbol>
<symbol id="g-arrow" viewBox="0 0 24 24"><path d="M4 12h15M13 6l6 6-6 6"/></symbol>
<symbol id="g-down" viewBox="0 0 24 24"><path d="M12 4v15M6 13l6 6 6-6"/></symbol>
<symbol id="g-up" viewBox="0 0 24 24"><path d="M12 20V5M6 11l6-6 6 6"/></symbol>
<symbol id="g-star" viewBox="0 0 24 24"><path class="fill" d="M12 1.5c.7 5.6 4.9 9.8 10.5 10.5-5.6.7-9.8 4.9-10.5 10.5-.7-5.6-4.9-9.8-10.5-10.5C7.1 11.3 11.3 7.1 12 1.5z"/></symbol>
</svg>
<a class="skip" href="#main">Skip to content</a>
<header class="nav" id="top">
<a class="nav__brand" href="#top" aria-label="Flexera Launcher, back to top">
<img src="assets/flexera-icon-256.webp" alt="" width="40" height="40">
<span>Flexera</span>
</a>
<nav class="nav__links" aria-label="Sections">
<a href="#how">How it works</a>
<a href="#details">Details</a>
<a href="#privacy">Privacy</a>
<a href="#play">Play</a>
</nav>
<a class="btn btn--sm btn--pink" href="#get">Get the APK</a>
<div class="nav__progress" aria-hidden="true"><i></i></div>
</header>
<main id="main">
<!-- HERO -->
<section class="hero" aria-labelledby="hero-title">
<div class="hero__ghost" aria-hidden="true" data-parallax="0.18">Flexera</div>
<div class="hero__grid">
<div class="hero__copy">
<p class="kicker rv"><span class="kicker__dot"></span>Android launcher <i>/</i> v<span data-version>0.13.0</span> <i>/</i> free</p>
<h1 class="hero__title rv" id="hero-title">
<span class="line"><span class="line__in">One thumb.</span></span>
<span class="line"><span class="line__in grad">Every app.</span></span>
</h1>
<p class="lead rv">Flexera is a home screen for Android that stays calm, with an app list you fly through using one thumb. No ads. No account. Nothing about you leaves your phone.</p>
<div class="cta rv">
<a class="btn btn--big btn--cyan" href="https://git.mariosem.es/msemes/Flexera-Launcher-Releases/releases/download/latest/FlexeraLauncher.apk" data-apk>
Download the APK <svg class="gly" aria-hidden="true"><use href="#g-down"/></svg>
</a>
<a class="btn btn--big btn--ghost" href="#how">
See it move <svg class="gly" aria-hidden="true"><use href="#g-arrow"/></svg>
</a>
</div>
<p class="meta rv">Android 8.0 or newer <i>·</i> Updates itself <i>·</i> No Play Store needed</p>
</div>
<div class="hero__stage" aria-hidden="true">
<div class="hero__slab"></div>
<div class="hero__tilt" data-tilt>
<div class="phone-host" data-phone="hero"></div>
</div>
<span class="sticker sticker--a">Wave<br>alphabet</span>
<span class="sticker sticker--b">0 trackers</span>
<div class="spinner">
<svg viewBox="0 0 120 120" class="spinner__ring">
<defs><path id="spin-path" d="M60,60 m-45,0 a45,45 0 1,1 90,0 a45,45 0 1,1 -90,0"/></defs>
<text><textPath href="#spin-path">Swipe up · Scrub · Launch · Repeat · </textPath></text>
</svg>
<svg class="gly spinner__arrow"><use href="#g-down"/></svg>
</div>
</div>
</div>
</section>
<div class="bands" aria-hidden="true">
<div class="marquee marquee--cyan">
<div class="marquee__track" data-marquee="70">
<span>One-handed</span><span>No ads</span><span>No account</span><span>No tracking</span><span>Wave alphabet</span><span>Blur folders</span><span>Quick launch</span><span>Updates itself</span>
</div>
</div>
<div class="marquee marquee--pink">
<div class="marquee__track" data-marquee="-55">
<span>Swipe up</span><span>Scrub</span><span>Launch</span><span>Repeat</span><span>Swipe up</span><span>Scrub</span><span>Launch</span><span>Repeat</span>
</div>
</div>
</div>
<!-- STORY -->
<section class="story" id="how" aria-labelledby="how-title">
<header class="section-head">
<p class="tag rv">[ 01 ] How it works</p>
<h2 class="h2 rv" id="how-title">
<span class="line"><span class="line__in">Keep scrolling.</span></span>
<span class="line"><span class="line__in grad">The phone talks.</span></span>
</h2>
</header>
<div class="story__body">
<div class="story__stage" aria-hidden="true">
<div class="story__num" data-story-num>01</div>
<div class="phone-host" data-phone="story"></div>
<ol class="story__rail" data-story-rail>
<li></li><li></li><li></li><li></li><li></li><li></li><li></li>
</ol>
</div>
<div class="story__steps">
<article class="step" data-step>
<div class="step__card">
<p class="step__no">01 / 07</p>
<h3 class="step__title">Home, minus the noise.</h3>
<p class="step__text">Pages, folders and widgets, in the Android layout you already know. No feed, no ads, nothing you didn't put there yourself.</p>
<p class="step__gesture"><span>Gesture</span> None. Just look.</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">02 / 07</p>
<h3 class="step__title">Swipe up. A&nbsp;to&nbsp;Z.</h3>
<p class="step__text">Every app in one clean column, straight over your wallpaper. New messages show up under the app's name, so you see them before you open anything.</p>
<p class="step__gesture"><span>Gesture</span> Swipe up</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">03 / 07</p>
<h3 class="step__title">Ride the wave.</h3>
<p class="step__text">Slide your thumb along the letters on the edge. They swell around it like a wave, and the list drops that letter into the middle of the screen, right where your thumb already is. Swipe up and keep holding to scrub straight from home.</p>
<p class="step__gesture"><span>Gesture</span> Slide on the edge</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">04 / 07</p>
<h3 class="step__title">Folders in focus.</h3>
<p class="step__text">Open a folder and the rest of the screen melts into a soft blur, wallpaper included. Just the apps you came for.</p>
<p class="step__gesture"><span>Gesture</span> Tap</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">05 / 07</p>
<h3 class="step__title">Swipe into nothing.</h3>
<p class="step__text">There's no page left of your first one. Swipe there anyway and your camera opens. Or any app, or any shortcut you pick. Works past your last page too.</p>
<p class="step__gesture"><span>Gesture</span> Swipe past the edge</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">06 / 07</p>
<h3 class="step__title">Widgets that fit in.</h3>
<p class="step__text">A clock that lines up with your icons: the date, a one-line forecast, left, center or right, thin to bold. Next to it, a picture frame in your icon shape that turns to the next photo every ten seconds.</p>
<p class="step__gesture"><span>Gesture</span> Hold, then edit</p>
</div>
</article>
<article class="step" data-step>
<div class="step__card">
<p class="step__no">07 / 07</p>
<h3 class="step__title">Out of sight.</h3>
<p class="step__text">Hold any app in the list and tap Hide app. Gone from the list and from search. Changed your mind? Undo, or bring it back from Settings whenever you like.</p>
<p class="step__gesture"><span>Gesture</span> Hold, then hide</p>
</div>
</article>
</div>
</div>
</section>
<!-- DETAILS -->
<section class="details" id="details" aria-labelledby="details-title">
<header class="section-head">
<p class="tag rv">[ 02 ] The details</p>
<h2 class="h2 rv" id="details-title">
<span class="line"><span class="line__in">Small things.</span></span>
<span class="line"><span class="line__in grad">Done properly.</span></span>
</h2>
</header>
<div class="cards">
<article class="card card--ink rv">
<div class="card__art art-flip" aria-hidden="true"><i class="art-flip__phone"><b>A B C D E F G</b></i></div>
<span class="card__no">01</span>
<h3>Lefty? Flip it.</h3>
<p>Move the alphabet to the left edge. One switch, and the whole list follows your other thumb.</p>
</article>
<article class="card card--cyan rv">
<div class="card__art art-rows" aria-hidden="true"><i class="art-rows__col"><b></b><b></b><b></b><b></b><b></b></i><i class="art-rows__col art-rows__col--comfy"><b></b><b></b><b></b></i></div>
<span class="card__no">02</span>
<h3>Compact or comfy.</h3>
<p>Pick how tall the rows in your app list are. More apps on screen, or bigger targets.</p>
</article>
<article class="card card--violet rv">
<div class="card__art art-update" aria-hidden="true"><span class="art-update__v">v<span data-version>0.13.0</span></span><i class="art-update__bar"><b></b></i></div>
<span class="card__no">03</span>
<h3>Updates itself.</h3>
<p>About once a day it checks for a new version and installs it when you say so. No store needed.</p>
</article>
<article class="card card--pink rv">
<div class="card__art art-move" aria-hidden="true"><i class="art-move__box">SL</i><svg class="gly art-move__arrow"><use href="#g-arrow"/></svg><i class="art-move__box art-move__box--f"><img src="assets/flexera-icon-256.webp" alt="" width="44" height="44"></i></div>
<span class="card__no">04</span>
<h3>Move in, one tap.</h3>
<p>Coming from Smart Launcher? Import your home screen from its backup: layout, folders and your photo widget.</p>
</article>
<article class="card card--ink rv">
<div class="card__art art-shapes" aria-hidden="true"><i></i><i></i><i></i></div>
<span class="card__no">05</span>
<h3>Make it yours.</h3>
<p>Icon packs, icon shapes, themed icons and colors. Labels on or off. Your grid, your rules.</p>
</article>
<article class="card card--cyan rv">
<div class="card__art art-tap" aria-hidden="true"><i></i><i></i></div>
<span class="card__no">06</span>
<h3>Gestures for everything.</h3>
<p>Double-tap to lock, swipe down for notifications, and more. Every gesture is yours to set.</p>
</article>
</div>
<div class="stats">
<div class="stat rv"><b>0</b><span>Ads</span></div>
<div class="stat rv"><b>0</b><span>Accounts</span></div>
<div class="stat rv"><b>2</b><span>Reasons to<br>go online</span></div>
<div class="stat rv"><b>1</b><span>Thumb<br>needed</span></div>
</div>
</section>
<!-- PRIVACY -->
<section class="privacy" id="privacy" aria-labelledby="privacy-title">
<p class="tag tag--ink rv">[ 03 ] Privacy</p>
<h2 class="privacy__title rv" id="privacy-title">It goes online for <span class="privacy__hl">exactly two</span> things.</h2>
<div class="privacy__list">
<div class="pitem rv">
<span class="pitem__no">1</span>
<h3>Updates</h3>
<p>About once a day it asks its own release page whether there's a newer version. Installing it is your call.</p>
</div>
<div class="pitem rv">
<span class="pitem__no">2</span>
<h3>Weather, if you ask</h3>
<p>Pick a town for the clock's weather line and it asks Open-Meteo about that place. Only that place. It never uses your location.</p>
</div>
</div>
<p class="privacy__end rv">Nothing else about you, your apps or your phone leaves it.</p>
<p class="privacy__note rv">This page plays by the same rules: no cookies, no analytics, no fonts or scripts from anyone else.</p>
</section>
<!-- PLAYGROUND -->
<section class="play" id="play" aria-labelledby="play-title">
<header class="section-head">
<p class="tag rv">[ 04 ] Playground</p>
<h2 class="h2 rv" id="play-title">
<span class="line"><span class="line__in">Go on.</span></span>
<span class="line"><span class="line__in grad">Touch it.</span></span>
</h2>
</header>
<div class="play__grid">
<div class="play__stage">
<div class="phone-host" data-phone="play" aria-label="A working model of Flexera Launcher. Use the buttons beside it if you can't use touch or a mouse." role="img"></div>
</div>
<div class="play__side">
<p class="play__caption" aria-live="polite" data-caption>Swipe up on the home screen to open your apps.</p>
<div class="chips" role="group" aria-label="Show me">
<button class="chip" type="button" data-demo="drawer"><svg class="gly" aria-hidden="true"><use href="#g-up"/></svg>Swipe up</button>
<button class="chip" type="button" data-demo="scrub"><svg class="gly" aria-hidden="true"><use href="#g-wave"/></svg>Scrub the letters</button>
<button class="chip" type="button" data-demo="folder"><svg class="gly" aria-hidden="true"><use href="#g-files"/></svg>Open a folder</button>
<button class="chip" type="button" data-demo="hide"><svg class="gly" aria-hidden="true"><use href="#g-eyeoff"/></svg>Hide an app</button>
<button class="chip" type="button" data-demo="quick"><svg class="gly" aria-hidden="true"><use href="#g-camera"/></svg>Quick launch</button>
<button class="chip chip--ghost" type="button" data-demo="reset">Reset</button>
</div>
<ul class="howto">
<li><b>Swipe up</b> on the home screen</li>
<li><b>Drag</b> along the letters on the right</li>
<li><b>Tap</b> a folder, then outside it</li>
<li><b>Hold</b> an app in the list</li>
<li><b>Swipe right</b> on the first page</li>
<li><b>Hold</b> the clock to restyle it</li>
<li><b>Swipe up</b> from the bottom edge to go home</li>
</ul>
</div>
</div>
</section>
<!-- GET IT -->
<section class="get" id="get" aria-labelledby="get-title">
<p class="tag tag--ink rv">[ 05 ] Get it</p>
<h2 class="get__title rv" id="get-title">
<span class="line"><span class="line__in">Get</span></span>
<span class="line"><span class="line__in">Flexera.</span></span>
</h2>
<div class="get__grid">
<figure class="qr rv">
<svg class="qr__code" viewBox="-4 -4 49 49" role="img" aria-label="QR code that downloads the newest Flexera Launcher APK"><path class="qrline" stroke="currentColor" d="M0 0.5h7m2 0h2m1 0h1m2 0h2m2 0h3m1 0h1m4 0h1m2 0h2m1 0h7m-41 1h1m5 0h1m2 0h1m1 0h3m2 0h1m1 0h1m1 0h2m3 0h1m1 0h1m2 0h1m1 0h1m1 0h1m5 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m1 0h3m6 0h3m2 0h3m5 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m2 0h1m4 0h1m3 0h6m6 0h1m1 0h3m1 0h1m-41 1h1m1 0h3m1 0h1m1 0h1m3 0h1m1 0h4m1 0h5m4 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-41 1h1m5 0h1m1 0h1m1 0h4m2 0h2m2 0h3m1 0h1m2 0h1m1 0h1m4 0h1m5 0h1m-41 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-33 1h1m1 0h2m3 0h1m4 0h3m1 0h1m1 0h3m-29 1h1m1 0h5m2 0h1m3 0h1m2 0h2m4 0h5m2 0h1m1 0h1m2 0h5m-38 1h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m1 0h1m1 0h7m1 0h3m-40 1h1m1 0h2m1 0h2m1 0h1m3 0h2m5 0h3m4 0h2m3 0h1m6 0h1m-39 1h3m4 0h1m2 0h1m3 0h2m1 0h2m1 0h4m1 0h3m2 0h2m3 0h2m1 0h1m-39 1h2m2 0h4m2 0h1m2 0h1m1 0h2m4 0h1m1 0h3m2 0h1m1 0h1m4 0h3m-38 1h1m1 0h1m1 0h1m1 0h10m1 0h1m1 0h4m1 0h2m1 0h1m2 0h2m1 0h7m-40 1h1m1 0h1m2 0h1m4 0h4m4 0h1m1 0h1m2 0h4m1 0h2m2 0h1m1 0h1m1 0h1m-37 1h1m5 0h3m2 0h1m1 0h1m3 0h6m2 0h1m1 0h1m2 0h1m2 0h2m1 0h1m2 0h1m-40 1h1m3 0h3m1 0h1m1 0h3m1 0h3m1 0h1m2 0h5m2 0h1m3 0h1m1 0h1m2 0h2m-40 1h4m1 0h1m2 0h1m4 0h3m1 0h5m1 0h1m1 0h2m1 0h5m1 0h4m1 0h2m-41 1h3m2 0h2m2 0h1m3 0h1m3 0h1m3 0h1m2 0h1m2 0h2m3 0h1m3 0h2m-37 1h1m1 0h1m4 0h1m1 0h3m2 0h4m1 0h2m6 0h1m1 0h1m1 0h3m1 0h2m-34 1h4m1 0h1m1 0h1m3 0h2m2 0h1m2 0h5m2 0h1m2 0h1m4 0h2m-39 1h1m1 0h1m5 0h1m5 0h7m2 0h1m3 0h14m-38 1h5m1 0h2m3 0h1m6 0h2m2 0h1m1 0h2m1 0h1m1 0h1m3 0h3m-37 1h2m1 0h1m7 0h1m1 0h2m2 0h1m1 0h4m2 0h2m3 0h1m2 0h1m1 0h1m-36 1h1m2 0h4m8 0h1m2 0h1m4 0h2m2 0h1m5 0h1m-35 1h1m2 0h2m2 0h1m2 0h2m2 0h1m3 0h3m1 0h1m4 0h1m2 0h1m1 0h5m2 0h1m-39 1h1m2 0h2m1 0h1m1 0h1m3 0h1m1 0h1m3 0h1m5 0h2m1 0h2m1 0h4m-36 1h2m1 0h1m1 0h1m1 0h2m2 0h1m1 0h4m3 0h2m1 0h1m2 0h1m1 0h1m1 0h4m1 0h1m1 0h1m2 0h1m-40 1h1m3 0h3m5 0h2m2 0h1m5 0h4m2 0h1m1 0h2m4 0h3m-40 1h1m3 0h2m4 0h4m4 0h1m2 0h1m1 0h2m3 0h1m1 0h5m1 0h2m2 0h1m-41 1h1m1 0h1m1 0h1m1 0h1m7 0h1m1 0h1m4 0h2m1 0h5m3 0h4m1 0h1m-38 1h1m3 0h2m4 0h2m2 0h2m5 0h1m1 0h2m1 0h3m3 0h3m1 0h2m-38 1h1m1 0h2m2 0h5m6 0h1m6 0h4m1 0h1m2 0h8m-32 1h2m3 0h2m4 0h2m2 0h1m8 0h1m3 0h1m2 0h2m-41 1h7m3 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m3 0h1m1 0h3m1 0h3m1 0h1m1 0h3m-39 1h1m5 0h1m1 0h1m1 0h2m1 0h1m1 0h2m2 0h5m2 0h1m4 0h2m3 0h1m3 0h1m-41 1h1m1 0h3m1 0h1m1 0h3m1 0h1m3 0h1m1 0h4m1 0h5m1 0h1m2 0h5m1 0h2m-40 1h1m1 0h3m1 0h1m1 0h2m1 0h2m1 0h1m2 0h1m2 0h1m2 0h1m1 0h2m6 0h1m1 0h1m1 0h1m1 0h2m-41 1h1m1 0h3m1 0h1m1 0h1m1 0h1m3 0h5m1 0h1m1 0h1m1 0h1m2 0h2m2 0h6m1 0h1m-39 1h1m5 0h1m2 0h3m2 0h3m1 0h1m1 0h2m1 0h1m2 0h2m2 0h1m1 0h1m1 0h1m1 0h2m1 0h1m-40 1h7m1 0h1m1 0h1m2 0h2m2 0h1m1 0h1m2 0h6m1 0h3m1 0h1m1 0h2m1 0h1"/></svg>
<figcaption>Scan with your phone's camera</figcaption>
</figure>
<div class="get__main">
<a class="btn btn--huge btn--pink rv" href="https://git.mariosem.es/msemes/Flexera-Launcher-Releases/releases/download/latest/FlexeraLauncher.apk" data-apk>
Download the APK <svg class="gly" aria-hidden="true"><use href="#g-down"/></svg>
</a>
<p class="meta meta--ink rv">Version <span data-version>0.13.0</span> <i>·</i> Android 8.0 or newer <i>·</i> Free, no ads</p>
<ol class="steps rv">
<li><b>Download</b> the APK on your Android phone.</li>
<li><b>Open it.</b> When Android asks, let your browser install apps.</li>
<li><b>Make it home.</b> Press Home and pick Flexera, or go to Settings &rarr; Apps &rarr; Default apps &rarr; Home app.</li>
</ol>
<a class="textlink rv" href="https://git.mariosem.es/msemes/Flexera-Launcher-Releases/releases">Every release and its notes <svg class="gly" aria-hidden="true"><use href="#g-arrow"/></svg></a>
</div>
</div>
<div class="faq">
<details class="rv">
<summary>Why isn't it on Google Play?</summary>
<p>It's a small independent project, shipped straight from its own release page. The app checks that page for new versions, so you won't miss updates.</p>
</details>
<details class="rv">
<summary>Android warns me before installing. Is that normal?</summary>
<p>Yes. Android double-checks every app installed from outside the Play Store. Play Protect may also mention that the app can ask for notification access (for notification dots and previews) and accessibility (only for gestures such as double-tap to lock). Both stay off until you turn them on yourself.</p>
</details>
<details class="rv">
<summary>Will it work on my phone?</summary>
<p>It runs on Android 8.0 and newer. The blur behind folders needs Android 12 or newer; on older versions folders open over a plain dimmed background.</p>
</details>
<details class="rv">
<summary>Can I go back to my old launcher?</summary>
<p>Any time. Settings &rarr; Apps &rarr; Default apps &rarr; Home app, and pick the old one. Nothing is deleted.</p>
</details>
<details class="rv">
<summary>What does it cost?</summary>
<p>Nothing. No ads, no in-app purchases, no account.</p>
</details>
</div>
</section>
</main>
<footer class="foot">
<div class="foot__word" aria-hidden="true">Flexera</div>
<div class="foot__row">
<p>Flexera Launcher <span data-version>0.13.0</span></p>
<p>Built on Lawnchair and Android's Launcher3.</p>
<p>&copy; 2026 Flexera Launcher</p>
<a class="textlink" href="#top">Back to top <svg class="gly" aria-hidden="true"><use href="#g-up"/></svg></a>
</div>
</footer>
</body>
</html>
+1284
View File
File diff suppressed because it is too large. Load diff
+754
View File
@@ -0,0 +1,754 @@
/* Flexera Launcher landing page.
Brutal layout (hard 2px lines, solid slabs, offset shadows) in the app icon's neon colours.
Fonts are served from this folder: nothing is loaded from anyone else. */
@font-face { font-family: "Unbounded"; src: url("fonts/unbounded.woff2") format("woff2"); font-weight: 300 900; font-display: swap; }
@font-face { font-family: "Space Grotesk"; src: url("fonts/space-grotesk.woff2") format("woff2"); font-weight: 300 700; font-display: swap; }
@font-face { font-family: "JetBrains Mono"; src: url("fonts/jetbrains-mono.woff2") format("woff2"); font-weight: 400 800; font-display: swap; }
@font-face { font-family: "Outfit"; src: url("fonts/outfit.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
:root {
--bg: #07070d;
--bg-2: #0e0d1d;
--fg: #f5f4ff;
--dim: #b4b2d2;
--ink: #07070d;
/* From the icon: ice and cyan at the top of the F, electric blue, deep indigo, violet, hot pink. */
--ice: #76fafc;
--cyan: #1ddbfd;
--blue: #417df4;
--indigo: #0d0b72;
--violet: #653df0;
--purple: #9c0ff6;
--pink: #fc59fb;
--grad: linear-gradient(95deg, #76fafc 0%, #1ddbfd 16%, #417df4 40%, #653df0 64%, #c866fa 82%, #fc59fb 100%);
--display: "Unbounded", "Arial Black", system-ui, sans-serif;
--body: "Space Grotesk", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", monospace;
--ui: "Outfit", system-ui, sans-serif;
--pad: clamp(16px, 4vw, 56px);
--b: 2px;
--max: 1520px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 400 17px/1.55 var(--body);
-webkit-font-smoothing: antialiased;
overflow-x: clip;
}
/* Film grain over everything, very faint. */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 90;
opacity: .055;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
img, svg { display: block; }
a { color: inherit; }
::selection { background: var(--pink); color: var(--ink); }
:focus-visible { outline: 3px solid var(--cyan); outline-offset: 3px; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.gly { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.gly .fill, .fill { fill: currentColor; stroke: none; }
.skip {
position: absolute; left: 12px; top: -60px; z-index: 200;
background: var(--cyan); color: var(--ink); padding: 10px 14px; font: 700 14px var(--mono);
border: var(--b) solid var(--ink); text-decoration: none;
}
.skip:focus { top: 12px; }
/* ---------- type helpers ---------- */
.grad {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tag {
margin: 0;
font: 700 13px/1 var(--mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--cyan);
}
.tag--ink { color: var(--ink); }
.h2 {
margin: .35em 0 0;
font: 900 clamp(40px, 7.2vw, 118px)/.9 var(--display);
letter-spacing: -.04em;
text-transform: uppercase;
}
/* Lines that slide up out of a mask when their heading scrolls into view. */
.line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.line__in { display: block; transform: translateY(108%); transition: transform 1s cubic-bezier(.2, .8, .1, 1); }
.line + .line .line__in { transition-delay: .09s; }
.in .line__in { transform: none; }
.rv:not(.hero__title):not(.h2):not(.get__title) { opacity: 0; transform: translateY(26px); transition: opacity .8s ease, transform .9s cubic-bezier(.2, .8, .1, 1); }
.rv.in:not(.hero__title):not(.h2):not(.get__title) { opacity: 1; transform: none; }
.meta { margin: 18px 0 0; font: 500 13px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
.meta i { font-style: normal; color: var(--pink); margin: 0 .35em; }
.meta--ink { color: var(--ink); }
.meta--ink i { color: var(--ink); }
/* ---------- buttons ---------- */
.btn {
--c: var(--cyan);
--sh: var(--fg);
display: inline-flex;
align-items: center;
gap: .7em;
padding: 1.05em 1.45em;
font: 800 15px/1 var(--display);
letter-spacing: .01em;
text-transform: uppercase;
text-decoration: none;
color: var(--ink);
background: var(--c);
border: var(--b) solid var(--fg);
box-shadow: 6px 6px 0 var(--sh);
transition: transform .14s ease, box-shadow .14s ease, background .2s;
cursor: pointer;
}
.btn .gly { width: 1.25em; height: 1.25em; stroke-width: 2.6; }
.btn:hover { transform: translate(-3px, -3px); box-shadow: 9px 9px 0 var(--sh); }
.btn:active { transform: translate(4px, 4px); box-shadow: 2px 2px 0 var(--sh); }
.btn--pink { --c: var(--pink); }
.btn--cyan { --c: var(--cyan); }
.btn--ghost { background: transparent; color: var(--fg); --sh: var(--violet); }
.btn--sm { padding: .8em 1.05em; font-size: 12px; box-shadow: 4px 4px 0 var(--sh); }
.btn--sm:hover { box-shadow: 6px 6px 0 var(--sh); }
.btn--big { font-size: clamp(14px, 1.1vw, 17px); }
.btn--huge { font-size: clamp(18px, 2.2vw, 30px); padding: 1em 1.3em; border-color: var(--ink); --sh: var(--ink); }
.textlink {
display: inline-flex; align-items: center; gap: .5em;
font: 700 14px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
text-decoration: none; border-bottom: var(--b) solid currentColor; padding-bottom: 4px;
}
.textlink:hover { color: var(--pink); }
/* ---------- nav ---------- */
.nav {
position: fixed; inset: 0 0 auto 0; z-index: 80;
display: flex; align-items: center; gap: 28px;
padding: 14px var(--pad);
transition: background .3s, border-color .3s;
border-bottom: var(--b) solid transparent;
}
.nav.is-scrolled { background: rgba(7, 7, 13, .86); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
.nav__brand { display: flex; align-items: center; gap: 12px; text-decoration: none; font: 900 18px/1 var(--display); text-transform: uppercase; letter-spacing: -.02em; }
.nav__brand img { width: 40px; height: 40px; }
.nav__links { display: flex; gap: 26px; margin-left: auto; }
.nav__links a { font: 600 13px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; text-decoration: none; padding: 6px 0; border-bottom: var(--b) solid transparent; }
.nav__links a:hover { border-bottom-color: var(--cyan); color: var(--cyan); }
.nav__progress { position: absolute; left: 0; right: 0; bottom: -2px; height: 3px; }
.nav__progress i { display: block; height: 100%; background: var(--grad); transform-origin: 0 50%; transform: scaleX(0); }
/* ---------- hero ---------- */
.hero {
position: relative;
min-height: 100vh;
min-height: 100svh;
padding: 120px var(--pad) 70px;
overflow: hidden;
background:
radial-gradient(60% 50% at 85% 30%, rgba(101, 61, 240, .35), transparent 70%),
radial-gradient(40% 40% at 10% 90%, rgba(29, 219, 253, .14), transparent 70%),
var(--bg);
}
.hero::before { /* the layout grid, drawn */
content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background-image:
linear-gradient(to right, rgba(245, 244, 255, .06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(245, 244, 255, .06) 1px, transparent 1px);
background-size: 96px 96px;
mask-image: linear-gradient(to bottom, #000 40%, transparent);
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent);
}
.hero__ghost {
position: absolute; left: -1vw; bottom: -5.5vw;
font: 900 26vw/.8 var(--display); letter-spacing: -.06em; text-transform: uppercase;
/* A faint solid fill, never an outline: the variable font draws its letters from overlapping
shapes, and an outline would trace every overlap. */
background: linear-gradient(180deg, rgba(118, 250, 252, .11), rgba(101, 61, 240, .07) 60%, rgba(252, 89, 251, .05));
-webkit-background-clip: text; background-clip: text; color: transparent;
white-space: nowrap; pointer-events: none; user-select: none;
}
.hero__grid {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.18fr) minmax(0, .82fr);
gap: clamp(24px, 4vw, 80px);
align-items: center;
max-width: var(--max);
margin: 0 auto;
}
.kicker {
display: inline-flex; align-items: center; gap: 10px; margin: 0;
padding: 9px 14px; border: var(--b) solid var(--fg);
font: 700 12px/1 var(--mono); text-transform: uppercase; letter-spacing: .12em;
}
.kicker i { font-style: normal; color: var(--pink); }
.kicker__dot { width: 9px; height: 9px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 0 4px rgba(29, 219, 253, .2); animation: blink 1.6s steps(2) infinite; }
@keyframes blink { 50% { opacity: .2; } }
.hero__title {
margin: 26px 0 28px;
font: 900 clamp(50px, 8.4vw, 158px)/.86 var(--display);
letter-spacing: -.05em;
text-transform: uppercase;
}
.lead { margin: 0; max-width: 33em; font-size: clamp(18px, 1.45vw, 22px); line-height: 1.5; color: #dddbf3; }
.cta { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 36px; }
.hero__stage { position: relative; display: grid; place-items: center; perspective: 1600px; padding: 20px 0; }
.hero__slab {
position: absolute; inset: 9% 3% 12% 14%;
background: var(--violet);
border: var(--b) solid var(--fg);
box-shadow: 14px 14px 0 var(--pink);
transform: rotate(-5deg);
}
.hero__tilt {
position: relative;
transform: rotateY(var(--ry, -16deg)) rotateX(var(--rx, 8deg)) rotateZ(var(--rz, 3deg));
transform-style: preserve-3d;
will-change: transform;
}
.hero .phone-host { width: min(360px, 74vw); }
.sticker {
position: absolute; z-index: 3;
padding: 11px 14px;
font: 800 14px/1.05 var(--display); text-transform: uppercase; letter-spacing: -.01em;
color: var(--ink); background: var(--cyan);
border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--ink);
animation: bob 4s ease-in-out infinite;
}
.sticker--a { top: 12%; left: -2%; transform: rotate(-9deg); }
.sticker--b { top: 54%; right: -3%; background: var(--pink); transform: rotate(7deg); animation-delay: -2s; }
@keyframes bob { 50% { translate: 0 -10px; } }
.spinner { position: absolute; left: 0; bottom: 4%; width: 128px; height: 128px; z-index: 3; display: grid; place-items: center; }
.spinner__ring { position: absolute; inset: 0; width: 100%; height: 100%; animation: spin 16s linear infinite; }
.spinner__ring text { font: 700 10.5px var(--mono); letter-spacing: .18em; text-transform: uppercase; fill: var(--fg); }
.spinner__arrow { width: 38px; height: 38px; color: var(--ink); background: var(--ice); border-radius: 50%; padding: 8px; stroke-width: 2.8; border: var(--b) solid var(--ink); }
@keyframes spin { to { rotate: 360deg; } }
/* ---------- marquees ---------- */
.bands { position: relative; overflow: hidden; padding-bottom: 30px; } /* the tilted band stays inside */
.marquee { position: relative; overflow: hidden; z-index: 2; }
.marquee--cyan { background: var(--cyan); color: var(--ink); }
.marquee--pink { background: var(--pink); color: var(--ink); transform: rotate(-1.6deg) scale(1.03); margin-top: -4px; border-top: var(--b) solid var(--ink); }
.marquee__track { display: flex; width: max-content; white-space: nowrap; will-change: transform; }
.marquee__track span {
display: inline-flex; align-items: center; gap: .55em; padding: .32em .55em .32em 0;
font: 900 clamp(26px, 3.8vw, 58px)/1 var(--display); text-transform: uppercase; letter-spacing: -.03em;
}
.marquee__track span::after {
content: ""; width: .62em; height: .62em; flex: none;
background: currentColor;
clip-path: polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
}
/* ---------- sections ---------- */
section { position: relative; }
.section-head { max-width: var(--max); margin: 0 auto; padding: clamp(80px, 11vw, 150px) var(--pad) clamp(30px, 4vw, 60px); }
/* ---------- story ---------- */
.story__body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
max-width: var(--max);
margin: 0 auto;
padding: 0 var(--pad);
}
.story__stage {
grid-column: 2; grid-row: 1;
position: sticky; top: 0;
height: 100vh; height: 100svh;
display: grid; place-items: center;
align-self: start;
}
.story__num {
position: absolute; left: 50%; top: 50%; translate: -50% -50%;
font: 900 clamp(180px, 30vw, 480px)/1 var(--display); letter-spacing: -.07em;
color: rgba(245, 244, 255, .045); /* filled, not outlined, for the same reason as .hero__ghost */
pointer-events: none; user-select: none;
}
.story .phone-host { height: min(84vh, 880px); height: min(84svh, 880px); position: relative; z-index: 1; }
.story__rail { position: absolute; right: 0; top: 50%; translate: 0 -50%; display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
.story__rail li { width: 12px; height: 34px; border: var(--b) solid var(--fg); transition: background .3s, height .3s; }
.story__rail li.done { background: var(--fg); }
.story__rail li.on { background: var(--pink); height: 54px; }
.story__steps { grid-column: 1; grid-row: 1; padding-bottom: 50vh; padding-bottom: 50svh; }
.step { min-height: 100vh; min-height: 100svh; display: flex; align-items: center; padding-right: 4vw; }
.step__card { opacity: .16; transition: opacity .5s ease; }
.step.is-active .step__card { opacity: 1; }
.step__no { margin: 0; font: 700 14px/1 var(--mono); letter-spacing: .1em; color: var(--pink); }
.step__title { margin: 18px 0 22px; font: 900 clamp(34px, 4.3vw, 74px)/.94 var(--display); letter-spacing: -.04em; text-transform: uppercase; }
.step__text { margin: 0; max-width: 30em; font-size: clamp(18px, 1.35vw, 21px); color: #dcdaf3; }
.step__gesture { display: inline-flex; gap: 12px; margin: 30px 0 0; padding: 10px 14px; border: var(--b) solid var(--fg); font: 700 12.5px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.step__gesture span { color: var(--cyan); }
/* ---------- details ---------- */
.cards {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
max-width: var(--max); margin: 0 auto; padding: 0 var(--pad);
gap: 0;
}
.card {
position: relative;
min-height: 380px;
padding: 28px 28px 32px;
border: var(--b) solid var(--fg);
margin: 0 -2px -2px 0; /* collapse the borders into a single grid */
display: flex; flex-direction: column;
transition: transform .2s ease, box-shadow .2s ease, z-index 0s .2s;
}
.card:hover { transform: translate(-6px, -6px); box-shadow: 12px 12px 0 var(--fg); z-index: 2; transition: transform .2s ease, box-shadow .2s ease; }
.card h3 { margin: auto 0 12px; font: 900 clamp(24px, 2.2vw, 34px)/1 var(--display); letter-spacing: -.03em; text-transform: uppercase; }
.card p { margin: 0; font-size: 17px; line-height: 1.5; }
.card__no { position: absolute; top: 22px; right: 24px; font: 800 14px var(--mono); }
.card--ink { background: var(--bg-2); }
.card--ink p { color: var(--dim); }
.card--cyan { background: var(--cyan); color: var(--ink); }
.card--violet { background: var(--violet); }
.card--pink { background: var(--pink); color: var(--ink); }
.card__art { height: 150px; margin-bottom: 28px; position: relative; }
/* card art: alphabet that hops from edge to edge */
.art-flip__phone { position: absolute; left: 0; top: 0; width: 120px; height: 150px; border: var(--b) solid currentColor; border-radius: 18px; }
.art-flip__phone b { position: absolute; top: 12px; right: 8px; width: 12px; font: 700 10px/1.25 var(--ui); text-align: center; word-break: break-all; color: var(--cyan); animation: flip 4s cubic-bezier(.7, 0, .3, 1) infinite; }
@keyframes flip { 0%, 40% { right: 8px; } 50%, 90% { right: 96px; } 100% { right: 8px; } }
/* compact and comfy rows */
.art-rows { display: flex; gap: 22px; }
.art-rows__col { display: flex; flex-direction: column; gap: 9px; width: 120px; padding: 12px; border: var(--b) solid var(--ink); border-radius: 14px; }
.art-rows__col b { display: block; height: 12px; background: var(--ink); border-radius: 3px; }
.art-rows__col--comfy { gap: 16px; }
.art-rows__col--comfy b { height: 20px; }
/* update bar */
.art-update { display: flex; flex-direction: column; justify-content: center; gap: 16px; }
.art-update__v { font: 900 46px/1 var(--display); letter-spacing: -.04em; }
.art-update__bar { display: block; height: 22px; border: var(--b) solid var(--fg); }
.art-update__bar b { display: block; height: 100%; background: var(--fg); animation: fillbar 3.2s cubic-bezier(.6, 0, .2, 1) infinite; }
@keyframes fillbar { 0% { width: 0; } 70%, 100% { width: 100%; } }
/* move in */
.art-move { display: flex; align-items: center; gap: 18px; }
.art-move__box { display: grid; place-items: center; width: 92px; height: 92px; border: var(--b) solid var(--ink); background: var(--fg); color: var(--ink); font: 900 26px var(--display); font-style: normal; box-shadow: 5px 5px 0 var(--ink); }
.art-move__box--f { background: var(--ink); }
.art-move__box--f img { width: 60px; height: 60px; }
.art-move__arrow { width: 44px; height: 44px; stroke-width: 3; animation: nudge 1.2s ease-in-out infinite; }
@keyframes nudge { 50% { translate: 8px 0; } }
/* shapes that morph */
.art-shapes { display: flex; align-items: center; gap: 18px; }
.art-shapes i { width: 72px; height: 72px; background: var(--grad); border: var(--b) solid var(--fg); animation: morph 4.5s ease-in-out infinite; }
.art-shapes i:nth-child(2) { animation-delay: -1.5s; }
.art-shapes i:nth-child(3) { animation-delay: -3s; }
@keyframes morph { 0%, 100% { border-radius: 50%; } 33% { border-radius: 30%; } 66% { border-radius: 8px; } }
/* double tap */
.art-tap { display: grid; place-items: center; }
.art-tap i { position: absolute; width: 56px; height: 56px; border-radius: 50%; border: 3px solid var(--ink); animation: tap 1.8s ease-out infinite; }
.art-tap i:nth-child(2) { animation-delay: .22s; }
@keyframes tap { 0% { scale: .3; opacity: 1; } 60%, 100% { scale: 2.3; opacity: 0; } }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); max-width: var(--max); margin: 0 auto; padding: clamp(60px, 8vw, 110px) var(--pad); }
.stat { padding: 0 20px; border-left: var(--b) solid var(--fg); }
.stat b { display: block; font: 900 clamp(70px, 10vw, 170px)/.9 var(--display); letter-spacing: -.06em; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
.stat span { display: block; margin-top: 14px; font: 700 13px/1.3 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
/* ---------- privacy ---------- */
.privacy {
background: var(--cyan);
color: var(--ink);
padding: clamp(80px, 11vw, 150px) var(--pad);
}
.privacy > * { max-width: var(--max); margin-left: auto; margin-right: auto; }
.privacy__title { margin-top: 22px; margin-bottom: clamp(40px, 6vw, 80px); font: 900 clamp(40px, 6.8vw, 112px)/.92 var(--display); letter-spacing: -.045em; text-transform: uppercase; }
.privacy__hl { display: inline-block; padding: 0 .12em; background: var(--ink); color: var(--cyan); transform: rotate(-2deg); }
.privacy__list { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
.pitem { position: relative; padding: 34px 34px 40px 0; border-top: 3px solid var(--ink); }
.pitem + .pitem { padding-left: 34px; border-left: 3px solid var(--ink); }
.pitem__no { display: grid; place-items: center; width: 54px; height: 54px; border-radius: 50%; background: var(--ink); color: var(--cyan); font: 900 24px var(--display); }
.pitem h3 { margin: 22px 0 10px; font: 900 clamp(24px, 2.4vw, 36px)/1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
.pitem p { margin: 0; max-width: 28em; font-size: clamp(17px, 1.35vw, 20px); }
.privacy__end { margin-top: clamp(40px, 6vw, 80px); padding-top: 34px; border-top: 3px solid var(--ink); font: 900 clamp(26px, 3.4vw, 54px)/1.02 var(--display); letter-spacing: -.035em; text-transform: uppercase; }
.privacy__note { margin-top: 26px; font: 600 14px/1.5 var(--mono); }
/* ---------- playground ---------- */
.play {
background:
linear-gradient(to right, rgba(245, 244, 255, .05) 1px, transparent 1px) 0 0 / 48px 48px,
linear-gradient(to bottom, rgba(245, 244, 255, .05) 1px, transparent 1px) 0 0 / 48px 48px,
var(--bg);
}
.play__grid {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: clamp(30px, 5vw, 90px);
align-items: center;
max-width: var(--max); margin: 0 auto;
padding: 0 var(--pad) clamp(80px, 10vw, 140px);
}
.play__stage { display: grid; place-items: center; position: relative; }
.play__stage::before {
content: ""; position: absolute; width: 70%; aspect-ratio: 1; border-radius: 50%;
background: radial-gradient(closest-side, rgba(101, 61, 240, .55), rgba(252, 89, 251, .18) 60%, transparent);
filter: blur(30px);
}
.play .phone-host { height: min(82vh, 860px); height: min(82svh, 860px); position: relative; touch-action: none; }
.play__caption {
margin: 0 0 28px; min-height: 3.2em;
font: 800 clamp(22px, 2.2vw, 34px)/1.15 var(--display); letter-spacing: -.03em; text-transform: uppercase;
}
.chips { display: flex; flex-wrap: wrap; gap: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 9px;
padding: 12px 16px;
font: 700 13px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
color: var(--fg); background: transparent;
border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet);
cursor: pointer; transition: transform .12s, box-shadow .12s, background .2s, color .2s;
}
.chip .gly { width: 18px; height: 18px; }
.chip:hover { background: var(--fg); color: var(--ink); }
.chip:active { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--violet); }
.chip.is-on { background: var(--cyan); color: var(--ink); }
.chip--ghost { box-shadow: none; border-style: dashed; }
.howto { margin: 36px 0 0; padding: 0; list-style: none; border-top: var(--b) solid var(--fg); }
.howto li { padding: 13px 0; border-bottom: 1px solid rgba(245, 244, 255, .2); font-size: 17px; color: var(--dim); }
.howto b { color: var(--fg); font-weight: 700; }
/* ---------- get it ---------- */
.get { background: var(--violet); padding: clamp(80px, 11vw, 150px) var(--pad); }
.get > * { max-width: var(--max); margin-left: auto; margin-right: auto; }
.get .tag { color: var(--fg); }
.get__title { margin-top: 10px; margin-bottom: clamp(30px, 5vw, 70px); font: 900 clamp(44px, 14.6vw, 260px)/.82 var(--display); letter-spacing: -.07em; text-transform: uppercase; color: var(--fg); text-shadow: .045em .045em 0 var(--ink); }
.get__grid { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: clamp(30px, 5vw, 90px); align-items: start; }
.qr {
margin: 0; padding: 22px; width: clamp(220px, 22vw, 300px);
background: var(--fg); color: var(--ink);
border: var(--b) solid var(--ink); box-shadow: 12px 12px 0 var(--ink);
transform: rotate(-3deg);
}
.qr__code { width: 100%; height: auto; }
.qr__code path { fill: none; stroke-width: 1; shape-rendering: crispEdges; }
.qr figcaption { margin-top: 14px; font: 700 12.5px/1.3 var(--mono); text-transform: uppercase; letter-spacing: .08em; text-align: center; }
.get__main { display: flex; flex-direction: column; align-items: flex-start; }
.get .btn--huge { --c: var(--pink); }
.steps { counter-reset: s; margin: 40px 0 34px; padding: 0; list-style: none; max-width: 40em; }
.steps li { counter-increment: s; position: relative; padding: 16px 0 16px 64px; border-top: var(--b) solid var(--ink); font-size: clamp(17px, 1.3vw, 20px); }
.steps li:last-child { border-bottom: var(--b) solid var(--ink); }
.steps li::before { content: "0" counter(s); position: absolute; left: 0; top: 16px; font: 900 22px/1 var(--display); color: var(--ice); }
.get .textlink { color: var(--fg); }
.faq { margin-top: clamp(60px, 8vw, 110px); border-top: 3px solid var(--ink); }
.faq details { border-bottom: 3px solid var(--ink); }
.faq summary {
list-style: none; cursor: pointer; position: relative;
padding: 24px 60px 24px 0;
font: 800 clamp(18px, 1.8vw, 28px)/1.15 var(--display); letter-spacing: -.02em; text-transform: uppercase;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
content: ""; position: absolute; right: 6px; top: 50%; width: 26px; height: 26px; margin-top: -13px;
background: linear-gradient(var(--fg), var(--fg)) center / 100% 4px no-repeat, linear-gradient(var(--fg), var(--fg)) center / 4px 100% no-repeat;
transition: rotate .25s;
}
.faq details[open] summary::after { rotate: 45deg; }
.faq details p { margin: 0 0 26px; max-width: 46em; font-size: clamp(17px, 1.3vw, 19px); }
/* ---------- footer ---------- */
.foot { padding: 40px var(--pad) 34px; overflow: hidden; }
.foot__word {
font: 900 16.6vw/.8 var(--display); letter-spacing: -.07em; text-transform: uppercase;
background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent;
margin: 0 0 30px -.03em; white-space: nowrap;
}
.foot__row { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 40px; padding-top: 22px; border-top: var(--b) solid var(--fg); font: 500 13px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
.foot__row p { margin: 0; }
.foot__row .textlink { margin-left: auto; color: var(--fg); }
/* =====================================================================
The phone. Drawn at 390 x 844 (a phone's CSS pixels) and scaled to fit
its host, so every measurement inside stays the same at any size.
===================================================================== */
.phone-host { position: relative; aspect-ratio: 414 / 868; }
.phone {
position: absolute; left: 0; top: 0;
width: 414px; height: 868px;
padding: 12px;
border-radius: 60px;
background: linear-gradient(150deg, #34324d, #121122 45%, #08070f);
box-shadow:
inset 0 0 0 2px #4a4767,
inset 0 0 0 5px #0a0914,
0 50px 90px -30px rgba(101, 61, 240, .7),
0 0 0 2px #000;
transform-origin: 0 0;
transform: scale(var(--s, .5));
user-select: none; -webkit-user-select: none;
}
.phone::before, .phone::after { /* side buttons */
content: ""; position: absolute; right: -4px; width: 4px; border-radius: 0 3px 3px 0; background: #2b2940;
}
.phone::before { top: 190px; height: 70px; }
.phone::after { top: 290px; height: 110px; }
.screen {
position: relative;
width: 390px; height: 844px;
border-radius: 48px;
overflow: hidden;
background: #05061a;
font-family: var(--ui);
color: #fff;
isolation: isolate;
contain: strict;
}
.screen * { box-sizing: border-box; }
.screen .ic svg, .screen .gl { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* wallpaper and home screen sit in one layer, so the folder blur takes both */
.world { position: absolute; inset: 0; transform-origin: 50% 50%; }
.wall {
position: absolute; inset: -30px;
background:
radial-gradient(70% 38% at 8% 12%, rgba(29, 219, 253, .62), transparent 70%),
radial-gradient(60% 34% at 95% 92%, rgba(252, 89, 251, .55), transparent 70%),
radial-gradient(55% 30% at 78% 40%, rgba(101, 61, 240, .7), transparent 72%),
linear-gradient(168deg, #2a1fb8 0%, #120d82 30%, #0a0954 58%, #050525 100%);
}
.wall::before, .wall::after { /* the icon's sweeping light lines */
content: ""; position: absolute; border-radius: 50%;
border-top: 3px solid rgba(118, 250, 252, .55);
filter: blur(1.5px);
}
.wall::before { left: -40%; top: 18%; width: 150%; height: 70%; transform: rotate(-22deg); }
.wall::after { left: -10%; top: 52%; width: 150%; height: 60%; transform: rotate(-24deg); border-top-color: rgba(252, 89, 251, .5); }
.sbar { position: absolute; left: 0; right: 0; top: 0; height: 40px; padding: 14px 28px 0; display: flex; justify-content: space-between; font: 600 14px/1 var(--ui); z-index: 30; pointer-events: none; }
.sbar__icons { display: flex; align-items: center; gap: 6px; }
.sbar__sig { display: flex; align-items: flex-end; gap: 2px; height: 11px; }
.sbar__sig i { width: 3px; background: #fff; border-radius: 1px; }
.sbar__sig i:nth-child(1) { height: 4px; } .sbar__sig i:nth-child(2) { height: 6px; } .sbar__sig i:nth-child(3) { height: 8px; } .sbar__sig i:nth-child(4) { height: 11px; }
.sbar__bat { width: 22px; height: 11px; border-radius: 3px; border: 1.5px solid #fff; padding: 1.5px; }
.sbar__bat::before { content: ""; display: block; width: 72%; height: 100%; background: #fff; border-radius: 1px; }
.notch { position: absolute; left: 50%; top: 12px; width: 22px; height: 22px; margin-left: -11px; border-radius: 50%; background: #000; z-index: 31; box-shadow: inset 0 0 0 4px #111; }
.home { position: absolute; inset: 0; transform-origin: 50% 60%; }
.pages { position: absolute; inset: 0; }
.page { position: absolute; top: 0; width: 390px; height: 844px; }
.cell { position: absolute; display: flex; flex-direction: column; align-items: center; padding-top: 8px; }
.cell .lbl { margin-top: 7px; font: 500 12.5px/1.2 var(--ui); text-shadow: 0 1px 4px rgba(0, 0, 0, .55); white-space: nowrap; }
.cell.tap .ic, .cell.tap .fic { animation: tapb .3s ease; }
@keyframes tapb { 40% { transform: scale(.86); } }
.ic {
position: relative; display: grid; place-items: center;
width: 58px; height: 58px; border-radius: 30%;
color: #fff;
background: linear-gradient(145deg, var(--a), var(--b));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), 0 6px 14px -6px rgba(0, 0, 0, .55);
flex: none;
}
.ic svg { width: 46%; height: 46%; }
.ndot { position: absolute; top: 4px; left: calc(50% + 20px); width: 13px; height: 13px; border-radius: 50%; background: var(--pink); border: 2px solid #0d0b40; }
.fic { width: 58px; height: 58px; padding: 7px; border-radius: 30%; background: rgba(236, 233, 249, .94); display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.ic--mini { width: 100%; height: auto; aspect-ratio: 1; box-shadow: none; }
.ic--mini svg { stroke-width: 2.6; }
.w-clock { position: absolute; padding: 26px 18px 0; }
.clk__line { white-space: nowrap; }
.clk__line > span { display: inline-block; will-change: transform; }
.clk__time { font: 300 88px/1 var(--ui); letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.clk__date { margin-top: 8px; font: 500 18px/1.2 var(--ui); }
.clk__wx { margin-top: 4px; font: 400 16px/1.2 var(--ui); opacity: .92; }
.w-pics { position: absolute; padding: 8px 12px 18px; }
.pics { position: relative; width: 100%; height: 100%; border-radius: 30px; overflow: hidden; box-shadow: 0 10px 24px -10px rgba(0, 0, 0, .6); }
.pic { position: absolute; inset: 0; opacity: 0; transition: opacity 1.1s ease; }
.pic.on { opacity: 1; }
.pic--a { /* sunset over water */
background:
radial-gradient(circle at 50% 58%, #fff1b8 0 9%, #ffc56b 10%, transparent 30%),
linear-gradient(to bottom, transparent 62%, rgba(10, 6, 60, .9) 62%),
repeating-linear-gradient(to bottom, transparent 0 7px, rgba(255, 190, 120, .28) 7px 9px) 0 62% / 100% 38% no-repeat,
linear-gradient(to bottom, #3b1c8f, #c2399b 40%, #ff7a59 62%);
}
.pic--b { /* mountains */
background:
linear-gradient(150deg, transparent 52%, #1a1660 52.4%) 0 100% / 60% 62% no-repeat,
linear-gradient(-140deg, transparent 50%, #2a2390 50.4%) 100% 100% / 75% 75% no-repeat,
linear-gradient(140deg, transparent 50%, #3c32b8 50.4%) 0 100% / 75% 55% no-repeat,
radial-gradient(circle at 72% 28%, #fff 0 6%, transparent 7%),
linear-gradient(to bottom, #1ddbfd, #417df4 55%, #9a7dff);
}
.pic--c { /* neon city */
background:
linear-gradient(to top, #fc59fb 0 2px, transparent 2px) 0 100% / 100% 100% no-repeat,
linear-gradient(#0a0920, #0a0920) 8% 100% / 16% 58% no-repeat,
linear-gradient(#120f33, #120f33) 28% 100% / 18% 76% no-repeat,
linear-gradient(#0a0920, #0a0920) 52% 100% / 14% 48% no-repeat,
linear-gradient(#17134a, #17134a) 74% 100% / 20% 68% no-repeat,
linear-gradient(#0a0920, #0a0920) 98% 100% / 12% 40% no-repeat,
radial-gradient(circle at 70% 22%, #fdf6ff 0 7%, transparent 8%),
linear-gradient(to bottom, #3a0d6e, #b8279b 70%, #fc59fb);
}
.pagedots { position: absolute; left: 0; right: 0; top: 748px; display: flex; justify-content: center; gap: 8px; }
.pagedots i { width: 7px; height: 7px; border-radius: 50%; background: rgba(255, 255, 255, .45); transition: background .2s, width .2s; }
.pagedots i.on { background: #fff; width: 18px; border-radius: 4px; }
.fscrim { position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none; z-index: 10; }
.fcard {
position: absolute; left: 45px; top: 282px; width: 300px;
padding: 22px 12px 14px;
border-radius: 32px;
background: #eeebf8; color: #1c1a2d;
z-index: 11; visibility: hidden; opacity: 0;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, .6);
}
.fcard__grid { display: grid; grid-template-columns: repeat(3, 1fr); row-gap: 16px; }
.fcard__app { display: flex; flex-direction: column; align-items: center; gap: 7px; font: 500 12.5px/1.2 var(--ui); }
.fcard__title { margin-top: 16px; text-align: center; font: 600 17px/1 var(--ui); }
.drawer { position: absolute; inset: 0; background: rgba(5, 4, 20, .8); z-index: 12; opacity: 0; visibility: hidden; }
.drawer__in { position: absolute; inset: 0; -webkit-mask-image: linear-gradient(to bottom, transparent 30px, #000 52px); mask-image: linear-gradient(to bottom, transparent 30px, #000 52px); } /* the list fades out under the status bar */
.list { position: absolute; left: 0; right: 0; top: 46px; padding: 0 58px 0 20px; will-change: transform; }
.search { display: flex; align-items: center; gap: 12px; height: 50px; margin: 6px 0 12px; padding: 0 18px; border-radius: 25px; background: #f2f0fb; color: #4b4867; font: 500 16px/1 var(--ui); }
.search .gl { width: 22px; height: 22px; color: #4b4867; stroke-width: 2.4; }
.hdr { height: 34px; padding: 12px 0 0 2px; font: 500 13px/1 var(--ui); color: #b9b6dc; }
.row { display: flex; align-items: center; gap: 15px; height: 54px; overflow: hidden; transition: background .2s; border-radius: 14px; }
.row--note { height: 64px; }
.row .ic { width: 40px; height: 40px; }
.row__t { display: flex; flex-direction: column; min-width: 0; }
.row__n { font: 500 17.5px/1.15 var(--ui); }
.row__p { margin-top: 3px; font: 400 13px/1.2 var(--ui); color: #cfcbef; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row.flash { background: rgba(255, 255, 255, .12); }
.row.held { background: rgba(255, 255, 255, .1); }
.alpha { position: absolute; right: 8px; top: 404px; width: 30px; display: flex; flex-direction: column; align-items: center; z-index: 2; }
.alpha__s { width: 15px; height: 15px; margin-bottom: 8px; color: #fff; opacity: .9; }
.alpha__l { display: block; width: 30px; height: 21px; font: 600 12.5px/21px var(--ui); text-align: center; color: rgba(255, 255, 255, .86); transform-origin: 50% 50%; }
.alpha__l.on { color: var(--pink); font-weight: 800; }
.bubble { position: absolute; left: 0; top: 0; width: 66px; height: 66px; display: grid; place-items: center; border-radius: 50%; background: var(--grad); color: #fff; font: 700 32px/1 var(--ui); box-shadow: 0 10px 30px -6px rgba(252, 89, 251, .6); opacity: 0; z-index: 3; pointer-events: none; text-shadow: 0 1px 6px rgba(0, 0, 0, .3); }
.menu { position: absolute; left: 0; top: 0; width: 236px; padding: 6px 0; border-radius: 24px; background: #f6f4fd; color: #1e1c2c; z-index: 20; opacity: 0; visibility: hidden; transform-origin: 30% 0; box-shadow: 0 24px 50px -16px rgba(0, 0, 0, .65); }
.mi { display: flex; align-items: center; gap: 16px; height: 50px; padding: 0 20px; font: 500 16px/1 var(--ui); }
.mi .gl { width: 22px; height: 22px; stroke-width: 1.9; color: #3a3752; }
.mi--hide.pick { background: #e2dcff; }
.snack { position: absolute; left: 24px; right: 24px; bottom: 64px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-radius: 18px; background: #f1eefb; color: #1b1a29; font: 500 15px/1.2 var(--ui); z-index: 21; opacity: 0; visibility: hidden; box-shadow: 0 18px 40px -14px rgba(0, 0, 0, .6); }
.snack__undo { all: unset; font: 700 15px/1 var(--ui); color: #5a33e6; cursor: pointer; padding: 4px; }
.sheet { position: absolute; left: 0; right: 0; bottom: 0; padding: 12px 22px 36px; border-radius: 30px 30px 0 0; background: #f4f2fb; color: #1c1a2d; z-index: 22; transform: translateY(105%); box-shadow: 0 -20px 50px -20px rgba(0, 0, 0, .5); }
.sheet__grab { display: block; width: 36px; height: 4px; margin: 0 auto 14px; border-radius: 2px; background: #a9a5c2; }
.sheet h4 { margin: 0 0 10px; font: 600 22px/1.2 var(--ui); }
.sheet__h { margin: 14px 0 8px; font: 600 13.5px/1 var(--ui); color: #5d5a78; }
.seg { display: flex; border: 1.5px solid #8a86a6; border-radius: 22px; overflow: hidden; }
.seg span { flex: 1; display: grid; place-items: center; height: 40px; font: 500 14px/1 var(--ui); border-left: 1.5px solid #8a86a6; transition: background .2s; }
.seg span:first-child { border-left: 0; }
.seg span.on { background: #ddd5ff; font-weight: 700; }
.cam { position: absolute; inset: 0; background: #000; z-index: 25; visibility: hidden; transform: translateX(-100%); }
.cam__view {
position: absolute; left: 0; right: 0; top: 52px; height: 560px; overflow: hidden;
background:
radial-gradient(circle at 66% 34%, #fff6d8 0 5%, #ffd48a 6%, transparent 22%),
linear-gradient(to bottom, #4b2ac9, #c2399b 45%, #ff9b6a 70%, #ffd48a);
}
.cam__hill { position: absolute; left: -10%; right: -10%; bottom: 0; }
.cam__hill--1 { height: 46%; background: #2a1563; clip-path: polygon(0 40%, 18% 22%, 36% 46%, 55% 18%, 74% 40%, 90% 28%, 100% 36%, 100% 100%, 0 100%); }
.cam__hill--2 { height: 30%; background: #120a36; clip-path: polygon(0 30%, 25% 12%, 48% 40%, 70% 20%, 100% 44%, 100% 100%, 0 100%); }
.cam__grid { position: absolute; inset: 0; background: linear-gradient(to right, transparent 33%, rgba(255, 255, 255, .35) 33%, rgba(255, 255, 255, .35) calc(33% + 1px), transparent calc(33% + 1px), transparent 66%, rgba(255, 255, 255, .35) 66%, rgba(255, 255, 255, .35) calc(66% + 1px), transparent calc(66% + 1px)), linear-gradient(to bottom, transparent 33%, rgba(255, 255, 255, .35) 33%, rgba(255, 255, 255, .35) calc(33% + 1px), transparent calc(33% + 1px), transparent 66%, rgba(255, 255, 255, .35) 66%, rgba(255, 255, 255, .35) calc(66% + 1px), transparent calc(66% + 1px)); }
.cam__focus { position: absolute; left: 50%; top: 50%; width: 76px; height: 76px; margin: -38px; border: 2px solid #ffe066; border-radius: 6px; }
.cam__modes { position: absolute; left: 0; right: 0; top: 632px; display: flex; justify-content: center; gap: 26px; font: 600 13px/1 var(--ui); letter-spacing: .08em; text-transform: uppercase; color: #9d9ab8; }
.cam__modes .on { color: var(--ice); }
.cam__bar { position: absolute; left: 0; right: 0; top: 684px; display: flex; align-items: center; justify-content: space-between; padding: 0 44px; }
.cam__thumb { width: 50px; height: 50px; border-radius: 12px; background: linear-gradient(145deg, #ff9b6a, #4b2ac9); border: 2px solid #fff; }
.cam__shutter { width: 78px; height: 78px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 5px #000, 0 0 0 9px #fff; transition: transform .12s ease; }
.cam__flip { display: grid; place-items: center; width: 50px; height: 50px; border-radius: 50%; background: #26243a; }
.cam__flip .gl { width: 24px; height: 24px; color: #fff; }
.cam__close { position: absolute; top: 58px; right: 16px; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .45); color: #fff; font: 400 26px/1 var(--ui); cursor: pointer; }
.gbar { position: absolute; left: 50%; bottom: 9px; width: 124px; height: 5px; margin-left: -62px; border-radius: 3px; background: rgba(255, 255, 255, .88); z-index: 40; pointer-events: none; }
.finger {
position: absolute; left: 0; top: 0; width: 50px; height: 50px; margin: -25px 0 0 -25px;
border-radius: 50%;
background: rgba(255, 255, 255, .26);
border: 2px solid rgba(255, 255, 255, .85);
box-shadow: 0 0 0 7px rgba(255, 255, 255, .1), 0 10px 26px rgba(0, 0, 0, .35);
z-index: 50; opacity: 0; pointer-events: none;
}
.finger__ring { position: absolute; inset: -9px; border-radius: 50%; background: conic-gradient(var(--pink) calc(var(--ring, 0) * 1turn), transparent 0); -webkit-mask: radial-gradient(closest-side, transparent 78%, #000 80%); mask: radial-gradient(closest-side, transparent 78%, #000 80%); }
/* ---------- responsive ---------- */
@media (max-width: 1100px) {
.nav__links { display: none; }
.nav .btn--sm { margin-left: auto; }
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
body { font-size: 16px; }
.hero { padding-top: 104px; }
.hero__grid { grid-template-columns: minmax(0, 1fr); }
.hero__stage { margin-top: 30px; }
.hero .phone-host { width: min(300px, 70vw); }
.sticker { font-size: 12px; }
.sticker--a { left: 0; }
.sticker--b { right: 0; }
.spinner { width: 100px; height: 100px; left: 2%; }
.story__body { display: block; padding: 0; }
.story__stage { position: sticky; top: 0; place-items: start center; padding-top: 70px; }
.story .phone-host { height: 52vh; height: 52svh; }
.story__num { top: 32%; font-size: 58vw; }
.story__rail { right: 14px; top: 32%; gap: 6px; }
.story__rail li { width: 8px; height: 20px; }
.story__rail li.on { height: 32px; }
.story__steps { position: relative; z-index: 2; margin-top: -100vh; margin-top: -100svh; padding: 0 var(--pad) 50svh; }
.step { align-items: flex-end; padding: 0 0 4svh; }
.step__card { opacity: 0; background: rgba(7, 7, 13, .94); border: var(--b) solid var(--fg); box-shadow: 6px 6px 0 var(--violet); padding: 18px 18px 20px; width: 100%; }
.step__title { margin: 10px 0 10px; font-size: clamp(24px, 7vw, 34px); }
.step__text { font-size: 15.5px; line-height: 1.45; }
.step__gesture { display: none; }
.cards { grid-template-columns: minmax(0, 1fr); }
.card { min-height: 0; }
.stats { grid-template-columns: 1fr 1fr; row-gap: 40px; }
.privacy__list { grid-template-columns: 1fr; }
.pitem + .pitem { padding-left: 0; border-left: 0; }
.play__grid { grid-template-columns: minmax(0, 1fr); }
.play .phone-host { height: 72svh; }
.play__side { order: -1; }
.play__caption { min-height: 2.4em; margin-bottom: 18px; }
.howto { display: none; }
.get__grid { grid-template-columns: minmax(0, 1fr); }
.qr { justify-self: start; }
.foot__row .textlink { margin-left: 0; }
}
@media (max-width: 520px) {
.cta .btn { width: 100%; justify-content: space-between; }
.btn--huge { width: 100%; justify-content: space-between; font-size: 18px; }
.marquee--pink { transform: rotate(-2.5deg) scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
.line__in, .rv { transform: none !important; opacity: 1 !important; }
}
+20
View File
@@ -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
View File
@@ -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 -------------------------------------------------------------------------------
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Hide app" in an app's long-press menu: an eye with a line through it. Drawn as strokes;
the menu tints it like its other icons. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- The eye's outline, open on the side the line crosses first. -->
<path
android:pathData="M9.9,5.2C10.6,5.07 11.3,5 12,5C16.2,5 19.7,7.6 21.5,12C20.9,13.4 20.1,14.6 19.1,15.6M16.4,17.6C15.1,18.5 13.6,19 12,19C7.8,19 4.3,16.4 2.5,12C3.3,10 4.5,8.4 6,7.2"
android:strokeColor="@android:color/white"
android:strokeWidth="1.8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- What the line leaves of the pupil. -->
<path
android:pathData="M14.1,14.1C13.56,14.66 12.82,15 12,15C10.34,15 9,13.66 9,12C9,11.18 9.34,10.44 9.9,9.9"
android:strokeColor="@android:color/white"
android:strokeWidth="1.8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- The line. -->
<path
android:pathData="M4,4L20,20"
android:strokeColor="@android:color/white"
android:strokeWidth="1.8"
android:strokeLineCap="round" />
</vector>
@@ -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>
+77 -18
View File
@@ -66,10 +66,10 @@
<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>
<string name="flexera_about_privacy_title">What goes online</string>
<string name="flexera_about_privacy_body">Flexera Launcher goes online for two things. It asks its release page whether a newer version exists, about once a day, and downloads that version when you ask. And if you pick a place for the weather on a clock widget, it asks Open-Meteo, a weather service, for the weather there; that place is all it sends. Nothing else about you, your apps or your phone leaves it.</string>
<!-- Lawnchair's wording promised web results and grid settings that this app does not have -->
<string name="all_apps_device_search_hint">Search apps and more</string>
@@ -93,11 +93,59 @@
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
<!-- Quick launch: a swipe on past the first or the last home screen page (app.flexera.home.QuickLaunch) -->
<string name="flexera_hide_app">Hide app</string>
<!-- Shown after an app was hidden from the app list. %1$s is the app's name -->
<string name="flexera_app_hidden">%1$s is hidden</string>
<string name="flexera_folder_blur">Blur behind open folders</string>
<string name="flexera_folder_blur_description">Blurs the home screen while a folder is open</string>
<string name="flexera_quick_launch">Quick launch</string>
<string name="flexera_quick_launch_description">Swipe on from the first or the last home screen page, as if there were one more, to open an app or do something else. Has no effect while Infinite scrolling is on.</string>
<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>
<string name="flexera_clock_widget_weight">Weight</string>
<string name="flexera_clock_widget_thin">Thin</string>
<string name="flexera_clock_widget_light">Light</string>
<string name="flexera_clock_widget_regular">Regular</string>
<string name="flexera_clock_widget_bold">Bold</string>
<string name="flexera_clock_widget_weather">Weather</string>
<string name="flexera_clock_widget_weather_off">Off. Look a place up to show its weather under the date.</string>
<!-- %1$s is a place, such as "Berlin, State of Berlin, Germany" -->
<string name="flexera_clock_widget_weather_on">Showing the weather in %1$s</string>
<string name="flexera_clock_widget_weather_place">Town or city</string>
<string name="flexera_clock_widget_weather_search">Search</string>
<string name="flexera_clock_widget_weather_nothing">No place of that name was found</string>
<string name="flexera_clock_widget_weather_failed">The weather service could not be reached. Check the connection and try again.</string>
<string name="flexera_clock_widget_weather_remove">Turn weather off</string>
<string name="flexera_clock_widget_weather_source">Weather data by Open-Meteo.com. The launcher asks it about the place you pick, about once an hour while the clock is in view, and sends nothing else.</string>
<!-- The clock widget's weather line. %1$d is the temperature, %2$s a word for the sky, such as "Overcast" -->
<string name="flexera_weather_line">%1$d° · %2$s</string>
<string name="flexera_weather_clear">Clear</string>
<string name="flexera_weather_mostly_clear">Mostly clear</string>
<string name="flexera_weather_partly_cloudy">Partly cloudy</string>
<string name="flexera_weather_overcast">Overcast</string>
<string name="flexera_weather_fog">Fog</string>
<string name="flexera_weather_drizzle">Drizzle</string>
<string name="flexera_weather_rain">Rain</string>
<string name="flexera_weather_freezing_rain">Freezing rain</string>
<string name="flexera_weather_snow">Snow</string>
<string name="flexera_weather_showers">Showers</string>
<string name="flexera_weather_snow_showers">Snow showers</string>
<string name="flexera_weather_thunderstorm">Thunderstorm</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>
@@ -110,34 +158,45 @@
<!-- Updates section of the About screen (app.flexera.update.FlexeraUpdater) -->
<string name="flexera_update_heading">Updates</string>
<!-- Button -->
<string name="flexera_update_check">Check for updates</string>
<string name="flexera_update_check_idle">Flexera Launcher also checks on its own, once a day</string>
<!-- Shown on the button while the check runs -->
<string name="flexera_update_checking">Checking for updates…</string>
<string name="flexera_update_up_to_date">You have the latest version</string>
<string name="flexera_update_failed_title">The check for updates didn\'t work</string>
<!-- Button -->
<string name="flexera_update_try_again">Try again</string>
<!-- Button that asks Android for local network access -->
<string name="flexera_update_allow_local_network">Allow access</string>
<!-- Why a check for updates failed. %1$s is the update server's name, such as git.example.org -->
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then tap to try again.</string>
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again.</string>
<string name="flexera_update_failed_local_network">%1$s is on the same network as this phone, and Android lets an app reach devices there only with your permission, which it calls Nearby devices. Tap to allow it. The check then runs again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Tap to try again later.</string>
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. Tap to try again later.</string>
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then try again.</string>
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then try again.</string>
<string name="flexera_update_failed_local_network">%1$s is on the same network as this phone, and Android lets an app reach devices there only with your permission, which it calls Nearby devices. Once you allow it, the check runs again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then try again.</string>
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Try again later.</string>
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. Try again later.</string>
<!-- %1$s is the error in the system\'s own words, for whoever looks for the cause -->
<string name="flexera_update_failed_detail">Details: %1$s</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_download">Download version %1$s</string>
<string name="flexera_update_available">Version %1$s is available</string>
<!-- Button. The download is followed by the installation without another tap -->
<string name="flexera_update_download">Download and install</string>
<!-- %1$s is a file size such as 24 MB -->
<string name="flexera_update_download_size">A newer version is available (%1$s)</string>
<string name="flexera_update_download_no_size">A newer version is available</string>
<string name="flexera_update_download_failed">The download didn\'t finish. Tap to try again.</string>
<string name="flexera_update_wrong_file">The downloaded file isn\'t a newer Flexera Launcher, so it was removed. Tap to try again.</string>
<string name="flexera_update_download_size">%1$s download. The installation starts as soon as it\'s done.</string>
<string name="flexera_update_download_no_size">The installation starts as soon as the download is done.</string>
<string name="flexera_update_download_failed">The download didn\'t finish.</string>
<string name="flexera_update_wrong_file">The downloaded file isn\'t a newer Flexera Launcher, so it was removed.</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_downloading">Downloading version %1$s</string>
<!-- %1$d is a percentage from 0 to 100 -->
<string name="flexera_update_progress">%1$d%%</string>
<string name="flexera_update_progress">%1$d%%. The installation starts as soon as it\'s done.</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_install">Install version %1$s</string>
<string name="flexera_update_install_hint">Downloaded and ready. If Android asks, allow Flexera Launcher to install apps, then tap here again.</string>
<string name="flexera_update_ready">Version %1$s is ready to install</string>
<!-- Button -->
<string name="flexera_update_install">Install now</string>
<string name="flexera_update_install_hint">If Android asks, allow Flexera Launcher to install apps. The installation then carries on by itself.</string>
<string name="flexera_update_whats_new">What\'s new</string>
<string name="flexera_update_notify">Notify me about new versions</string>
<string name="flexera_update_notify_hint">Notifications are off, so a new version shows up only on this screen</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" />
+78
View File
@@ -0,0 +1,78 @@
package app.flexera.drawer
import android.view.View
import androidx.lifecycle.lifecycleScope
import app.lawnchair.LawnchairLauncher
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_ALL_APPS
import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION
import com.android.launcher3.R
import com.android.launcher3.model.data.ItemInfo
import com.android.launcher3.popup.SystemShortcut
import com.android.launcher3.util.ComponentKey
import com.android.launcher3.views.Snackbar
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* "Hide app" in the menu a long press on an app opens in the app list.
*
* It puts the app on the hidden list Lawnchair already has: the one the app list and its search
* leave out, kept under Settings > App drawer > Hidden apps, where an app is brought back. Before
* this, hiding meant going through that settings page or through Customize. A message with Undo
* follows, because the app is gone from the list the moment it is hidden.
*/
object HideApp {
/**
* Offered for apps in the app list only: hiding does nothing to an icon on the home screen,
* so offering it there would promise something else. Not offered while the home screen is
* locked against changes, like Customize and Uninstall.
*/
@JvmField
val SHORTCUT = SystemShortcut.Factory { launcher: LawnchairLauncher, itemInfo: ItemInfo, originalView: View ->
val component = itemInfo.targetComponent
when {
itemInfo.container != CONTAINER_ALL_APPS -> null
itemInfo.itemType != ITEM_TYPE_APPLICATION || component == null -> null
PreferenceManager2.getInstance(launcher).lockHomeScreen.firstCached() -> null
else -> Shortcut(launcher, itemInfo, originalView, ComponentKey(component, itemInfo.user))
}
}
private class Shortcut(
launcher: LawnchairLauncher,
itemInfo: ItemInfo,
originalView: View,
private val app: ComponentKey,
) : SystemShortcut<LawnchairLauncher>(
R.drawable.ic_flexera_hide,
R.string.flexera_hide_app,
launcher,
itemInfo,
originalView,
) {
override fun onClick(view: View) {
val launcher = mTarget
val name = mItemInfo.title?.toString().orEmpty()
dismissTaskMenuView()
setHidden(launcher, app, hidden = true)
Snackbar.show(
launcher,
launcher.getString(R.string.flexera_app_hidden, name),
R.string.undo,
null,
) { setHidden(launcher, app, hidden = false) }
}
}
private fun setHidden(launcher: LawnchairLauncher, app: ComponentKey, hidden: Boolean) {
val hiddenApps = PreferenceManager2.getInstance(launcher).hiddenApps
val key = app.toString()
launcher.lifecycleScope.launch {
val now = hiddenApps.get().first()
hiddenApps.set(if (hidden) now + key else now - key)
}
}
}
@@ -0,0 +1,29 @@
package app.flexera.home
import android.content.Context
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.Utilities
/**
* The blur of the home screen behind an open folder: whether the owner wants it, and how strong
* it is. The blur itself is the depth controller's work
* ([com.android.quickstep.util.BaseDepthControllerImpl.folderBlur]), because that already blurs
* the same views and the wallpaper for other reasons and would undo anything done past it. The
* folder's opening and closing animation moves it
* ([com.android.launcher3.folder.FolderSpringAnimatorSet]).
*/
object FolderBlur {
/**
* The blur's radius behind a fully open folder, as a share of the largest radius the
* launcher blurs with (30dp). Strong enough that the icons behind the folder are no longer
* read, which is the point: the folder is what is in focus.
*/
const val STRENGTH = 0.8f
/** Blurring a view needs Android 12. */
@JvmStatic
fun isOn(context: Context): Boolean =
Utilities.ATLEAST_S && PreferenceManager2.getInstance(context).folderBlur.firstCached()
}
+182 -55
View File
@@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,13 +18,24 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.InstallMobile
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -39,6 +51,7 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -114,7 +127,7 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
val updateRows = buildList<@Composable () -> Unit> {
if (FlexeraUpdater.isSupported) {
add { UpdateStatusRow(updateState) }
add { UpdateCard(updateState) }
val notes = updateState.release()?.notes.orEmpty()
if (notes.isNotBlank()) {
add {
@@ -200,7 +213,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(
@@ -243,12 +260,30 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
}
}
/** What the update card shows; every part may be missing. */
private class UpdateCardContent(
val title: String? = null,
val body: String? = null,
/** From 0 to 1, while a download runs. */
val progress: Float? = null,
val action: UpdateAction? = null,
)
/** The button at the bottom of the update card. */
private class UpdateAction(
val label: String,
val icon: ImageVector?,
val onClick: () -> Unit,
/** Shown as a disabled button with a spinner while something is already under way. */
val busy: Boolean = false,
)
/**
* The row that says where the updater stands and, when there is something to do, does it on tap:
* check, download, or install.
* The card that says where the updater stands and offers the next step as a large button: check,
* download (which goes on to the installation by itself), or install.
*/
@Composable
private fun UpdateStatusRow(state: UpdateState) {
private fun UpdateCard(state: UpdateState) {
val context = LocalContext.current
// Android 17 asks the owner before an app may reach devices on the local network, and an
// update server at home is one. The answer starts the check again; a "no" that Android no
@@ -265,87 +300,179 @@ private fun UpdateStatusRow(state: UpdateState) {
openAppSettings(context)
}
}
when (state) {
UpdateState.Idle -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_check_idle),
onClick = { FlexeraUpdater.check(context) },
val check = UpdateAction(
label = stringResource(id = R.string.flexera_update_check),
icon = Icons.Rounded.Refresh,
onClick = { FlexeraUpdater.check(context) },
)
val download = UpdateAction(
label = stringResource(id = R.string.flexera_update_download),
icon = Icons.Rounded.Download,
onClick = { FlexeraUpdater.download(context) },
)
val content = when (state) {
UpdateState.Idle -> UpdateCardContent(
body = stringResource(id = R.string.flexera_update_check_idle),
action = check,
)
UpdateState.Checking -> PreferenceTemplate(
title = { Text(text = stringResource(id = R.string.flexera_update_checking)) },
UpdateState.Checking -> UpdateCardContent(
body = stringResource(id = R.string.flexera_update_check_idle),
action = UpdateAction(
label = stringResource(id = R.string.flexera_update_checking),
icon = null,
onClick = {},
busy = true,
),
)
UpdateState.UpToDate -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_up_to_date),
onClick = { FlexeraUpdater.check(context) },
UpdateState.UpToDate -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_up_to_date),
body = stringResource(id = R.string.flexera_update_check_idle),
action = check,
)
is UpdateState.Available -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_download, state.release.versionName),
subtitle = downloadSizeText(context, state.release),
onClick = { FlexeraUpdater.download(context) },
is UpdateState.Available -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_available, state.release.versionName),
body = downloadSizeText(context, state.release),
action = download,
)
is UpdateState.Downloading -> PreferenceTemplate(
title = { Text(text = stringResource(id = R.string.flexera_update_downloading, state.release.versionName)) },
description = {
Column {
Text(text = stringResource(id = R.string.flexera_update_progress, (state.progress * 100).toInt()))
LinearProgressIndicator(
progress = { state.progress },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
)
}
},
is UpdateState.Downloading -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_downloading, state.release.versionName),
body = stringResource(id = R.string.flexera_update_progress, (state.progress * 100).toInt()),
progress = state.progress,
)
is UpdateState.ReadyToInstall -> ClickablePreference(
label = stringResource(id = R.string.flexera_update_install, state.release.versionName),
subtitle = stringResource(id = R.string.flexera_update_install_hint),
onClick = { FlexeraUpdater.install(context) },
is UpdateState.ReadyToInstall -> UpdateCardContent(
title = stringResource(id = R.string.flexera_update_ready, state.release.versionName),
body = stringResource(id = R.string.flexera_update_install_hint),
action = UpdateAction(
label = stringResource(id = R.string.flexera_update_install),
icon = Icons.Rounded.InstallMobile,
onClick = { FlexeraUpdater.install(context) },
),
)
is UpdateState.Failed -> {
val release = state.release
if (release == null) {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = checkFailureText(state),
onClick = {
if (state.problem == CheckProblem.LOCAL_NETWORK) {
askForLocalNetwork.launch(FlexeraUpdater.LOCAL_NETWORK_PERMISSION)
} else {
FlexeraUpdater.check(context)
}
UpdateCardContent(
title = stringResource(id = R.string.flexera_update_failed_title),
body = checkFailureText(state),
action = if (state.problem == CheckProblem.LOCAL_NETWORK) {
UpdateAction(
label = stringResource(id = R.string.flexera_update_allow_local_network),
icon = null,
onClick = { askForLocalNetwork.launch(FlexeraUpdater.LOCAL_NETWORK_PERMISSION) },
)
} else {
UpdateAction(
label = stringResource(id = R.string.flexera_update_try_again),
icon = Icons.Rounded.Refresh,
onClick = { FlexeraUpdater.check(context) },
)
},
)
} else {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_download, release.versionName),
subtitle = stringResource(
UpdateCardContent(
title = stringResource(id = R.string.flexera_update_available, release.versionName),
body = stringResource(
id = if (state.failure == UpdateFailure.WRONG_FILE) {
R.string.flexera_update_wrong_file
} else {
R.string.flexera_update_download_failed
},
),
onClick = { FlexeraUpdater.download(context) },
action = UpdateAction(
label = stringResource(id = R.string.flexera_update_try_again),
icon = Icons.Rounded.Download,
onClick = download.onClick,
),
)
}
}
}
val title = content.title
val body = content.body
val progress = content.progress
val action = content.action
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp),
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
if (body != null) {
if (title != null) Spacer(Modifier.height(4.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (progress != null) {
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
)
}
if (action != null) {
Spacer(Modifier.height(16.dp))
UpdateButton(action)
}
}
}
/** A full-width filled button, so the next step stands out from the settings rows around it. */
@Composable
private fun UpdateButton(action: UpdateAction) {
val icon = action.icon
Button(
onClick = action.onClick,
enabled = !action.busy,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
if (action.busy) {
CircularProgressIndicator(
modifier = Modifier.size(ButtonDefaults.IconSize),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
} else if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
}
Text(text = action.label, style = MaterialTheme.typography.titleSmall)
}
}
/**
* 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 +480,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 +492,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,
@@ -13,6 +13,8 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
@@ -24,6 +26,7 @@ import com.android.launcher3.BuildConfig
import com.android.launcher3.R
import java.io.File
import java.io.IOException
import java.lang.ref.WeakReference
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.NetworkInterface
@@ -149,6 +152,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
@@ -160,6 +170,22 @@ object FlexeraUpdater {
private const val KEY_LAST_ATTEMPT_OK = "last_attempt_ok"
private const val KEY_NOTIFIED_VERSION = "notified_version"
/**
* When the owner last asked for the update to be installed: by tapping Download, or Install
* while Android still had to be told to let this app install apps. Kept on disk, because
* Android may end the launcher's process while the owner is in that setting.
*/
private const val KEY_INSTALL_REQUESTED_AT = "install_requested_at"
/** True while the owner was sent to Android's "Install unknown apps" setting. */
private const val KEY_AWAITING_INSTALL_PERMISSION = "awaiting_install_permission"
/**
* How long after such a request the installer may still open by itself. An installer that
* pops up hours after the owner tapped Download would come as a surprise.
*/
private const val AUTO_INSTALL_WINDOW_MS = 30L * 60 * 1000
private const val UPDATES_DIR = "updates"
private const val APK_PREFIX = "FlexeraLauncher-"
private const val CHANNEL_ID = "flexera_updates"
@@ -182,17 +208,39 @@ object FlexeraUpdater {
private var nextAutomaticCheckAt = 0L
/**
* Starts the once-a-day automatic check. Called once, from the application's onCreate.
* The activity of this app that is in front (home screen or settings), if one is. The
* installer opens from it; Android does not let an app in the background open one.
*/
@Volatile
private var frontActivity: WeakReference<Activity>? = null
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Starts the once-a-day automatic check, and lets a requested install go ahead whenever one
* of the app's screens comes to the front. Called once, from the application's onCreate.
*/
@JvmStatic
fun start(app: Application) {
if (!isSupported) return
scope.launch { restoreDownload(app) }
scope.launch {
restoreDownload(app)
// The process may have been restarted while the owner was allowing installs.
installIfRequested()
}
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) = checkIfDue(activity.applicationContext)
override fun onActivityResumed(activity: Activity) {
frontActivity = WeakReference(activity)
checkIfDue(activity.applicationContext)
installIfRequested()
}
override fun onActivityPaused(activity: Activity) {
if (frontActivity?.get() === activity) frontActivity = null
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
@@ -264,7 +312,10 @@ object FlexeraUpdater {
}
}
/** Downloads the APK of the release that the last check found. */
/**
* Downloads the APK of the release that the last check found, then hands it to Android's
* installer straight away (see [installIfRequested]).
*/
@JvmStatic
fun download(context: Context) {
val release = when (val current = mutableState.value) {
@@ -273,6 +324,7 @@ object FlexeraUpdater {
else -> null
} ?: return
val app = context.applicationContext
requestInstall(app, awaitingPermission = false)
mutableState.value = UpdateState.Downloading(release, 0f)
scope.launch {
@@ -283,14 +335,17 @@ object FlexeraUpdater {
}
} catch (error: Exception) {
Log.w(TAG, "Update download failed", error)
clearInstallRequest(app)
mutableState.value = UpdateState.Failed(UpdateFailure.DOWNLOAD, release)
return@launch
}
if (isValidUpdate(app, target)) {
mutableState.value = UpdateState.ReadyToInstall(release, target)
installIfRequested()
} else {
Log.w(TAG, "Downloaded file is not a newer ${app.packageName}")
target.delete()
clearInstallRequest(app)
mutableState.value = UpdateState.Failed(UpdateFailure.WRONG_FILE, release)
}
}
@@ -299,18 +354,20 @@ object FlexeraUpdater {
/**
* Hands the downloaded APK to Android's installer, which asks the owner to confirm. The first
* time, Android wants the owner to allow this app to install apps; this opens that setting
* instead, and the owner taps Install again afterwards.
* instead, and the installer follows by itself when the owner comes back having allowed it.
*/
@JvmStatic
fun install(context: Context) {
val ready = mutableState.value as? UpdateState.ReadyToInstall ?: return
if (!ready.file.exists()) {
// Android cleared the cache in the meantime; find the release again.
clearInstallRequest(context)
mutableState.value = UpdateState.Idle
check(context)
return
}
if (!context.packageManager.canRequestPackageInstalls()) {
requestInstall(context, awaitingPermission = true)
val settings = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
@@ -318,6 +375,7 @@ object FlexeraUpdater {
context.startActivity(settings)
return
}
clearInstallRequest(context)
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.fileprovider", ready.file)
val install = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
@@ -325,6 +383,48 @@ object FlexeraUpdater {
context.startActivity(install)
}
/**
* Opens the installer when the owner asked for the update a short while ago, it is downloaded,
* and one of the app's screens is in front to open it from. Runs again each time a screen
* comes to the front, which is how the install carries on after the owner has allowed this
* app to install apps. Coming back without having allowed it drops the request, so the owner
* is not sent to that setting over and over; the Install button stays.
*/
private fun installIfRequested() {
mainHandler.post {
val activity = frontActivity?.get() ?: return@post
if (mutableState.value !is UpdateState.ReadyToInstall) return@post
val prefs = prefs(activity)
val requestedAt = prefs.getLong(KEY_INSTALL_REQUESTED_AT, 0L)
if (requestedAt == 0L) return@post
if (System.currentTimeMillis() - requestedAt !in 0 until AUTO_INSTALL_WINDOW_MS) {
clearInstallRequest(activity)
return@post
}
if (prefs.getBoolean(KEY_AWAITING_INSTALL_PERMISSION, false) &&
!activity.packageManager.canRequestPackageInstalls()
) {
clearInstallRequest(activity)
return@post
}
install(activity)
}
}
private fun requestInstall(context: Context, awaitingPermission: Boolean) {
prefs(context).edit()
.putLong(KEY_INSTALL_REQUESTED_AT, System.currentTimeMillis())
.putBoolean(KEY_AWAITING_INSTALL_PERMISSION, awaitingPermission)
.apply()
}
private fun clearInstallRequest(context: Context) {
prefs(context).edit()
.remove(KEY_INSTALL_REQUESTED_AT)
.remove(KEY_AWAITING_INSTALL_PERMISSION)
.apply()
}
/**
* Picks up an update that was downloaded before the launcher process was restarted, so the
* About screen can offer "Install" straight away. Also clears files left over from updates
@@ -596,6 +696,7 @@ object FlexeraUpdater {
private fun clearDownloads(context: Context) {
updatesDir(context).listFiles { file -> file.name.startsWith(APK_PREFIX) }?.forEach { it.delete() }
clearInstallRequest(context)
}
private fun apkFile(context: Context, release: FlexeraRelease): File {
@@ -0,0 +1,177 @@
package app.flexera.widget
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.WorkerThread
import com.android.launcher3.R
import com.android.launcher3.util.Executors
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.Locale
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.math.roundToInt
import org.json.JSONObject
/**
* The weather line of the clock widget: the temperature and a word for the sky at a place the
* owner has chosen.
*
* The data comes from Open-Meteo, a weather service that needs no account. It is asked only for
* a place the owner picked by name in the clock's options, never for where the phone is, so the
* launcher needs no location permission; and only while a clock with weather is in view, at most
* every [FRESH_MS]. The place's coordinates, rounded to about a kilometre, are all that is sent.
* The last answer is kept, so that the line is there at once and survives a spell offline.
*/
object ClockWeather {
/** A place found by name. [detail] tells places of one name apart: region and country. */
data class Place(val name: String, val detail: String, val latitude: Double, val longitude: Double)
/** What the service said: degrees in the unit of the phone's country, and a WMO weather code. */
data class Reading(val degrees: Int, val code: Int, val fahrenheit: Boolean, val readAt: Long)
/** Told on the main thread that a new reading has come in. */
fun interface Listener {
fun onWeatherChanged()
}
private const val TAG = "ClockWeather"
private const val FILE = "flexera_clock_weather"
private const val FORECAST = "https://api.open-meteo.com/v1/forecast"
private const val SEARCH = "https://geocoding-api.open-meteo.com/v1/search"
private const val TIMEOUT_MS = 10_000
/** A reading younger than this is not asked for again. */
private const val FRESH_MS = 45 * 60 * 1000L
/** A reading older than this is no longer shown: stale weather is worse than none. */
private const val SHOWN_MS = 3 * 60 * 60 * 1000L
/** The countries that count degrees in Fahrenheit. */
private val fahrenheitCountries = setOf("US", "BS", "BZ", "KY", "PW", "FM", "MH", "LR")
private val listeners = CopyOnWriteArraySet<Listener>()
private val asking = HashSet<String>()
private val main = Handler(Looper.getMainLooper())
fun addListener(listener: Listener) {
listeners.add(listener)
}
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
/** The places of this name, the likeliest first. Asks the service; throws when it cannot. */
@WorkerThread
@Throws(IOException::class)
fun search(name: String): List<Place> {
val language = Locale.getDefault().language.ifEmpty { "en" }
val answer = get("$SEARCH?name=${URLEncoder.encode(name, "UTF-8")}&count=6&language=$language&format=json")
val results = JSONObject(answer).optJSONArray("results") ?: return emptyList()
return (0 until results.length()).map { index ->
val place = results.getJSONObject(index)
val detail = listOf(place.optString("admin1"), place.optString("country"))
.filter { it.isNotEmpty() }
.joinToString(", ")
Place(place.getString("name"), detail, place.getDouble("latitude"), place.getDouble("longitude"))
}
}
/** The last reading for a place, if there is one that is still worth showing. */
fun reading(context: Context, place: Place): Reading? {
val stored = prefs(context).getString(key(place), null)?.split('|') ?: return null
if (stored.size != 4) return null
val reading = Reading(
degrees = stored[0].toIntOrNull() ?: return null,
code = stored[1].toIntOrNull() ?: return null,
fahrenheit = stored[2] == "F",
readAt = stored[3].toLongOrNull() ?: return null,
)
val age = System.currentTimeMillis() - reading.readAt
return if (age in 0..SHOWN_MS && reading.fahrenheit == usesFahrenheit()) reading else null
}
/** Asks for a new reading in the background, unless the last one is fresh or one is on its way. */
fun refreshIfStale(context: Context, place: Place) {
val app = context.applicationContext
val key = key(place)
val last = reading(app, place)
if (last != null && System.currentTimeMillis() - last.readAt < FRESH_MS) return
synchronized(asking) {
if (!asking.add(key)) return
}
Executors.THREAD_POOL_EXECUTOR.execute {
try {
val fahrenheit = usesFahrenheit()
val answer = get(
"$FORECAST?latitude=${coordinate(place.latitude)}&longitude=${coordinate(place.longitude)}" +
"&current=temperature_2m,weather_code" +
"&temperature_unit=${if (fahrenheit) "fahrenheit" else "celsius"}",
)
val current = JSONObject(answer).getJSONObject("current")
val degrees = current.getDouble("temperature_2m").roundToInt()
val code = current.getInt("weather_code")
prefs(app).edit()
.putString(key, "$degrees|$code|${if (fahrenheit) "F" else "C"}|${System.currentTimeMillis()}")
.apply()
main.post { listeners.forEach { it.onWeatherChanged() } }
} catch (e: Exception) {
// Offline, or the service is down: the last reading stays until it is too old.
Log.w(TAG, "No weather this time", e)
} finally {
synchronized(asking) { asking.remove(key) }
}
}
}
/** The line as the widget shows it, such as "6° · Overcast". */
fun line(context: Context, reading: Reading): String =
context.getString(R.string.flexera_weather_line, reading.degrees, context.getString(word(reading.code)))
/** A word for a WMO weather code (the codes Open-Meteo uses). */
private fun word(code: Int): Int = when (code) {
0 -> R.string.flexera_weather_clear
1 -> R.string.flexera_weather_mostly_clear
2 -> R.string.flexera_weather_partly_cloudy
3 -> R.string.flexera_weather_overcast
45, 48 -> R.string.flexera_weather_fog
51, 53, 55 -> R.string.flexera_weather_drizzle
56, 57, 66, 67 -> R.string.flexera_weather_freezing_rain
61, 63, 65 -> R.string.flexera_weather_rain
71, 73, 75, 77 -> R.string.flexera_weather_snow
80, 81, 82 -> R.string.flexera_weather_showers
85, 86 -> R.string.flexera_weather_snow_showers
95, 96, 99 -> R.string.flexera_weather_thunderstorm
else -> R.string.flexera_weather_overcast
}
private fun usesFahrenheit(): Boolean = Locale.getDefault().country in fahrenheitCountries
/** Two decimals, about a kilometre: all a forecast needs, and no more exact than that. */
private fun coordinate(value: Double): String = String.format(Locale.ROOT, "%.2f", value)
private fun key(place: Place): String = "${coordinate(place.latitude)},${coordinate(place.longitude)}"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private fun get(address: String): String {
val connection = URL(address).openConnection() as HttpURLConnection
try {
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
connection.setRequestProperty("Accept", "application/json")
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("The weather service answered HTTP ${connection.responseCode}")
}
return connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,306 @@
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.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import app.flexera.widget.ClockWidgetStore.Align
import app.flexera.widget.ClockWidgetStore.Size
import app.flexera.widget.ClockWidgetStore.Weight
import app.lawnchair.ui.theme.EdgeToEdge
import app.lawnchair.ui.theme.LawnchairTheme
import com.android.launcher3.R
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The clock widget's options: where its text stands, how large and how heavy its figures are,
* and the place its weather line is for. 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()
.verticalScroll(rememberScrollState())
.imePadding()
.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)) },
)
Spacer(modifier = Modifier.height(20.dp))
Choice(
heading = R.string.flexera_clock_widget_weight,
choices = listOf(
Weight.THIN to R.string.flexera_clock_widget_thin,
Weight.LIGHT to R.string.flexera_clock_widget_light,
Weight.REGULAR to R.string.flexera_clock_widget_regular,
Weight.BOLD to R.string.flexera_clock_widget_bold,
),
chosen = options.weight,
onChoose = { change(options.copy(weight = it)) },
)
Spacer(modifier = Modifier.height(20.dp))
WeatherChoice(
place = options.weather,
onChoose = { change(options.copy(weather = it)) },
)
}
}
}
}
}
}
@Composable
private fun Heading(@StringRes text: Int) {
Text(
text = stringResource(id = text),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
}
/** 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,
) {
Heading(heading)
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), maxLines = 1)
}
}
}
}
private enum class Search { IDLE, RUNNING, NOTHING, FAILED }
/**
* The weather line: off until the owner looks a place up by name and picks it from what the
* weather service finds. The field starts with the city of the phone's time zone as a guess.
*/
@Composable
private fun WeatherChoice(place: ClockWeather.Place?, onChoose: (ClockWeather.Place?) -> Unit) {
val scope = rememberCoroutineScope()
var name by rememberSaveable { mutableStateOf(place?.name ?: timeZoneCity()) }
var found by remember { mutableStateOf(emptyList<ClockWeather.Place>()) }
var search by remember { mutableStateOf(Search.IDLE) }
val keyboard = LocalSoftwareKeyboardController.current
val focus = LocalFocusManager.current
val look = {
val wanted = name.trim()
if (wanted.isNotEmpty() && search != Search.RUNNING) {
// The places found are listed under the field, where the keyboard would cover them.
keyboard?.hide()
focus.clearFocus()
search = Search.RUNNING
scope.launch {
val result = withContext(Dispatchers.IO) { runCatching { ClockWeather.search(wanted) } }
found = result.getOrDefault(emptyList())
search = when {
result.isFailure -> Search.FAILED
found.isEmpty() -> Search.NOTHING
else -> Search.IDLE
}
}
}
}
Heading(R.string.flexera_clock_widget_weather)
Text(
text = if (place == null) {
stringResource(id = R.string.flexera_clock_widget_weather_off)
} else {
stringResource(id = R.string.flexera_clock_widget_weather_on, placeLabel(place))
},
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(text = stringResource(id = R.string.flexera_clock_widget_weather_place)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { look() }),
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(12.dp))
Button(onClick = { look() }) {
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_search))
}
}
when (search) {
Search.RUNNING -> LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Search.NOTHING -> Note(R.string.flexera_clock_widget_weather_nothing)
Search.FAILED -> Note(R.string.flexera_clock_widget_weather_failed)
Search.IDLE -> Unit
}
found.forEach { candidate ->
Text(
text = placeLabel(candidate),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.clickable {
onChoose(candidate)
name = candidate.name
found = emptyList()
}
.padding(vertical = 14.dp),
)
}
if (place != null) {
TextButton(onClick = { onChoose(null) }) {
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_remove))
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.flexera_clock_widget_weather_source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun Note(@StringRes text: Int) {
Text(
text = stringResource(id = text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
private fun placeLabel(place: ClockWeather.Place): String =
if (place.detail.isEmpty()) place.name else "${place.name}, ${place.detail}"
/** The city a time zone is named after ("Europe/Zagreb" gives "Zagreb"); empty for zones without one. */
private fun timeZoneCity(): String {
val id = TimeZone.getDefault().id
return if ('/' in id) id.substringAfterLast('/').replace('_', ' ') else ""
}
@@ -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,111 @@
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, how
* large and how heavy its figures are, and the place its weather line is for, if it has one.
* 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") }
/** How heavy the figures are drawn; [value] is the font weight, from 100 to 900. */
enum class Weight(val key: String, val value: Int) {
THIN("thin", 100),
LIGHT("light", 300),
REGULAR("regular", 400),
BOLD("bold", 700),
}
data class Options(
val align: Align = Align.LEFT,
val size: Size = Size.MEDIUM,
val weight: Weight = Weight.LIGHT,
/** The place of the weather line; null for a clock without weather, which is how it starts. */
val weather: ClockWeather.Place? = null,
)
/** 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)
val weight = prefs.getString(WEIGHT + appWidgetId, null)
val place = prefs.getString(PLACE + appWidgetId, null)
return Options(
align = Align.entries.firstOrNull { it.key == align } ?: Align.LEFT,
size = Size.entries.firstOrNull { it.key == size } ?: Size.MEDIUM,
weight = Weight.entries.firstOrNull { it.key == weight } ?: Weight.LIGHT,
weather = place?.let {
ClockWeather.Place(
name = it,
detail = prefs.getString(PLACE_DETAIL + appWidgetId, "").orEmpty(),
latitude = prefs.getFloat(LATITUDE + appWidgetId, 0f).toDouble(),
longitude = prefs.getFloat(LONGITUDE + appWidgetId, 0f).toDouble(),
)
},
)
}
fun set(context: Context, appWidgetId: Int, options: Options) {
val edit = prefs(context).edit()
.putString(ALIGN + appWidgetId, options.align.key)
.putString(SIZE + appWidgetId, options.size.key)
.putString(WEIGHT + appWidgetId, options.weight.key)
val place = options.weather
if (place == null) {
edit.remove(PLACE + appWidgetId).remove(PLACE_DETAIL + appWidgetId)
.remove(LATITUDE + appWidgetId).remove(LONGITUDE + appWidgetId)
} else {
edit.putString(PLACE + appWidgetId, place.name)
.putString(PLACE_DETAIL + appWidgetId, place.detail)
.putFloat(LATITUDE + appWidgetId, place.latitude.toFloat())
.putFloat(LONGITUDE + appWidgetId, place.longitude.toFloat())
}
edit.apply()
listeners.forEach { it.onClockOptionsChanged(appWidgetId) }
}
/** Forgets the options of a widget that left the home screen. */
fun delete(context: Context, appWidgetId: Int) {
val edit = prefs(context).edit()
for (key in listOf(ALIGN, SIZE, WEIGHT, PLACE, PLACE_DETAIL, LATITUDE, LONGITUDE)) {
edit.remove(key + appWidgetId)
}
edit.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_"
private const val WEIGHT = "weight_"
private const val PLACE = "place_"
private const val PLACE_DETAIL = "place_detail_"
private const val LATITUDE = "latitude_"
private const val LONGITUDE = "longitude_"
}
@@ -0,0 +1,409 @@
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. Up and down the text stands in the
* middle of the widget's own cells, which is what the outline shows while the widget is edited.
*
* A clock for which the owner has picked a place has a third line, the weather there
* ([ClockWeather]).
*
* A tap opens the phone's clock app.
*/
class ClockWidgetView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs),
ClockWidgetStore.Listener,
ClockWeather.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 = ""
/** The weather line; empty for a clock without weather or while none is known. */
private var weather = ""
private val timePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
// 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)
}
private val weatherPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = weight(WEATHER_WEIGHT)
}
// Where the lines go; worked out in arrange().
private var timeX = 0f
private var timeBaseline = 0f
private var dateX = 0f
private var dateBaseline = 0f
private var weatherX = 0f
private var weatherBaseline = 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)
}
val paints = listOf(timePaint, datePaint, weatherPaint)
paints.forEach { it.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
paints.forEach {
it.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
}
} else {
paints.forEach { it.clearShadowLayer() }
}
}
/** Takes the owner's choices for this widget: how the figures are drawn, and the weather. */
private fun takeOptions() {
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
timePaint.typeface = weight(options.weight.value)
readWeather()
}
/** Shows the last known weather for the chosen place and asks for newer if it is due. */
private fun readWeather() {
val place = options.weather
weather = if (place == null) {
""
} else {
if (isAttachedToWindow && !sample) ClockWeather.refreshIfStale(context, place)
ClockWeather.reading(context, place)?.let { ClockWeather.line(context, it) }.orEmpty()
}
describe()
}
override fun onWeatherChanged() {
val before = weather
readWeather()
if (weather != before) {
arrange()
invalidate()
}
}
/** 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
takeOptions()
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
takeOptions()
arrange()
invalidate()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ClockWidgetStore.addListener(this)
ClockWeather.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()
takeOptions()
tell()
arrange()
}
override fun onDetachedFromWindow() {
ClockWidgetStore.removeListener(this)
ClockWeather.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, with the
// time and with the weather.
if (isVisible) {
tell()
onWeatherChanged()
}
}
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
describe()
arrange()
invalidate()
}
/** What a screen reader says for the widget: everything it shows. */
private fun describe() {
contentDescription = listOf(time, date, weather).filter { it.isNotEmpty() }.joinToString(", ")
}
/** 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()
if (width <= 0f || frame.height() <= 0f || time.isEmpty()) return
val scaled = resources.displayMetrics.scaledDensity
// Up and down, the room is the widget's own cells; in the list of widgets, the frame.
val roomTop = if (sample) frame.top else 0f
val roomHeight = if (sample) frame.height() else height.toFloat()
// 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 lines under the figures go with them in size, within the sizes that read well, and
// are never wider than the frame.
var smallSize = (wanted * DATE_IN_FIGURES).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
datePaint.textSize = smallSize
weatherPaint.textSize = smallSize
val widest = max(datePaint.measureText(date), weatherPaint.measureText(weather))
if (widest > width) {
smallSize *= width / widest
datePaint.textSize = smallSize
weatherPaint.textSize = smallSize
}
// What is seen of a line of words is its capitals' height; the room a font keeps above
// and below them would push the text off the middle.
val smallHeight = smallSize * CAPITALS_IN_TEXT_SIZE
val gap = smallSize * GAP_IN_DATE_SIZES
val lineGap = smallSize * LINE_GAP_IN_DATE_SIZES
val under = gap + smallHeight + if (weather.isEmpty()) 0f else lineGap + smallHeight
// The figures: as tall as wanted, unless the room above the other lines 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(roomHeight - under, width * inkHeightAtMeasure / inkWidthAtMeasure))
timePaint.textSize = max(1f, MEASURE_SIZE * figures / inkHeightAtMeasure)
timePaint.getTextBounds(time, 0, time.length, ink)
// All lines together, in the middle of the room.
val block = ink.height() + under
val top = roomTop + (roomHeight - block) / 2f
timeBaseline = top - ink.top
dateBaseline = top + ink.height() + gap + smallHeight
weatherBaseline = dateBaseline + lineGap + smallHeight
// 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)
val weatherLength = weatherPaint.measureText(weather)
when (options.align) {
Align.LEFT -> {
timeX = frame.left - ink.left
dateX = frame.left
weatherX = frame.left
}
Align.CENTER -> {
timeX = frame.centerX() - ink.exactCenterX()
dateX = frame.centerX() - dateLength / 2f
weatherX = frame.centerX() - weatherLength / 2f
}
Align.RIGHT -> {
timeX = frame.right - ink.right
dateX = frame.right - dateLength
weatherX = frame.right - weatherLength
}
}
}
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)
if (weather.isNotEmpty()) canvas.drawText(weather, weatherX, weatherBaseline, weatherPaint)
}
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)
}
}
/** The phone's own font at a weight from 100 (thin) to 900. Android 8 only knows a few. */
private fun weight(weight: Int): Typeface =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Typeface.create(Typeface.DEFAULT, weight, false)
} else {
val family = when {
weight <= 100 -> "sans-serif-thin"
weight <= 300 -> "sans-serif-light"
weight <= 400 -> "sans-serif"
weight <= 500 -> "sans-serif-medium"
else -> "sans-serif-black"
}
Typeface.create(family, Typeface.NORMAL)
}
private companion object {
const val TAG = "ClockWidgetView"
const val DATE_WEIGHT = 500
const val WEATHER_WEIGHT = 400
/**
* 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
/** How tall a font's capitals are, as a share of its text size. */
const val CAPITALS_IN_TEXT_SIZE = 0.72f
/** The space between the figures and the date, and between the lines under them, in date text sizes. */
const val GAP_IN_DATE_SIZES = 0.8f
const val LINE_GAP_IN_DATE_SIZES = 0.65f
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
}
}
}
+1 -1
View File
@@ -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.14.0
@@ -283,7 +283,8 @@ class LawnchairLauncher : QuickstepLauncher() {
super.getSupportedShortcuts(container),
Stream.concat(
Stream.of(LawnchairShortcut.UNINSTALL, LawnchairShortcut.CUSTOMIZE, LawnchairShortcut.OPEN_IN_STORE),
Stream.of(LawnchairShortcut.PAUSE_APPS),
// Flexera Launcher: "Hide app", which its factory offers in the app list only.
Stream.of(LawnchairShortcut.PAUSE_APPS, app.flexera.drawer.HideApp.SHORTCUT),
),
)
@@ -886,6 +886,13 @@ class PreferenceManager2 @Inject constructor(
defaultValue = GestureHandlerConfig.NoOp,
)
// Flexera Launcher: whether the home screen behind an open folder is blurred
// (app.flexera.home.FolderBlur). Read when a folder opens, so nothing has to be reloaded.
val folderBlur = preference(
key = booleanPreferencesKey(name = "flexera_folder_blur"),
defaultValue = true,
)
val autoUpdaterNightly = preference(
key = booleanPreferencesKey(name = "enable_nightly_auto_updater"),
defaultValue = if (BuildConfig.APPLICATION_ID.contains("nightly")) {
@@ -912,10 +919,16 @@ class PreferenceManager2 @Inject constructor(
.onEach { cachedPreferences = it }
.launchIn(scope)
// Flexera Launcher: in the five collectors below, distinctUntilChanged comes before
// drop(1). Upstream has them the other way round, which lets the first emission after
// the dropped one through whatever its value: the first write of any preference after
// the launcher started then reloaded the model, rebuilt the grid three times and
// recreated the launcher. Hiding an app from the app list is such a write, made while
// the owner looks at the launcher.
initializeIconShape(iconShape.firstCached(this))
iconShape.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { shape ->
initializeIconShape(shape)
L3ThemeManager.INSTANCE.get(context)
@@ -924,14 +937,14 @@ class PreferenceManager2 @Inject constructor(
.launchIn(scope)
hotseatMode.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { reloadHelper.restart() }
.launchIn(scope)
isHotseatEnabled.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach {
reloadHelper.recreate()
reloadHelper.reloadGrid()
@@ -939,14 +952,14 @@ class PreferenceManager2 @Inject constructor(
.launchIn(scope)
hotseatQsbProvider.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { reloadHelper.recreate() }
.launchIn(scope)
enableLabelInDock.get()
.drop(1)
.distinctUntilChanged()
.drop(1)
.onEach { reloadHelper.reloadGrid() }
.launchIn(scope)
}
@@ -33,6 +33,7 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceGroup
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
import app.lawnchair.ui.preferences.navigation.GeneralIconShape
import com.android.launcher3.R
import com.android.launcher3.Utilities
@Composable
fun FolderPreferences(
@@ -75,6 +76,14 @@ fun FolderPreferences(
valueRange = 0F..1F,
showAsPercentage = true,
)
// Flexera Launcher: blurring a view needs Android 12.
if (Utilities.ATLEAST_S) {
SwitchPreference(
adapter = prefs2.folderBlur.getAdapter(),
label = stringResource(id = R.string.flexera_folder_blur),
description = stringResource(id = R.string.flexera_folder_blur_description),
)
}
}
PreferenceGroup(heading = stringResource(id = R.string.grid)) {
SliderPreference(
@@ -32,6 +32,7 @@ import android.view.ViewTreeObserver;
import androidx.annotation.VisibleForTesting;
import com.android.launcher3.AbstractFloatingView;
import com.android.launcher3.BaseActivity;
import com.android.launcher3.LauncherState;
import com.android.launcher3.Utilities;
@@ -220,6 +221,12 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
* Clears a stuck workspace icon blur after resume when settled on home.
*/
public void clearStuckBlurOnResumeIfHome() {
// Flexera Launcher: a folder blur with no folder on the screen, open or still closing,
// is a stuck one too. It should not come about; this makes sure it could not stay.
if (folderBlur.getValue() > 0f && AbstractFloatingView.getAnyView(
mLauncher, AbstractFloatingView.TYPE_FOLDER) == null) {
folderBlur.setValue(0f);
}
if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
&& !mLauncher.getStateManager().isInTransition()) {
clearWorkspaceRenderEffects();
@@ -227,10 +234,18 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
}
}
@Override
protected boolean isDepthEffectEnabled() {
return mEnableDepth;
}
@Override
protected void applyDepthAndBlur() {
try {
if (LawnchairQuickstepCompat.ATLEAST_R && mEnableDepth) {
// Flexera Launcher: the blur behind an open folder is a setting of its own, and
// also comes where the wallpaper's zoom is switched off.
if (LawnchairQuickstepCompat.ATLEAST_R
&& (mEnableDepth || folderBlurNeedsApplying())) {
ensureDependencies();
super.applyDepthAndBlur();
} else if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
@@ -90,6 +90,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
public final MultiProperty widgetDepth;
/** Property to set just the wallpaper zoom for folder expansion. */
public final MultiProperty folderZoom;
/**
* Flexera Launcher: property to set the blur behind an open folder, from 0 to 1. Unlike the
* depth it comes with no zoom, and it reaches the icons as well as the wallpaper.
*/
public final MultiProperty folderBlur;
/**
* Blur radius when completely zoomed out, in pixels.
@@ -106,6 +111,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
private float mDepth;
// Used just for animating wallpaper zoom without affecting blur.
private final AnimatedFloat mWallpaperZoomOnly = new AnimatedFloat(this::applyDepthAndBlur);
// Flexera Launcher: the blur behind an open folder, see folderBlur.
private final AnimatedFloat mFolderBlur = new AnimatedFloat(this::applyDepthAndBlur);
// Whether a folder blur is on the launcher's surface, and so has to come off again.
private boolean mFolderBlurApplied;
protected SurfaceControl mBaseSurface;
@@ -164,6 +173,9 @@ public class BaseDepthControllerImpl implements BaseDepthController {
} else {
folderZoom = null;
}
// Flexera Launcher
folderBlur = new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 1, Float::max)
.get(0);
// mEarlyWakeupInfo.token = new Binder();
// mEarlyWakeupInfo.trace = BaseDepthControllerImpl.class.getName();
@@ -184,6 +196,53 @@ public class BaseDepthControllerImpl implements BaseDepthController {
return folderZoom;
}
@Override
public MultiProperty getFolderBlur() {
return folderBlur;
}
/** Flexera Launcher: the blur behind an open folder as it is now, from 0 to 1. */
private float folderBlurAmount() {
return Utilities.boundToRange(mFolderBlur.value, 0f, 1f);
}
/**
* Flexera Launcher: true while a folder blur is wanted, and after that until it has been
* taken off the launcher's surface again.
*/
protected boolean folderBlurNeedsApplying() {
return folderBlurAmount() > 0f || mFolderBlurApplied;
}
/** Flexera Launcher: false where the owner switched the wallpaper's zoom off. */
protected boolean isDepthEffectEnabled() {
return true;
}
/**
* Flexera Launcher: blurs the icons and widgets behind an open folder. That works without
* blur across windows as well, so also where the wallpaper has to stay sharp.
*
* @return {@code false} when no folder blur is wanted, and nothing was done
*/
private boolean blurFolderBackdrop() {
float amount = folderBlurAmount();
if (!Utilities.ATLEAST_S || amount <= 0f) {
return false;
}
float radius = Math.max(mCurrentBlur,
amount * app.flexera.home.FolderBlur.STRENGTH * mMaxBlurRadius);
if (radius < 1f) {
// Too little to see, and a blur of no radius is not one the system accepts.
mLauncher.getDepthBlurTargets().forEach(target -> target.setRenderEffect(null));
return true;
}
RenderEffect blurEffect = RenderEffect.createBlurEffect(
radius, radius, Shader.TileMode.DECAL);
mLauncher.getDepthBlurTargets().forEach(target -> target.setRenderEffect(blurEffect));
return true;
}
/**
* Returns if cross window blurs are enabled. In other words, whether launcher should use blurs
* style UI or fallback style UI.
@@ -236,7 +295,9 @@ public class BaseDepthControllerImpl implements BaseDepthController {
boolean applyImmediately, boolean skipSimilarBlur) {
float depth = mDepth;
float wallpaperZoom = depth;
if (Flags.enableExpressiveFolderExpansion()) {
// Flexera Launcher: with the zoom switched off this used to be reached by accident
// only; the folder blur now brings it here on purpose, and must not bring the zoom along.
if (Flags.enableExpressiveFolderExpansion() && isDepthEffectEnabled()) {
wallpaperZoom = Math.max(wallpaperZoom, mWallpaperZoomOnly.value);
}
IBinder windowToken = mLauncher.getRootView().getWindowToken();
@@ -261,6 +322,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
} else {
blurAmount = depth;
}
// Flexera Launcher: an open folder blurs what is behind it, whatever the depth is.
float behindFolder = folderBlurAmount() * app.flexera.home.FolderBlur.STRENGTH;
blurAmount = Math.max(blurAmount, behindFolder);
mFolderBlurApplied = behindFolder > 0f;
int previousBlur = mCurrentBlur;
int newBlur = BlurUtils.supportsBlursOnWindows() && mCrossWindowBlursEnabled
@@ -378,6 +443,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
if (!Utilities.ATLEAST_S) {
return false;
}
// Flexera Launcher: an open folder blurs these views in every state, the home screen
// at rest included, which the rules below would leave sharp.
if (blurFolderBackdrop()) {
return true;
}
StateManager<LauncherState, Launcher> stateManager = mLauncher.getStateManager();
LauncherState settledState = stateManager.getState();
@@ -414,6 +484,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
if (!Utilities.ATLEAST_S) {
return;
}
// Flexera Launcher: the blur behind an open folder is not a stuck one, and stays.
if (blurFolderBackdrop()) {
return;
}
mLauncher.getDepthBlurTargets().forEach(target -> {
target.setRenderEffect(null);
target.invalidate();
+9 -1
View File
@@ -185,8 +185,16 @@ public class Hotseat extends FrameLayout implements Insettable {
// Create an initial page so alpha/translation channels have a target during construction.
mPagedView.resetPages(false, null, mActivity.getDeviceProfile());
mIconsAlphaChannels = new MultiValueAlpha(mIconsContainer, ALPHA_CHANNEL_CHANNELS_COUNT);
// Flexera Launcher: one more channel than upstream, which hides the icons of a dock
// that is switched off. Such a dock has no height, but its icons were still drawn,
// just below the screen edge, and the zoom-out behind an open folder brought their
// tops into view.
mIconsAlphaChannels = new MultiValueAlpha(mIconsContainer,
ALPHA_CHANNEL_CHANNELS_COUNT + 1);
mIconsAlphaChannels.setUpdateVisibility(true);
if (!hotseatEnabled) {
mIconsAlphaChannels.get(ALPHA_CHANNEL_CHANNELS_COUNT).setValue(0f);
}
if (mQsb instanceof Reorderable qsbReorderable) {
mQsbTranslationX = qsbReorderable.getTranslateDelegate()
.getTranslationX(MultiTranslateDelegate.INDEX_NAV_BAR_ANIM);
@@ -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);
@@ -81,6 +81,8 @@ class FolderAnimationSpringBuilderManager(
val launcher = launcherDelegate.launcher ?: return
val scrim = launcher.scrimView
launcherDelegate.launcher?.depthController?.folderZoom?.value = 0f
// Flexera Launcher: the blur behind the folder goes with it.
launcherDelegate.launcher?.depthController?.folderBlur?.value = 0f
if (!launcher.isInState(LauncherState.ALL_APPS)) {
scrim.alpha = 1f
scrim.setBackgroundColor(Color.TRANSPARENT)
@@ -57,6 +57,8 @@ class FolderScrimAnimationListener(
// Folder.closeComplete's restore path, leaving workspace/hotseat visually dimmed/scaled.
SCALE_PROPERTY.set(launcher.workspace, 1f)
SCALE_PROPERTY.set(launcher.hotseat, 1f)
// Flexera Launcher: and the blur behind the folder, for the same reason.
launcher.depthController?.folderBlur?.value = 0f
if (Utilities.ATLEAST_S && launcher.stateManager.state.getDepth(launcher) == 0f) {
launcher.depthBlurTargets.forEach { it.setRenderEffect(null) }
}
@@ -91,6 +91,12 @@ class FolderSpringAnimatorSet(val animatorSet: AnimatorSet) {
launcherDelegate.launcher?.depthController?.folderZoom?.let {
addWallpaperZoomAnimator(folder.context, animatorSet, folderAnimData.isOpening, it)
}
addBackdropBlurAnimator(
folder.context,
animatorSet,
folderAnimData.isOpening,
launcherDelegate,
)
iconAnimData.forEach { addContentIconAnimators(folder.context, animatorSet, it) }
return FolderSpringAnimatorSet(animatorSet)
}
@@ -258,6 +264,39 @@ class FolderSpringAnimatorSet(val animatorSet: AnimatorSet) {
)
}
/**
* Flexera Launcher: blurs the home screen behind the folder as it opens, and clears it
* again as it closes, in step with the dimming. Closing always plays, also when the
* setting has been switched off since the folder was opened.
*/
private fun addBackdropBlurAnimator(
context: Context,
animatorSet: AnimatorSet,
isOpening: Boolean,
launcherDelegate: LauncherDelegate,
) {
val launcher = launcherDelegate.launcher ?: return
// As with the dimming: a folder in the app list leaves the list as it is.
if (launcher.isInState(LauncherState.ALL_APPS)) return
val blur = launcher.depthController?.folderBlur ?: return
val wanted =
if (isOpening) app.flexera.home.FolderBlur.isOn(context) else blur.value > 0f
if (!wanted) return
playSpringAnimation(
context = context,
animatorSet = animatorSet,
isOpening = isOpening,
startDelay = 0,
stiffness = STIFFNESS_LAUNCHER_SCRIM,
damping = DAMPING_LAUNCHER_SCRIM,
startValue = 0f,
endValue = 1f,
minVisibleChange = MIN_VISIBLE_CHANGE_ALPHA,
property = MULTI_PROPERTY_VALUE,
view = blur,
)
}
private fun addAlphaAndColorAnimators(
folder: Folder,
animatorSet: AnimatorSet,
@@ -40,4 +40,10 @@ interface BaseDepthController {
* Property for folder expansion wallpaper zoom.
*/
val folderZoom: MultiPropertyFactory<*>.MultiProperty?
/**
* Flexera Launcher: property for the blur of the home screen behind an open folder, from 0
* (none) to 1 (as strong as it gets).
*/
val folderBlur: MultiPropertyFactory<*>.MultiProperty?
}
@@ -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;
}