Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9026a8ec7a | ||
|
|
f5d7cf07ed | ||
|
|
0dbb146c75 | ||
|
|
900071ccac | ||
|
|
0fbefd2ca6 | ||
|
|
058fecd27f | ||
|
|
6c68cb1369 | ||
|
|
f1fa44b086 | ||
|
|
c93bb1df9c | ||
|
|
bfb70cab63 | ||
|
|
4746d194f4 | ||
|
|
12ec415e48 | ||
|
|
89fa6ef6ca | ||
|
|
d273543070 | ||
|
|
c8da9f301c | ||
|
|
77278a11e0 | ||
|
|
81c5c8914a | ||
|
|
3b7388c473 | ||
|
|
4f687957fb | ||
|
|
b79579a8dd | ||
|
|
516c4efc56 | ||
|
|
8e6b737894 | ||
|
|
31ab0d52e1 |
No files matched your search
@@ -0,0 +1,64 @@
|
||||
# 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, four times over: a looping teaser in
|
||||
the hero, one driven by the scroll position through the eight "How it works" chapters
|
||||
(`storyState()` in `script.js`), one in the playground that answers to touch and mouse, and the
|
||||
list phone of the race (the playground's controller, locked to the app list). All of them render
|
||||
the same state object, described in `blank()`. The 40 app names and icons on it are generic
|
||||
stand-ins, not real apps.
|
||||
|
||||
Other moving parts, all in `script.js`:
|
||||
|
||||
- **Wave headline** (`setupWaveTitle`): the hero's letters swell from weight 560 to 900 around the
|
||||
mouse; without a mouse a wave rolls through on its own.
|
||||
- **Page alphabet** (`setupPageWave`): one letter per section on the right edge. Hover to see the
|
||||
wave, drag to jump between sections, tap to scroll there. On phones it shows while scrolling.
|
||||
- **Detail-card switches** (`setupLook`): left-handed alphabet, comfy rows and icon shape, applied
|
||||
to every phone on the page.
|
||||
- **Every layer, pulled apart** (`setupAnatomy`): the story's eighth chapter. The story phone is
|
||||
swapped for a double built from separate layers (it looks the same closed), which then comes
|
||||
apart in 3D with a labelled tag per layer and goes back together.
|
||||
- **Race** (`setupRace`): find an app in a shuffled grid of 40, then in the list with the alphabet.
|
||||
- **Magnetic buttons and the finger cursor** (`setupPointerFx`), desktop only.
|
||||
- **Easter eggs**: scrub to F in the playground, or type "flexera" anywhere.
|
||||
|
||||
Everything that moves on its own stops for visitors who ask their system for reduced motion.
|
||||
|
||||
## Fonts
|
||||
|
||||
All four are under the SIL Open Font License 1.1 (`fonts/*-OFL.txt`), downloaded from Google Fonts
|
||||
and served from this folder.
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -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 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,476 @@
|
||||
<!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-alarm" viewBox="0 0 24 24"><circle cx="12" cy="13" r="7"/><path d="M12 10v3.2l2.2 1.6M5 4L2.8 6.2M19 4l2.2 2.2"/></symbol>
|
||||
<symbol id="g-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M15.5 8.5l-2 5-5 2 2-5z"/></symbol>
|
||||
<symbol id="g-game" viewBox="0 0 24 24"><rect x="2.5" y="7" width="19" height="10" rx="5"/><path d="M7 10.5v3M5.5 12h3M15.5 11h.01M17.5 13h.01"/></symbol>
|
||||
<symbol id="g-heart" viewBox="0 0 24 24"><path d="M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"/></symbol>
|
||||
<symbol id="g-ruler" viewBox="0 0 24 24"><path d="M3 16.5L16.5 3 21 7.5 7.5 21z"/><path d="M7 12.5l1.5 1.5M10 9.5l1.5 1.5M13 6.5l1.5 1.5"/></symbol>
|
||||
<symbol id="g-radio" viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="12" rx="2"/><circle cx="15.5" cy="14" r="2.5"/><path d="M7 12h3M7 15h3M6 8l11-4"/></symbol>
|
||||
<symbol id="g-chart" viewBox="0 0 24 24"><path d="M3 17l5-5 4 3 8-8"/><path d="M15 7h5v5"/></symbol>
|
||||
<symbol id="g-check" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="4"/><path d="M8 12.5l2.5 2.5L16 9.5"/></symbol>
|
||||
<symbol id="g-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></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>
|
||||
<a href="#race">Race</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>
|
||||
|
||||
<!-- The page's own wave alphabet: one letter per section. Drag it like the launcher's. -->
|
||||
<nav class="pagewave" aria-label="Jump to a section" data-pagewave>
|
||||
<a href="#top" data-label="Flexera">F</a>
|
||||
<a href="#how" data-label="How it works">H</a>
|
||||
<a href="#anatomy" data-label="Under the glass">U</a>
|
||||
<a href="#details" data-label="Details">D</a>
|
||||
<a href="#privacy" data-label="Privacy">P</a>
|
||||
<a href="#play" data-label="Touch it">T</a>
|
||||
<a href="#race" data-label="Race">R</a>
|
||||
<a href="#get" data-label="Get it">G</a>
|
||||
<span class="pagewave__bubble" aria-hidden="true"><b>F</b><i>Flexera</i></span>
|
||||
</nav>
|
||||
|
||||
<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" data-wave>
|
||||
<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="story__phones">
|
||||
<div class="phone-host" data-phone="story"></div>
|
||||
<div class="xhost" data-xphone></div>
|
||||
</div>
|
||||
<svg class="anatomy__lines" data-xlines></svg>
|
||||
<ol class="anatomy__labels" data-xlabels>
|
||||
<li><i>05</i><b>Glass</b><span>No feed. No ads.</span></li>
|
||||
<li><i>04</i><b>App list</b><span>A to Z + wave alphabet</span></li>
|
||||
<li><i>03</i><b>Icons & folders</b><span>Blur behind folders</span></li>
|
||||
<li><i>02</i><b>Widgets</b><span>Clock, weather, pictures</span></li>
|
||||
<li><i>01</i><b>Wallpaper</b><span>Yours. Untouched.</span></li>
|
||||
</ol>
|
||||
<ol class="story__rail" data-story-rail>
|
||||
<li></li><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 / 08</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 / 08</p>
|
||||
<h3 class="step__title">Swipe up. A to 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 / 08</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 / 08</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 / 08</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 / 08</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 / 08</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>
|
||||
<article class="step step--long" data-step id="anatomy">
|
||||
<div class="step__card">
|
||||
<p class="step__no">08 / 08</p>
|
||||
<h3 class="step__title">Every layer. <span class="grad">Pulled apart.</span></h3>
|
||||
<p class="step__text">Under the glass sit five layers: your wallpaper, the widgets, icons and folders, the app list with its wave alphabet, and the glass on top. No feed and no ads hiding in between.</p>
|
||||
<p class="step__gesture"><span>Gesture</span> Keep scrolling</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>
|
||||
|
||||
<p class="cards__note rv">Cards 01, 02 and 05 are switches. Try them: they restyle every phone on this page.</p>
|
||||
<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>
|
||||
<button class="card__try" type="button" data-look="lefty" aria-pressed="false">Flip the alphabet</button>
|
||||
</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>
|
||||
<div class="card__seg" role="group" aria-label="Row size">
|
||||
<button type="button" data-look="comfy" data-v="0" aria-pressed="true">Compact</button>
|
||||
<button type="button" data-look="comfy" data-v="1" aria-pressed="false">Comfy</button>
|
||||
</div>
|
||||
</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" role="group" aria-label="Icon shape">
|
||||
<button type="button" data-shape="squircle" aria-pressed="true" aria-label="Squircle"><i></i></button>
|
||||
<button type="button" data-shape="circle" aria-pressed="false" aria-label="Circle"><i></i></button>
|
||||
<button type="button" data-shape="square" aria-pressed="false" aria-label="Square"><i></i></button>
|
||||
<button type="button" data-shape="teardrop" aria-pressed="false" aria-label="Teardrop"><i></i></button>
|
||||
</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. Tap a shape above.</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>
|
||||
<li><b>Scrub to F</b> for a surprise</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RACE -->
|
||||
<section class="race" id="race" aria-labelledby="race-title">
|
||||
<header class="section-head">
|
||||
<p class="tag rv">[ 05 ] Race</p>
|
||||
<h2 class="h2 rv" id="race-title">
|
||||
<span class="line"><span class="line__in">Find it.</span></span>
|
||||
<span class="line"><span class="line__in grad">Faster.</span></span>
|
||||
</h2>
|
||||
</header>
|
||||
<div class="race__grid">
|
||||
<div class="race__stage" data-race-stage data-phase="idle">
|
||||
<p class="race__pill" aria-hidden="true">Find <b data-race-pill>Podcasts</b></p>
|
||||
<div class="phone-host race__host race__host--grid" data-race-grid></div>
|
||||
<div class="phone-host race__host race__host--list" data-phone="race"></div>
|
||||
<div class="race__overlay" data-race-overlay aria-hidden="true">
|
||||
<p class="race__round" data-race-round>Round 1</p>
|
||||
<p class="race__msg" data-race-msg>A grid of 40 apps. Find the one named beside it, as fast as you can.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="race__side">
|
||||
<p class="race__find">Find <b data-race-target>Podcasts</b></p>
|
||||
<div class="race__times">
|
||||
<div class="race__time"><span>Round 1<i>A grid of 40 apps</i></span><b data-race-t1>–</b></div>
|
||||
<div class="race__time"><span>Round 2<i>Flexera's list and wave alphabet</i></span><b data-race-t2>–</b></div>
|
||||
</div>
|
||||
<p class="race__verdict" aria-live="polite" data-race-verdict>Two rounds, the same 40 apps. In round 2, drag along the letters on the edge.</p>
|
||||
<button class="btn btn--big btn--pink" type="button" data-race-start>Start the race <svg class="gly" aria-hidden="true"><use href="#g-arrow"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GET IT -->
|
||||
<section class="get" id="get" aria-labelledby="get-title">
|
||||
<p class="tag tag--ink rv">[ 06 ] 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 → Apps → Default apps → 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 → Apps → Default apps → 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>© 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>
|
||||
@@ -0,0 +1,930 @@
|
||||
/* 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__phones { position: relative; z-index: 1; height: min(84vh, 880px); height: min(84svh, 880px); aspect-ratio: 414 / 868; }
|
||||
.story__phones .phone-host, .story__phones .xhost { position: absolute; inset: 0; height: auto; }
|
||||
.story__phones .xhost { visibility: hidden; }
|
||||
/* Chapter 8 is two screens long; its text stays put while the phone comes apart. */
|
||||
.step.step--long { min-height: 300vh; min-height: 300svh; align-items: flex-start; }
|
||||
.step.step--long .step__card { position: sticky; top: max(96px, calc(50vh - 170px)); }
|
||||
.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); transition: right .55s cubic-bezier(.7, 0, .3, 1); }
|
||||
.art-flip.is-left .art-flip__phone b { right: 96px; }
|
||||
/* 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; } }
|
||||
/* icon shapes to pick from */
|
||||
.art-shapes { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.art-shapes button { all: unset; cursor: pointer; padding: 5px; border: var(--b) solid transparent; transition: border-color .2s, transform .15s; }
|
||||
.art-shapes button:hover { transform: translateY(-3px); }
|
||||
.art-shapes button:focus-visible { outline: 3px solid var(--cyan); outline-offset: 2px; }
|
||||
.art-shapes button[aria-pressed="true"] { border-color: var(--cyan); }
|
||||
.art-shapes i { display: block; width: 60px; height: 60px; background: var(--grad); border: var(--b) solid var(--fg); }
|
||||
.art-shapes [data-shape="squircle"] i { border-radius: 30%; }
|
||||
.art-shapes [data-shape="circle"] i { border-radius: 50%; }
|
||||
.art-shapes [data-shape="square"] i { border-radius: 14%; }
|
||||
.art-shapes [data-shape="teardrop"] i { border-radius: 50% 50% 50% 14%; }
|
||||
|
||||
/* switches on the cards */
|
||||
.cards__note { max-width: var(--max); margin: 0 auto 22px; padding: 0 var(--pad); font: 700 13px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--pink); }
|
||||
.card__try, .card__seg button {
|
||||
font: 700 12.5px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
|
||||
padding: 12px 15px; border: var(--b) solid currentColor; background: transparent; color: inherit; cursor: pointer;
|
||||
transition: background .2s, color .2s, transform .14s, box-shadow .14s, translate .3s cubic-bezier(.2, .8, .2, 1);
|
||||
}
|
||||
.card__try { align-self: flex-start; margin-top: 24px; box-shadow: 4px 4px 0 var(--cyan); }
|
||||
.card__try:active { transform: translate(3px, 3px); box-shadow: 1px 1px 0 var(--cyan); }
|
||||
.card__try[aria-pressed="true"] { background: var(--cyan); color: var(--ink); border-color: var(--cyan); }
|
||||
.card__seg { display: inline-flex; align-self: flex-start; margin-top: 24px; }
|
||||
.card__seg button + button { border-left: 0; }
|
||||
.card__seg button[aria-pressed="true"] { background: var(--ink); color: var(--cyan); border-color: var(--ink); }
|
||||
/* 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: var(--shape, 30%);
|
||||
transition: border-radius .35s ease;
|
||||
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: var(--shape, 30%); transition: border-radius .35s ease; 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: var(--shape-w, 30px); transition: border-radius .35s ease; 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); }
|
||||
/* Settings from the detail cards. */
|
||||
.is-comfy .row { height: 66px; }
|
||||
.is-comfy .row--note { height: 78px; }
|
||||
.is-comfy .row .ic { width: 48px; height: 48px; }
|
||||
.is-comfy .row__n { font-size: 19.5px; }
|
||||
.is-lefty .list { padding: 0 20px 0 58px; }
|
||||
.is-lefty .alpha { right: auto; left: 8px; }
|
||||
.row.held { background: rgba(255, 255, 255, .1); }
|
||||
|
||||
.alpha { position: absolute; right: 8px; bottom: 112px; 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__s svg { display: block; width: 15px; height: 15px; pointer-events: none; } /* unsized, an svg is 300 px wide and would cover the list */
|
||||
.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%); }
|
||||
|
||||
/* ---------- the page's wave alphabet ---------- */
|
||||
.pagewave {
|
||||
position: fixed; right: 8px; top: 50%; translate: 0 -50%; z-index: 85;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 10px 6px; touch-action: none; user-select: none; -webkit-user-select: none;
|
||||
}
|
||||
.pagewave a {
|
||||
display: grid; place-items: center; width: 26px; height: 27px;
|
||||
font: 800 12px/1 var(--display); color: rgba(255, 255, 255, .62); text-decoration: none;
|
||||
transform-origin: 100% 50%; transition: color .2s;
|
||||
mix-blend-mode: difference; /* readable on the dark, cyan and violet sections alike */
|
||||
}
|
||||
.pagewave a:hover, .pagewave a:focus-visible { color: #fff; }
|
||||
.pagewave a.on { color: #fff; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; }
|
||||
.pagewave__bubble {
|
||||
position: absolute; right: 66px; top: 0;
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px 14px 6px 6px;
|
||||
background: var(--fg); color: var(--ink); border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--pink);
|
||||
opacity: 0; pointer-events: none; white-space: nowrap; transition: opacity .2s;
|
||||
}
|
||||
.pagewave__bubble b { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; background: var(--grad); color: #fff; font: 900 20px/1 var(--display); }
|
||||
.pagewave__bubble i { font: 800 12.5px/1 var(--mono); font-style: normal; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.pagewave.is-active .pagewave__bubble { opacity: 1; }
|
||||
|
||||
/* ---------- toast, confetti, finger cursor ---------- */
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 22px; z-index: 120; max-width: calc(100vw - 32px);
|
||||
transform: translate(-50%, 160%);
|
||||
padding: 13px 16px; background: var(--cyan); color: var(--ink);
|
||||
border: var(--b) solid var(--ink); box-shadow: 5px 5px 0 var(--ink);
|
||||
font: 700 13px/1.35 var(--mono); text-transform: uppercase; letter-spacing: .05em;
|
||||
transition: transform .4s cubic-bezier(.2, .8, .2, 1);
|
||||
}
|
||||
.toast.is-on { transform: translate(-50%, 0); }
|
||||
.confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 150; }
|
||||
.tcursor {
|
||||
position: fixed; left: 0; top: 0; z-index: 140; width: 46px; height: 46px; margin: -23px 0 0 -23px;
|
||||
border-radius: 50%; background: rgba(255, 255, 255, .2); border: 2px solid rgba(255, 255, 255, .9);
|
||||
box-shadow: 0 0 0 6px rgba(255, 255, 255, .08), 0 10px 26px rgba(0, 0, 0, .35);
|
||||
pointer-events: none; opacity: 0; transition: opacity .2s, scale .15s;
|
||||
}
|
||||
.over-phone .tcursor { opacity: 1; }
|
||||
.tcursor.is-down { scale: .82; }
|
||||
.over-phone .tcursor.is-down { opacity: 0; } /* the phone draws its own finger while pressed */
|
||||
.over-phone .phone-host, .over-phone .phone-host * { cursor: none !important; }
|
||||
.btn, .chip { transition: transform .14s ease, box-shadow .14s ease, background .2s, color .2s, translate .35s cubic-bezier(.2, .8, .2, 1); }
|
||||
|
||||
/* ---------- chapter 8: the phone pulled apart ---------- */
|
||||
.xhost { perspective: 2600px; }
|
||||
.xphone { position: absolute; left: 0; top: 0; width: 414px; height: 868px; transform-origin: 0 0; transform-style: preserve-3d; --e: 0; }
|
||||
.xl { position: absolute; left: 12px; top: 12px; width: 390px; height: 844px; border-radius: 48px; overflow: hidden; font-family: var(--ui); color: #fff; box-shadow: 0 0 0 2px rgba(118, 250, 252, calc(var(--e) * .55)); }
|
||||
.xl * { box-sizing: border-box; }
|
||||
.xl .ic svg, .xl .gl { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.xl--body::before, .xl--body::after { content: ""; position: absolute; right: -4px; width: 4px; border-radius: 0 3px 3px 0; background: #2b2940; }
|
||||
.xl--body::before { top: 190px; height: 70px; }
|
||||
.xl--body::after { top: 290px; height: 110px; }
|
||||
.xl--body { overflow: visible; left: 0; top: 0; width: 414px; height: 868px; 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; }
|
||||
.xl--list { background: rgba(5, 4, 20, calc(.35 + var(--e) * .3)); }
|
||||
.xl--list .list { top: 46px; }
|
||||
.xl--list .alpha { bottom: 112px; }
|
||||
.xl--glass::before { content: ""; position: absolute; inset: 0; opacity: var(--e); background: linear-gradient(125deg, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0) 38%, rgba(255, 255, 255, .07) 68%, rgba(255, 255, 255, 0)); }
|
||||
.xl__pin { position: absolute; right: 14px; bottom: 14px; width: 1px; height: 1px; }
|
||||
.anatomy__lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; opacity: 0; }
|
||||
.anatomy__lines path { fill: none; stroke: var(--cyan); stroke-width: 2; }
|
||||
.anatomy__lines circle { fill: var(--bg); stroke: var(--cyan); stroke-width: 2.5; }
|
||||
.anatomy__labels { position: absolute; inset: 0; list-style: none; margin: 0; padding: 0; pointer-events: none; } /* out of the flow, or the stage's grid would split its height and the phone sit high */
|
||||
.anatomy__labels li {
|
||||
position: absolute; left: 0; top: 0; z-index: 3; width: 214px; translate: 0 -50%;
|
||||
padding: 9px 12px 10px; background: rgba(7, 7, 13, .94);
|
||||
border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet);
|
||||
opacity: 0; pointer-events: none; will-change: transform;
|
||||
}
|
||||
.anatomy__labels li { transition: border-color .25s, box-shadow .25s; }
|
||||
.anatomy__labels li.is-on { border-color: var(--cyan); box-shadow: 4px 4px 0 var(--cyan); }
|
||||
.anatomy__labels li.is-on b { color: var(--cyan); }
|
||||
.anatomy__labels i { margin-right: 8px; font: 700 11px/1 var(--mono); font-style: normal; color: var(--cyan); }
|
||||
.anatomy__labels b { font: 900 13px/1.15 var(--display); text-transform: uppercase; letter-spacing: -.01em; }
|
||||
.anatomy__labels span { display: block; margin-top: 6px; font: 500 12px/1.3 var(--mono); color: var(--dim); white-space: nowrap; }
|
||||
|
||||
/* ---------- race ---------- */
|
||||
.race__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);
|
||||
}
|
||||
.race__stage { position: relative; height: min(80vh, 840px); height: min(80svh, 840px); aspect-ratio: 414 / 868; margin: 0 auto; }
|
||||
.race__host { position: absolute; inset: 0; transition: opacity .35s ease; touch-action: none; }
|
||||
.race__stage[data-phase="between"] .race__host--grid,
|
||||
.race__stage[data-phase="r2"] .race__host--grid,
|
||||
.race__stage[data-phase="done"] .race__host--grid,
|
||||
.race__stage[data-phase="idle"] .race__host--list,
|
||||
.race__stage[data-phase="r1"] .race__host--list { opacity: 0; pointer-events: none; }
|
||||
.race__overlay {
|
||||
position: absolute; left: 8%; right: 8%; top: 50%; translate: 0 -50%; z-index: 3;
|
||||
padding: 22px; background: var(--fg); color: var(--ink);
|
||||
border: var(--b) solid var(--ink); box-shadow: 8px 8px 0 var(--pink);
|
||||
transition: opacity .25s, scale .25s;
|
||||
}
|
||||
.race__stage[data-phase="r1"] .race__overlay, .race__stage[data-phase="r2"] .race__overlay, .race__stage[data-phase="done"] .race__overlay { opacity: 0; scale: .9; pointer-events: none; }
|
||||
.race__pill {
|
||||
position: absolute; left: 50%; top: -18px; translate: -50% 0; z-index: 4; margin: 0; white-space: nowrap;
|
||||
padding: 9px 14px; background: var(--cyan); color: var(--ink); border: var(--b) solid var(--ink); box-shadow: 4px 4px 0 var(--ink);
|
||||
font: 700 13px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
|
||||
opacity: 0; transition: opacity .25s;
|
||||
}
|
||||
.race__pill b { font-family: var(--display); font-weight: 900; }
|
||||
.race__stage[data-phase="r1"] .race__pill, .race__stage[data-phase="r2"] .race__pill, .race__stage[data-phase="between"] .race__pill { opacity: 1; }
|
||||
.race__round { margin: 0; font: 900 clamp(26px, 3vw, 40px)/1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
|
||||
.race__msg { margin: 10px 0 0; font-size: 16px; line-height: 1.4; }
|
||||
.race__find { margin: 0 0 24px; font: 800 clamp(26px, 2.6vw, 40px)/1.1 var(--display); text-transform: uppercase; letter-spacing: -.03em; }
|
||||
.race__find b { display: inline-block; padding: 0 .2em; background: var(--cyan); color: var(--ink); transform: rotate(-2deg); }
|
||||
.race__times { border-top: var(--b) solid var(--fg); margin-bottom: 24px; }
|
||||
.race__time { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid rgba(245, 244, 255, .25); }
|
||||
.race__time span { font: 700 13px/1.3 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.race__time i { display: block; margin-top: 4px; font-style: normal; font-weight: 500; color: var(--dim); }
|
||||
.race__time b { font: 900 clamp(34px, 4vw, 60px)/1 var(--display); letter-spacing: -.04em; font-variant-numeric: tabular-nums; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.race__verdict { min-height: 3em; margin: 0 0 26px; font: 800 clamp(18px, 1.7vw, 24px)/1.25 var(--display); text-transform: uppercase; letter-spacing: -.02em; }
|
||||
.rgrid { position: absolute; left: 8px; right: 8px; top: 52px; display: grid; grid-template-columns: repeat(5, 1fr); row-gap: 12px; }
|
||||
.rcell { all: unset; display: flex; flex-direction: column; align-items: center; gap: 5px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.rcell .ic { width: 50px; height: 50px; }
|
||||
.rcell span { max-width: 70px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 10.5px/1.15 var(--ui); color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .6); }
|
||||
.rcell.miss .ic, .row.miss { animation: miss .32s ease; }
|
||||
.rcell.hit .ic { box-shadow: 0 0 0 4px #fff, 0 0 26px 6px rgba(118, 250, 252, .8); }
|
||||
@keyframes miss { 25% { translate: -6px 0; } 75% { translate: 6px 0; } }
|
||||
|
||||
/* ---------- 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; overflow: hidden; } /* nothing the phones do may widen the page */
|
||||
.story__phones { height: 52vh; height: 52svh; }
|
||||
.step.step--long { align-items: flex-end; }
|
||||
.step.step--long .step__card { position: sticky; top: auto; bottom: 4svh; }
|
||||
.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; }
|
||||
|
||||
.story__rail { display: none; }
|
||||
/* On phones the page alphabet shows up while scrolling, low on the edge like the launcher's. */
|
||||
.pagewave { top: auto; bottom: 14svh; translate: none; right: 2px; opacity: 0; pointer-events: none; transition: opacity .3s; }
|
||||
.pagewave.is-awake, .pagewave.is-active { opacity: 1; pointer-events: auto; }
|
||||
.pagewave a { height: 25px; }
|
||||
.pagewave__bubble { right: 58px; }
|
||||
|
||||
.anatomy__labels, .anatomy__lines { display: none; } /* the chapter's text names the layers */
|
||||
|
||||
.race__grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.race__side { order: -1; }
|
||||
.race__stage { height: 70svh; }
|
||||
.race__find { margin-bottom: 14px; }
|
||||
.race__times { margin-bottom: 14px; }
|
||||
.race__time { padding: 10px 0; }
|
||||
.race__verdict { min-height: 0; margin-bottom: 18px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -251,23 +251,33 @@ $release = Read-Response ($client.PostAsync("$Api/releases", $content).Result) '
|
||||
|
||||
Step "6/7 Uploading the APK"
|
||||
|
||||
$assetName = "FlexeraLauncher-$Version.apk"
|
||||
$stream = [IO.File]::OpenRead($apk)
|
||||
# The APK goes up twice. Once under a name with its version, which is what a person looking at
|
||||
# the release sees and what the app's updater downloads. And once under a name that never
|
||||
# changes, so that <server>/<repo>/releases/download/latest/FlexeraLauncher.apk always leads to
|
||||
# the newest APK: the address in the QR code on the app's About screen (FlexeraUpdater.LATEST_APK_URL).
|
||||
# The versioned one goes first, because the updater takes the first .apk it finds.
|
||||
$assetNames = @("FlexeraLauncher-$Version.apk", 'FlexeraLauncher.apk')
|
||||
$uploaded = @()
|
||||
try {
|
||||
$file = New-Object System.Net.Http.StreamContent($stream)
|
||||
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
|
||||
$form = New-Object System.Net.Http.MultipartFormDataContent
|
||||
$form.Add($file, 'attachment', $assetName)
|
||||
$asset = Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) 'Uploading the APK'
|
||||
foreach ($assetName in $assetNames) {
|
||||
$stream = [IO.File]::OpenRead($apk)
|
||||
try {
|
||||
$file = New-Object System.Net.Http.StreamContent($stream)
|
||||
$file.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
|
||||
$form = New-Object System.Net.Http.MultipartFormDataContent
|
||||
$form.Add($file, 'attachment', $assetName)
|
||||
$uploaded += Read-Response ($client.PostAsync("$Api/releases/$($release.id)/assets?name=$assetName", $form).Result) "Uploading $assetName"
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# A release without its APK would make every installed copy report a failed check.
|
||||
$client.DeleteAsync("$Api/releases/$($release.id)").Result | Out-Null
|
||||
$client.DeleteAsync("$Api/tags/v$Version").Result | Out-Null
|
||||
throw
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
Write-Host "Uploaded: $($asset.browser_download_url)"
|
||||
$uploaded | ForEach-Object { Write-Host "Uploaded: $($_.browser_download_url)" }
|
||||
|
||||
# --- 7. Source tag -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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,62 @@
|
||||
<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>
|
||||
<!-- Home screen settings, Widgets: every widget's edges meet the edges of the icons -->
|
||||
<string name="flexera_align_widgets">Line up with icons</string>
|
||||
<string name="flexera_align_widgets_description">The edges of every widget meet the edges of the icons above, below and beside it</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 +161,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" />
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package app.flexera.home
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.LauncherApps
|
||||
import android.graphics.Canvas
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
@@ -15,6 +18,7 @@ import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.Launcher
|
||||
import com.android.launcher3.LauncherState
|
||||
import com.android.launcher3.model.data.AppInfo
|
||||
import com.android.launcher3.util.EdgeEffectCompat
|
||||
|
||||
/**
|
||||
@@ -24,7 +28,9 @@ import com.android.launcher3.util.EdgeEffectCompat
|
||||
*
|
||||
* The home screen already answers such a swipe by stretching and springing back. That stretch is
|
||||
* an edge effect, one per side, and quick launch is an edge effect that also keeps count of how
|
||||
* far the finger has pulled: far enough, or a flick, and lifting the finger does the action.
|
||||
* far the finger has pulled. When the choice is an app, there is no stretch: the pull brings in
|
||||
* a page of the app's own ([QuickLaunchPage]), and the app opens on it. For the other actions
|
||||
* the stretch stays, and far enough, or a flick, does the action when the finger lifts.
|
||||
* Launcher3 uses the same place for the Google feed on other phones (OverlayEdgeEffect).
|
||||
*/
|
||||
object QuickLaunch {
|
||||
@@ -56,11 +62,26 @@ object QuickLaunch {
|
||||
}
|
||||
}
|
||||
|
||||
/** The app [choice] opens, if it opens one. */
|
||||
private fun appOf(choice: GestureHandlerConfig): OpenAppTarget.App? =
|
||||
(choice as? GestureHandlerConfig.OpenApp)?.target as? OpenAppTarget.App
|
||||
|
||||
/** Opens [target]; false if it cannot be opened. */
|
||||
private fun startApp(launcher: Launcher, target: OpenAppTarget.App, options: ActivityOptions): Boolean = try {
|
||||
val apps = launcher.getSystemService(LauncherApps::class.java)
|
||||
apps?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
|
||||
apps != null
|
||||
} catch (e: RuntimeException) {
|
||||
// The chosen app is gone or cannot be started; nothing to open.
|
||||
Log.w(TAG, "The quick launch app could not be opened", e)
|
||||
false
|
||||
}
|
||||
|
||||
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
|
||||
val choice = choice(launcher, firstPage)
|
||||
if (choice is GestureHandlerConfig.NoOp) return
|
||||
val target = (choice as? GestureHandlerConfig.OpenApp)?.target
|
||||
if (target is OpenAppTarget.App) {
|
||||
val target = appOf(choice)
|
||||
if (target != null) {
|
||||
// The app opens out of the screen edge the finger pulled from, as near to a page
|
||||
// sliding in as Android lets an ordinary app come: it plays a sideways slide of
|
||||
// the launcher's own making only for the phone's built-in launcher.
|
||||
@@ -72,18 +93,44 @@ object QuickLaunch {
|
||||
1,
|
||||
root.height,
|
||||
)
|
||||
try {
|
||||
launcher.getSystemService(LauncherApps::class.java)
|
||||
?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
|
||||
} catch (e: RuntimeException) {
|
||||
// The chosen app is gone or cannot be started; nothing to open.
|
||||
Log.w(TAG, "The quick launch app could not be opened", e)
|
||||
}
|
||||
startApp(launcher, target, options)
|
||||
return
|
||||
}
|
||||
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
|
||||
}
|
||||
|
||||
/** The page is going all the way in: the app is started, or the home screen comes back. */
|
||||
private fun launchOnPage(launcher: Launcher, firstPage: Boolean, page: QuickLaunchPage) {
|
||||
val target = appOf(choice(launcher, firstPage))
|
||||
if (target == null || !startOnPage(launcher, target)) page.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens [target] without Android's opening animation: the app appears on the page the moment
|
||||
* it has something to show. That is its last screen if it was still in memory, else its
|
||||
* splash screen. (Android plays no other animation that a launcher of one's own may choose:
|
||||
* asked for a plain fade, it still zooms the app in from the middle.)
|
||||
*/
|
||||
private fun startOnPage(launcher: Launcher, target: OpenAppTarget.App): Boolean {
|
||||
val key = target.key
|
||||
if (key.user != Process.myUserHandle()) {
|
||||
// An app of another profile, such as a work profile, can only be started through
|
||||
// LauncherApps, which takes no such flag; it opens with Android's usual animation.
|
||||
return startApp(launcher, target, ActivityOptions.makeBasic())
|
||||
}
|
||||
// The same intent as the app's icon, so that it is the same task that comes up.
|
||||
val intent = AppInfo.makeLaunchIntent(key.componentName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
|
||||
return try {
|
||||
launcher.startActivity(intent)
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
// The chosen app is gone or cannot be started; nothing to open.
|
||||
Log.w(TAG, "The quick launch app could not be opened", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private class Effect(
|
||||
private val workspace: View,
|
||||
private val launcher: Launcher,
|
||||
@@ -99,33 +146,83 @@ object QuickLaunch {
|
||||
private var armed = false
|
||||
private var flicked = false
|
||||
|
||||
/** The page this pull brings in, when the choice is an app; then there is no stretch. */
|
||||
private var paging: QuickLaunchPage? = null
|
||||
|
||||
/** How fast the finger was moving towards the missing page when it lifted, in px/s. */
|
||||
private var outwardVelocity = 0f
|
||||
|
||||
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
|
||||
|
||||
// While paging, the pull is the page's, not a stretch's: the pages ask these whether
|
||||
// there is a pull to give back before they scroll the other way.
|
||||
override fun getDistance(): Float = if (paging != null) pulled else super.getDistance()
|
||||
|
||||
override fun isFinished(): Boolean = if (paging != null) pulled <= 0f else super.isFinished()
|
||||
|
||||
override fun draw(canvas: Canvas): Boolean = if (paging != null) false else super.draw(canvas)
|
||||
|
||||
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
|
||||
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
|
||||
if (pulled == 0f && deltaDistance > 0f) {
|
||||
active = choice(workspace.context, firstPage) !is GestureHandlerConfig.NoOp &&
|
||||
val choice = choice(workspace.context, firstPage)
|
||||
active = choice !is GestureHandlerConfig.NoOp &&
|
||||
launcher.isInState(LauncherState.NORMAL) &&
|
||||
!launcher.dragController.isDragging
|
||||
paging = if (active && appOf(choice) != null) {
|
||||
QuickLaunchPage.of(launcher)?.takeUnless { it.isOpening }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A page still on its way back is caught where it is.
|
||||
pulled = paging?.begin(fromLeft = atLeftEdge) ?: 0f
|
||||
}
|
||||
|
||||
val page = paging
|
||||
if (page != null) {
|
||||
val before = pulled
|
||||
pulled = (pulled + deltaDistance).coerceIn(0f, 1f)
|
||||
page.show(pulled)
|
||||
arm(pulled >= QuickLaunchPage.OPEN_DISTANCE)
|
||||
return pulled - before
|
||||
}
|
||||
|
||||
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
|
||||
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
|
||||
val nowArmed = active && pulled >= ARM_DISTANCE
|
||||
if (nowArmed != armed) {
|
||||
armed = nowArmed
|
||||
// A tick when the pull is far enough, and again when it is taken back.
|
||||
tick()
|
||||
}
|
||||
arm(active && pulled >= ARM_DISTANCE)
|
||||
return consumed
|
||||
}
|
||||
|
||||
override fun onFlingVelocity(velocity: Int) {
|
||||
// A pull at the left side is a finger moving right, which is a positive speed.
|
||||
val outward = if (atLeftEdge) velocity else -velocity
|
||||
flicked = active && pulled >= FLICK_DISTANCE && outward >= flickVelocity
|
||||
outwardVelocity = (if (atLeftEdge) velocity else -velocity).toFloat()
|
||||
flicked = active && pulled >= FLICK_DISTANCE && outwardVelocity >= flickVelocity
|
||||
}
|
||||
|
||||
override fun onAbsorb(velocity: Int) {
|
||||
if (paging == null) super.onAbsorb(velocity)
|
||||
}
|
||||
|
||||
override fun onRelease(ev: MotionEvent) {
|
||||
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
|
||||
val page = paging
|
||||
if (page != null) {
|
||||
// As with the pages themselves: a flick decides, else how far the page is in.
|
||||
val open = lifted && when {
|
||||
flicked -> true
|
||||
outwardVelocity <= -flickVelocity -> false
|
||||
else -> armed
|
||||
}
|
||||
val tickNow = open && !armed
|
||||
val speed = if (open) outwardVelocity else -outwardVelocity
|
||||
reset()
|
||||
if (tickNow) tick()
|
||||
// The app is started right after the pages have finished with this touch.
|
||||
page.settle(open, speed) {
|
||||
workspace.post { launchOnPage(launcher, firstPage, page) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val fire = lifted && active && (armed || flicked)
|
||||
val tickNow = fire && !armed
|
||||
reset()
|
||||
@@ -138,13 +235,23 @@ object QuickLaunch {
|
||||
}
|
||||
|
||||
override fun onRelease() {
|
||||
val page = paging
|
||||
reset()
|
||||
super.onRelease()
|
||||
if (page != null) page.close() else super.onRelease()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
val page = paging
|
||||
reset()
|
||||
super.finish()
|
||||
if (page != null) page.close() else super.finish()
|
||||
}
|
||||
|
||||
private fun arm(nowArmed: Boolean) {
|
||||
if (nowArmed != armed) {
|
||||
armed = nowArmed
|
||||
// A tick when the pull is far enough, and again when it is taken back.
|
||||
tick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
@@ -152,6 +259,8 @@ object QuickLaunch {
|
||||
active = false
|
||||
armed = false
|
||||
flicked = false
|
||||
paging = null
|
||||
outwardVelocity = 0f
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package app.flexera.home
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import com.android.launcher3.BaseActivity
|
||||
import com.android.launcher3.InsettableFrameLayout
|
||||
import com.android.launcher3.Launcher
|
||||
import com.android.launcher3.util.SystemUiController
|
||||
import java.util.WeakHashMap
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Quick launch's page: when a swipe past the first or the last home screen page opens an app,
|
||||
* the home screen does not stretch. It moves aside with the finger, as it does for a page that
|
||||
* is there, and an empty black page comes in behind it. The home screen blurs and darkens the
|
||||
* further it goes. Let go far enough, or flick, and the app is started there and then, while the
|
||||
* page settles over the whole screen; it appears on the page as soon as it has drawn, with none
|
||||
* of Android's opening animation. Let go early, and the home screen comes back.
|
||||
*
|
||||
* The home screen is moved by its drawing only ([View.setAnimationMatrix] on the drag layer),
|
||||
* not by its position: the touch that moves it is still being read by the pages inside it, and
|
||||
* moving their position would move the finger's position in them as well.
|
||||
*
|
||||
* [QuickLaunch]'s edge effect drives this with the finger, from 0 (home screen) to 1 (page).
|
||||
*/
|
||||
class QuickLaunchPage private constructor(private val launcher: Launcher) {
|
||||
|
||||
companion object {
|
||||
|
||||
/** How far the page has to come in for letting go to open it, as a share of the width. */
|
||||
const val OPEN_DISTANCE = 0.4f
|
||||
|
||||
/** How dark the home screen gets beside a page that is all the way in. */
|
||||
private const val HOME_DIM = 0.35f
|
||||
|
||||
/** The page settles at least this fast, in dp per second, and within these times. */
|
||||
private const val MIN_SETTLE_DP_PER_SECOND = 1500f
|
||||
private const val MIN_SETTLE_MS = 150L
|
||||
private const val MAX_SETTLE_MS = 300L
|
||||
|
||||
/**
|
||||
* Once the page is across, the launcher is paused as soon as the app starts. Still in
|
||||
* the foreground after this long, it never will be: nothing came, and the home screen
|
||||
* comes back.
|
||||
*/
|
||||
private const val LAUNCH_TIMEOUT_MS = 1500L
|
||||
|
||||
private val pages = WeakHashMap<Launcher, QuickLaunchPage>()
|
||||
|
||||
/** [launcher]'s page, made when first wanted; null while the launcher has no views. */
|
||||
fun of(launcher: Launcher): QuickLaunchPage? {
|
||||
pages[launcher]?.let { return it }
|
||||
if (launcher.rootView == null || launcher.dragLayer == null) return null
|
||||
return QuickLaunchPage(launcher).also { pages[launcher] = it }
|
||||
}
|
||||
|
||||
/** Whether [launcher]'s page is on the screen, all or part of it. */
|
||||
@JvmStatic
|
||||
fun isShowing(launcher: Launcher): Boolean = (pages[launcher]?.progress ?: 0f) > 0f
|
||||
|
||||
/** The blur for how far the page is in: quick at first, so the home screen soon recedes. */
|
||||
private fun blurFor(progress: Float): Float {
|
||||
val left = 1f - progress
|
||||
return 1f - left * left
|
||||
}
|
||||
}
|
||||
|
||||
/** How far the page is in, from 0 (not at all) to 1 (it fills the screen). */
|
||||
var progress = 0f
|
||||
private set
|
||||
|
||||
/** Whether the page comes in from the left edge of the screen, rather than the right. */
|
||||
private var fromLeft = false
|
||||
|
||||
/** The page is on its way all the way in, or there, with the app to follow. */
|
||||
private var opening = false
|
||||
|
||||
/** Changes whenever what an earlier opening waits for no longer counts. */
|
||||
private var generation = 0
|
||||
|
||||
private var animator: ValueAnimator? = null
|
||||
private val shift = Matrix()
|
||||
private val view = PageView(launcher)
|
||||
private val minSettleSpeed = MIN_SETTLE_DP_PER_SECOND * launcher.resources.displayMetrics.density
|
||||
|
||||
init {
|
||||
view.visibility = View.INVISIBLE
|
||||
// Over the whole window, the status and navigation bars included.
|
||||
val params = InsettableFrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
).apply { ignoreInsets = true }
|
||||
launcher.rootView.addView(view, params)
|
||||
}
|
||||
|
||||
/** Whether the page is busy opening an app; it then takes no new pull. */
|
||||
val isOpening: Boolean
|
||||
get() = opening
|
||||
|
||||
/**
|
||||
* A pull begins at the left or the right edge. A page still on its way back from the same
|
||||
* edge is caught where it is; that is returned, as how far the page is in.
|
||||
*/
|
||||
fun begin(fromLeft: Boolean): Float {
|
||||
cancelAnimation()
|
||||
if (this.fromLeft != fromLeft) {
|
||||
show(0f)
|
||||
this.fromLeft = fromLeft
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
/** Puts the page [progress] of the way in, from 0 to 1, with the home screen beside it. */
|
||||
fun show(progress: Float) {
|
||||
this.progress = progress.coerceIn(0f, 1f)
|
||||
apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The finger has let go: the page goes all the way in ([open]) or back out, at least as fast
|
||||
* as the finger was moving that way ([speed], pixels per second). Going in, [launch] runs at
|
||||
* once, so that the app is on its way while the page settles; it starts the app, or calls
|
||||
* [close] if there is none to start.
|
||||
*/
|
||||
fun settle(open: Boolean, speed: Float, launch: () -> Unit) {
|
||||
cancelAnimation()
|
||||
opening = open
|
||||
val target = if (open) 1f else 0f
|
||||
if (progress == target) {
|
||||
apply()
|
||||
} else {
|
||||
val remaining = abs(target - progress) * view.rootWidth()
|
||||
// A decelerating move starts at twice its average speed.
|
||||
val duration = (2000f * remaining / max(speed, minSettleSpeed)).toLong()
|
||||
val anim = ValueAnimator.ofFloat(progress, target)
|
||||
anim.duration = duration.coerceIn(MIN_SETTLE_MS, MAX_SETTLE_MS)
|
||||
anim.interpolator = DecelerateInterpolator()
|
||||
anim.addUpdateListener { show(it.animatedValue as Float) }
|
||||
anim.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (animator === anim) animator = null
|
||||
}
|
||||
})
|
||||
animator = anim
|
||||
anim.start()
|
||||
}
|
||||
if (open) opened(launch)
|
||||
}
|
||||
|
||||
/** The home screen comes back, as after letting go too early. */
|
||||
fun close() {
|
||||
generation++
|
||||
settle(open = false, speed = 0f) {}
|
||||
}
|
||||
|
||||
/** The home screen is back at once, with nothing to see on the way. */
|
||||
fun reset() {
|
||||
generation++
|
||||
cancelAnimation()
|
||||
opening = false
|
||||
show(0f)
|
||||
}
|
||||
|
||||
private fun opened(launch: () -> Unit) {
|
||||
val token = ++generation
|
||||
// Once the app is in front, the launcher stops; the next time it is seen, it is as it was.
|
||||
launcher.addEventCallback(BaseActivity.EVENT_STOPPED) {
|
||||
if (token == generation) reset()
|
||||
}
|
||||
// Starting an app pauses the launcher, resumes it for a moment and pauses it again, so
|
||||
// being resumed only means something once the start has had its time.
|
||||
view.postDelayed({
|
||||
if (token != generation) return@postDelayed
|
||||
if (launcher.hasBeenResumed()) {
|
||||
// Still in front: nothing came.
|
||||
close()
|
||||
} else {
|
||||
// Something is in front without hiding the launcher, or is still starting.
|
||||
// Should the launcher come back without having stopped, the home screen does too.
|
||||
launcher.addEventCallback(BaseActivity.EVENT_RESUMED) {
|
||||
if (token == generation) close()
|
||||
}
|
||||
}
|
||||
}, LAUNCH_TIMEOUT_MS)
|
||||
launch()
|
||||
}
|
||||
|
||||
private fun cancelAnimation() {
|
||||
animator?.let {
|
||||
animator = null
|
||||
it.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply() {
|
||||
val p = progress
|
||||
view.visibility = if (p > 0f) View.VISIBLE else View.INVISIBLE
|
||||
view.invalidate()
|
||||
|
||||
val dragLayer = launcher.dragLayer
|
||||
if (p > 0f) {
|
||||
shift.setTranslate((if (fromLeft) 1f else -1f) * p * view.rootWidth(), 0f)
|
||||
dragLayer.setAnimationMatrix(shift)
|
||||
} else {
|
||||
dragLayer.setAnimationMatrix(null)
|
||||
}
|
||||
|
||||
launcher.depthController?.quickLaunchBlur?.value = blurFor(p)
|
||||
|
||||
// Light status bar icons once the page is most of what is seen.
|
||||
launcher.systemUiController?.updateUiState(
|
||||
SystemUiController.UI_STATE_QUICK_LAUNCH_PAGE,
|
||||
if (p >= 0.5f) SystemUiController.FLAG_DARK_STATUS or SystemUiController.FLAG_DARK_NAV else 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** The width the page and the home screen move across; known before this view's first layout. */
|
||||
private fun View.rootWidth(): Int = if (width > 0) width else launcher.dragLayer.width
|
||||
|
||||
private inner class PageView(context: Context) : View(context) {
|
||||
|
||||
private val pagePaint = Paint().apply { color = Color.BLACK }
|
||||
private val dimPaint = Paint().apply { color = Color.BLACK }
|
||||
|
||||
init {
|
||||
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
||||
}
|
||||
|
||||
/**
|
||||
* While the page opens and the app is on its way, a touch would land on the home screen
|
||||
* behind it, where nothing is where it is drawn; the page keeps it. On its way back it
|
||||
* lets touches through, so that a new swipe can catch it.
|
||||
*/
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean = opening
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
val edge = progress * w
|
||||
dimPaint.alpha = (HOME_DIM * progress * 255).roundToInt()
|
||||
if (fromLeft) {
|
||||
canvas.drawRect(0f, 0f, edge, h, pagePaint)
|
||||
canvas.drawRect(edge, 0f, w, h, dimPaint)
|
||||
} else {
|
||||
canvas.drawRect(w - edge, 0f, w, h, pagePaint)
|
||||
canvas.drawRect(0f, 0f, w - edge, h, dimPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}" +
|
||||
"¤t=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,119 @@
|
||||
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.DeviceProfile
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 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, and about the room every other widget
|
||||
* keeps in its cells, which by default lines those up with the icons as well.
|
||||
*/
|
||||
object FlexeraWidgets {
|
||||
|
||||
private val noInset = Rect()
|
||||
|
||||
private fun isOwn(provider: ComponentName?): Boolean =
|
||||
provider == PictureWidgetProvider.componentName || provider == ClockWidgetProvider.componentName
|
||||
|
||||
/**
|
||||
* The room a widget's cells keep free around it, on each side; one cell is [cellWidth] by
|
||||
* [cellHeight]. The launcher's own widgets take none, because they place what they draw
|
||||
* against the icons of their cells by themselves ([IconFrame]); every other widget gets
|
||||
* [appWidgetPadding].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, profile: DeviceProfile, cellWidth: Int, cellHeight: Int): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo
|
||||
if (info != null && isOwn(info.providerName)) return noInset
|
||||
return appWidgetPadding(profile, cellWidth, cellHeight)
|
||||
}
|
||||
|
||||
/** [appWidgetPadding] for the cells of [profile]'s home screen. */
|
||||
@JvmStatic
|
||||
fun appWidgetPadding(profile: DeviceProfile): Rect {
|
||||
val cell = profile.cellSize
|
||||
return appWidgetPadding(profile, cell.x, cell.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* The room a widget of another app keeps free inside its cells, on each side, where one
|
||||
* cell is [cellWidth] by [cellHeight]. Lined up with the icons (the default), it is the
|
||||
* distance from the cells' edges to the edges of the icons that would sit in them: the
|
||||
* same box the launcher's own widgets draw in ([IconFrame]), so that every widget's edges
|
||||
* meet the edges of the icons above, below and beside it. Otherwise it is Launcher3's own,
|
||||
* which the Widget padding setting scales.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun appWidgetPadding(profile: DeviceProfile, cellWidth: Int, cellHeight: Int): Rect {
|
||||
if (!profile.alignWidgetsWithIcons || cellWidth <= 0 || cellHeight <= 0) {
|
||||
return profile.widgetPadding
|
||||
}
|
||||
// As in IconFrame: the icon is centred sideways in its cell and sits a set distance
|
||||
// below the cell's top; what is seen of it leaves a little air inside its square.
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val air = icon * (1f - ICON_VISIBLE_AREA_FACTOR) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - profile.cellHeightPx) / 2f)
|
||||
}
|
||||
val side = max(0, ((cellWidth - icon) / 2f + air).roundToInt())
|
||||
return Rect(
|
||||
side,
|
||||
max(0, (iconTop + air).roundToInt()),
|
||||
side,
|
||||
max(0, (cellHeight - iconTop - icon + air).roundToInt()),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,83 @@
|
||||
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 cell = profile.cellSize
|
||||
val inset = FlexeraWidgets.cellInset(host, profile, cell.x, cell.y)
|
||||
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
|
||||
val spanTop = -(lp.topMargin + inset.top).toFloat()
|
||||
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
|
||||
val spanHeight = (lp.height + lp.topMargin + lp.bottomMargin + inset.top + inset.bottom).toFloat()
|
||||
val gap = profile.cellLayoutBorderSpacePx
|
||||
val cellWidth = (spanWidth - (lp.cellHSpan - 1) * gap.x) / lp.cellHSpan
|
||||
val cellHeight = (spanHeight - (lp.cellVSpan - 1) * gap.y) / lp.cellVSpan
|
||||
|
||||
// Where an icon sits in its cell: centred sideways and a set distance below the
|
||||
// cell's top (ShortcutAndWidgetContainer.measureChild, BubbleTextView.getIconBounds).
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val iconLeft = (cellWidth - icon) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - min(container.measuredHeight, profile.cellHeightPx)) / 2f)
|
||||
}
|
||||
val visible = icon * ICON_VISIBLE_AREA_FACTOR
|
||||
val air = (icon - visible) / 2f
|
||||
|
||||
val l = spanLeft + iconLeft + air - view.left
|
||||
val r = spanLeft + spanWidth - iconLeft - air - view.left
|
||||
val t = spanTop + iconTop + air - view.top
|
||||
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - view.top
|
||||
if (r - l < 1f || b - t < 1f) return null
|
||||
return IconFrame(l, t, r, b, icon, visible)
|
||||
}
|
||||
|
||||
private fun activityContext(context: Context): ActivityContext? {
|
||||
var current: Context? = context
|
||||
while (current != null) {
|
||||
if (current is ActivityContext) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,7 @@ package app.flexera.widget
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import com.android.launcher3.BuildConfig
|
||||
import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
|
||||
/**
|
||||
* The launcher's own picture widget: one picture, or several that take turns.
|
||||
@@ -14,7 +11,7 @@ import com.android.launcher3.model.data.LauncherAppWidgetInfo
|
||||
* Android knows it as an ordinary app widget, so it is added, moved, resized and removed like
|
||||
* any other. What it shows is not sent through Android, though: the launcher draws it itself
|
||||
* with a [PictureWidgetView], which is what lets it line up with the icons around it. Its
|
||||
* pictures live in [PictureWidgetStore].
|
||||
* pictures live in [PictureWidgetStore]. See [FlexeraWidgets].
|
||||
*/
|
||||
class PictureWidgetProvider : AppWidgetProvider() {
|
||||
|
||||
@@ -32,18 +29,5 @@ class PictureWidgetProvider : AppWidgetProvider() {
|
||||
companion object {
|
||||
@JvmField
|
||||
val componentName = ComponentName(BuildConfig.APPLICATION_ID, PictureWidgetProvider::class.java.name)
|
||||
|
||||
private val noInset = Rect()
|
||||
|
||||
/**
|
||||
* The room a home screen cell keeps free around a widget. Launcher3 gives every widget
|
||||
* the same ([standard]); the picture widget takes none, because it places its picture
|
||||
* against the icons of its cells by itself.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cellInset(child: View, standard: Rect): Rect {
|
||||
val info = child.tag as? LauncherAppWidgetInfo ?: return standard
|
||||
return if (info.providerName == componentName) noInset else standard
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
@@ -23,7 +22,6 @@ import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import app.lawnchair.icons.shape.CornerShapeCompat
|
||||
@@ -31,13 +29,7 @@ import app.lawnchair.icons.shape.IconShape
|
||||
import app.lawnchair.preferences2.PreferenceManager2
|
||||
import app.lawnchair.preferences2.firstCached
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.ShortcutAndWidgetContainer
|
||||
import com.android.launcher3.celllayout.CellLayoutLayoutParams
|
||||
import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR
|
||||
import com.android.launcher3.util.Executors
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
import com.android.launcher3.widget.LauncherAppWidgetHostView
|
||||
import com.android.launcher3.widget.PendingAppWidgetHostView
|
||||
import java.io.File
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -365,50 +357,14 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
var corner = min(width, height) * LOOSE_CORNER_SHARE
|
||||
var iconSize = min(width, height).toFloat()
|
||||
|
||||
val host = parent as? View
|
||||
val lp = host?.layoutParams as? CellLayoutLayoutParams
|
||||
val container = host?.parent as? ShortcutAndWidgetContainer
|
||||
val profile = activityContext()?.deviceProfile
|
||||
if (host != null && lp != null && container != null && profile != null &&
|
||||
lp.cellHSpan > 0 && lp.cellVSpan > 0
|
||||
) {
|
||||
// The cells the widget covers, measured from the corner of the host view. Undoes
|
||||
// what CellLayoutLayoutParams.setup took off them.
|
||||
val inset = PictureWidgetProvider.cellInset(host, profile.widgetPadding)
|
||||
val spanLeft = -(lp.leftMargin + inset.left).toFloat()
|
||||
val spanTop = -(lp.topMargin + inset.top).toFloat()
|
||||
val spanWidth = (lp.width + lp.leftMargin + lp.rightMargin + inset.left + inset.right).toFloat()
|
||||
val spanHeight = (lp.height + lp.topMargin + lp.bottomMargin + inset.top + inset.bottom).toFloat()
|
||||
val gap = profile.cellLayoutBorderSpacePx
|
||||
val cellWidth = (spanWidth - (lp.cellHSpan - 1) * gap.x) / lp.cellHSpan
|
||||
val cellHeight = (spanHeight - (lp.cellVSpan - 1) * gap.y) / lp.cellVSpan
|
||||
|
||||
// Where an icon sits in its cell: centred sideways and a set distance below the
|
||||
// cell's top (ShortcutAndWidgetContainer.measureChild, BubbleTextView.getIconBounds).
|
||||
val icon = profile.iconSizePx.toFloat()
|
||||
val iconLeft = (cellWidth - icon) / 2f
|
||||
val iconTop = if (profile.cellYPaddingPx >= 0) {
|
||||
profile.cellYPaddingPx.toFloat()
|
||||
} else {
|
||||
max(0f, (cellHeight - min(container.measuredHeight, profile.cellHeightPx)) / 2f)
|
||||
}
|
||||
// What is seen of an icon is a little smaller than the square it is drawn in; the
|
||||
// rest is room for its shadow.
|
||||
val visible = icon * ICON_VISIBLE_AREA_FACTOR
|
||||
val air = (icon - visible) / 2f
|
||||
|
||||
val l = spanLeft + iconLeft + air - getLeft()
|
||||
val r = spanLeft + spanWidth - iconLeft - air - getLeft()
|
||||
val t = spanTop + iconTop + air - getTop()
|
||||
val b = spanTop + spanHeight - cellHeight + iconTop + icon - air - getTop()
|
||||
if (r - l >= 1f && b - t >= 1f) {
|
||||
left = l
|
||||
top = t
|
||||
right = r
|
||||
bottom = b
|
||||
corner = visible / 2f
|
||||
iconSize = icon
|
||||
}
|
||||
val aligned = IconFrame.of(this)
|
||||
if (aligned != null) {
|
||||
left = aligned.left
|
||||
top = aligned.top
|
||||
right = aligned.right
|
||||
bottom = aligned.bottom
|
||||
corner = aligned.visibleIconSize / 2f
|
||||
iconSize = aligned.iconSize
|
||||
}
|
||||
corner = min(corner, min(right - left, bottom - top) / 2f)
|
||||
|
||||
@@ -537,15 +493,6 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
|
||||
private fun currentIconShape(): IconShape = PreferenceManager2.getInstance(context).iconShape.firstCached()
|
||||
|
||||
private fun activityContext(): ActivityContext? {
|
||||
var current: Context? = context
|
||||
while (current != null) {
|
||||
if (current is ActivityContext) return current
|
||||
current = (current as? ContextWrapper)?.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PictureWidgetView"
|
||||
|
||||
@@ -580,38 +527,5 @@ class PictureWidgetView @JvmOverloads constructor(
|
||||
const val HINT_MAX_LINES = 2
|
||||
const val HINT_MAX_HEIGHT_SHARE = 0.4f
|
||||
const val HINT_CENTRE_SHARE = 0.36f
|
||||
|
||||
/**
|
||||
* Puts a [PictureWidgetView] into a widget host view when the widget is the picture
|
||||
* widget, and returns it; returns null for every other widget. Launcher3's host view
|
||||
* calls this in place of asking Android for the widget's views.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun attachIfOwn(
|
||||
host: LauncherAppWidgetHostView,
|
||||
appWidgetId: Int,
|
||||
info: AppWidgetProviderInfo?,
|
||||
): View? {
|
||||
if (info == null || info.provider != PictureWidgetProvider.componentName) return null
|
||||
// A widget that still waits to be set up draws its own "tap to set up".
|
||||
if (host is PendingAppWidgetHostView) return null
|
||||
var view: PictureWidgetView? = null
|
||||
for (i in host.childCount - 1 downTo 0) {
|
||||
val child = host.getChildAt(i)
|
||||
if (child is PictureWidgetView && view == null) view = child else host.removeViewAt(i)
|
||||
}
|
||||
if (view == null) {
|
||||
view = PictureWidgetView(host.context)
|
||||
host.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
view.bind(appWidgetId)
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
# build.gradle reads this and derives the Android version code from it
|
||||
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
|
||||
# flexera/release.ps1 raises it for every release.
|
||||
versionName=0.9.0
|
||||
versionName=0.15.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,22 @@ class PreferenceManager2 @Inject constructor(
|
||||
defaultValue = GestureHandlerConfig.NoOp,
|
||||
)
|
||||
|
||||
// Flexera Launcher: whether the edges of every widget meet the edges of the icons in the
|
||||
// rows and columns it covers, as the launcher's own widgets do
|
||||
// (app.flexera.widget.FlexeraWidgets.appWidgetPadding). Read with the grid.
|
||||
val alignWidgetsWithIcons = preference(
|
||||
key = booleanPreferencesKey(name = "flexera_align_widgets_with_icons"),
|
||||
defaultValue = true,
|
||||
onSet = { reloadHelper.reloadGrid() },
|
||||
)
|
||||
|
||||
// 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 +928,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 +946,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 +961,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(
|
||||
|
||||
@@ -285,13 +285,23 @@ fun HomeScreenPreferences(
|
||||
label = stringResource(id = R.string.force_widget_resize_label),
|
||||
description = stringResource(id = R.string.force_widget_resize_description),
|
||||
)
|
||||
SliderPreference(
|
||||
label = stringResource(id = R.string.widget_padding_label),
|
||||
adapter = prefs2.widgetPaddingFactor.getAdapter(),
|
||||
step = 0.05f,
|
||||
valueRange = 0F..2F,
|
||||
showAsPercentage = true,
|
||||
// Flexera Launcher: widgets lined up with the icons; Lawnchair's padding is for
|
||||
// when they are not (app.flexera.widget.FlexeraWidgets.appWidgetPadding).
|
||||
val alignWidgetsAdapter = prefs2.alignWidgetsWithIcons.getAdapter()
|
||||
SwitchPreference(
|
||||
adapter = alignWidgetsAdapter,
|
||||
label = stringResource(id = R.string.flexera_align_widgets),
|
||||
description = stringResource(id = R.string.flexera_align_widgets_description),
|
||||
)
|
||||
ExpandAndShrink(visible = !alignWidgetsAdapter.state.value) {
|
||||
SliderPreference(
|
||||
label = stringResource(id = R.string.widget_padding_label),
|
||||
adapter = prefs2.widgetPaddingFactor.getAdapter(),
|
||||
step = 0.05f,
|
||||
valueRange = 0F..2F,
|
||||
showAsPercentage = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 @@ 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);
|
||||
}
|
||||
// Flexera Launcher: likewise quick launch's blur with its page gone.
|
||||
if (quickLaunchBlur.getValue() > 0f
|
||||
&& !app.flexera.home.QuickLaunchPage.isShowing(mLauncher)) {
|
||||
quickLaunchBlur.setValue(0f);
|
||||
}
|
||||
if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
|
||||
&& !mLauncher.getStateManager().isInTransition()) {
|
||||
clearWorkspaceRenderEffects();
|
||||
@@ -227,10 +239,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,17 @@ 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;
|
||||
/**
|
||||
* Flexera Launcher: property to set the blur of the home screen while quick launch's page
|
||||
* slides in over it (app.flexera.home.QuickLaunchPage), from 0 to 1. It shares the folder
|
||||
* blur's machinery; the stronger of the two wins.
|
||||
*/
|
||||
public final MultiProperty quickLaunchBlur;
|
||||
|
||||
/**
|
||||
* Blur radius when completely zoomed out, in pixels.
|
||||
@@ -106,6 +117,11 @@ 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, or behind quick
|
||||
// launch's page, see quickLaunchBlur.
|
||||
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 +180,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
} else {
|
||||
folderZoom = null;
|
||||
}
|
||||
// Flexera Launcher
|
||||
MultiPropertyFactory<AnimatedFloat> backdropBlur =
|
||||
new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 2, Float::max);
|
||||
folderBlur = backdropBlur.get(0);
|
||||
quickLaunchBlur = backdropBlur.get(1);
|
||||
|
||||
// mEarlyWakeupInfo.token = new Binder();
|
||||
// mEarlyWakeupInfo.trace = BaseDepthControllerImpl.class.getName();
|
||||
@@ -184,6 +205,58 @@ public class BaseDepthControllerImpl implements BaseDepthController {
|
||||
return folderZoom;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultiProperty getFolderBlur() {
|
||||
return folderBlur;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultiProperty getQuickLaunchBlur() {
|
||||
return quickLaunchBlur;
|
||||
}
|
||||
|
||||
/** 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 +309,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 +336,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 +457,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 +498,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();
|
||||
|
||||
@@ -349,6 +349,8 @@ public class DeviceProfile {
|
||||
private float workspacePaddingHorizontalFactor;
|
||||
private float workspacePaddingVerticalFactor;
|
||||
private float widgetPaddingFactor;
|
||||
// Flexera Launcher: widgets lined up with the icons (app.flexera.widget.FlexeraWidgets).
|
||||
public boolean alignWidgetsWithIcons;
|
||||
private float drawerPaddingTopFactor;
|
||||
private PreferenceManager2 preferenceManager2 = null;
|
||||
|
||||
@@ -373,6 +375,8 @@ public class DeviceProfile {
|
||||
.firstCached(preferenceManager2.getWorkspacePaddingVerticalFactor()), 0f, 2f);
|
||||
widgetPaddingFactor = Utilities.boundToRange(PreferenceCacheExtensionsKt
|
||||
.firstCached(preferenceManager2.getWidgetPaddingFactor()), 0f, 2f);
|
||||
alignWidgetsWithIcons = PreferenceCacheExtensionsKt
|
||||
.firstCached(preferenceManager2.getAlignWidgetsWithIcons());
|
||||
drawerPaddingTopFactor = Utilities.boundToRange(PreferenceCacheExtensionsKt
|
||||
.firstCached(preferenceManager2.getDrawerPaddingTopFactor()), 1f, 2f);
|
||||
this.inv = inv;
|
||||
|
||||
@@ -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,9 +151,10 @@ 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,
|
||||
profile.widgetPadding));
|
||||
// Flexera Launcher: the launcher's own widgets take their cells whole;
|
||||
// the others are lined up with the icons.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child, profile,
|
||||
mCellWidth, mCellHeight));
|
||||
} else {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
@@ -179,8 +180,10 @@ 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;
|
||||
// the others are lined up with the icons.
|
||||
app.flexera.widget.FlexeraWidgets.cellInset(child, dp,
|
||||
mCellWidth, mCellHeight));
|
||||
} else if (isChildQsb(child)) {
|
||||
lp.setup(mCellWidth, mCellHeight, invertLayoutHorizontally(), mCountX, mCountY,
|
||||
mBorderSpace);
|
||||
|
||||
@@ -3300,7 +3300,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
|
||||
if (info.itemType == LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET) {
|
||||
DeviceProfile profile = mLauncher.getDeviceProfile();
|
||||
if (finalView instanceof NavigableAppWidgetHostView) {
|
||||
Rect widgetPadding = profile.widgetPadding;
|
||||
// Flexera Launcher: the room this widget keeps in its cells.
|
||||
Point cell = profile.getCellSize();
|
||||
Rect widgetPadding = app.flexera.widget.FlexeraWidgets.cellInset(
|
||||
finalView, profile, cell.x, cell.y);
|
||||
r.left -= widgetPadding.left;
|
||||
r.right += widgetPadding.right;
|
||||
r.top -= widgetPadding.top;
|
||||
|
||||
@@ -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,16 @@ 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?
|
||||
|
||||
/**
|
||||
* Flexera Launcher: property for the blur of the home screen while quick launch's page
|
||||
* slides in over it, from 0 (none) to 1 (as strong as it gets).
|
||||
*/
|
||||
val quickLaunchBlur: MultiPropertyFactory<*>.MultiProperty?
|
||||
}
|
||||
@@ -35,6 +35,8 @@ public class SystemUiController {
|
||||
public static final int UI_STATE_WIDGET_BOTTOM_SHEET = 2;
|
||||
public static final int UI_STATE_FULLSCREEN_TASK = 3;
|
||||
public static final int UI_STATE_ALL_APPS = 4;
|
||||
// Flexera Launcher: quick launch's black page (app.flexera.home.QuickLaunchPage).
|
||||
public static final int UI_STATE_QUICK_LAUNCH_PAGE = 5;
|
||||
|
||||
public static final int FLAG_LIGHT_NAV = 1 << 0;
|
||||
public static final int FLAG_DARK_NAV = 1 << 1;
|
||||
@@ -54,7 +56,7 @@ public class SystemUiController {
|
||||
public @interface SystemUiControllerFlags {}
|
||||
|
||||
private final View mView;
|
||||
private final int[] mStates = new int[5];
|
||||
private final int[] mStates = new int[6];
|
||||
|
||||
public SystemUiController(View view) {
|
||||
mView = view;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ public final class WidgetSizes {
|
||||
final int vBorderSpacing = (spanY - 1) * profile.cellLayoutBorderSpacePx.y;
|
||||
|
||||
Point cellSize = profile.getCellSize();
|
||||
Rect padding = profile.widgetPadding;
|
||||
// Flexera Launcher: the size the widget really gets, lined up with the icons.
|
||||
Rect padding = app.flexera.widget.FlexeraWidgets.appWidgetPadding(profile);
|
||||
|
||||
return new Size(
|
||||
(spanX * cellSize.x) + hBorderSpacing - padding.left - padding.right,
|
||||
|
||||