Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 0ef37b16c8 Merge main into feat/stores-motion 2026-10-09 14:49:00 -04:00
xXJsonDeruloXx bed4c1040b perf(stores): keep chip motion responsive while libraries change 2026-10-09 14:29:34 -04:00
xXJsonDeruloXx 3f45835ed6 feat(stores): the store a pad's focus is on shows without A, and a press never lands on where it sent focus 2026-10-09 13:37:03 -04:00
xXJsonDeruloXx 29abf954ce fix(ui): the focus ring follows a control until it stops and meets one still rising into place 2026-10-09 13:37:03 -04:00
xXJsonDeruloXx fa3037de68 style(stores): the sign-in page is the store's name and Sign in 2026-10-09 13:25:51 -04:00
xXJsonDeruloXx 9419f1efda style(stores): drop the sign-in page's store eyebrow, which the title already names 2026-10-09 13:23:51 -04:00
xXJsonDeruloXx d73c4e50c2 feat(stores): sign-in pages in the Steam tab's style and an are-you-sure for sign out 2026-10-09 13:22:25 -04:00
xXJsonDeruloXx bcaeb27780 feat(stores): modes step out of the store chip, each store in its own colour, search in the header 2026-10-09 13:16:11 -04:00
xXJsonDeruloXx fc515aa0e4 fix(ui): a step-out's backdrop never takes the pad's focus 2026-10-09 13:16:11 -04:00
xXJsonDeruloXx f164af81e7 perf(stores): GOG remembers its non-games and the library cache loads off the main thread 2026-10-09 13:16:11 -04:00
xXJsonDeruloXx 90af0b9c0c fix(stores): a game page no longer leaves the pad's focus on a dead card and crashes the next press 2026-10-09 13:16:10 -04:00
Kurt Himebauch 67cf1cdd9c fix(stores): lay out the pane's rows in a column 2026-10-09 12:32:10 -04:00
Kurt Himebauch 571751f15c feat(stores): bring the launcher's motion to the stores pages 2026-10-09 12:17:35 -04:00
Kurt Himebauch 768ed0c505 feat(ui): let PageFlood carry a tile's art 2026-10-09 12:17:35 -04:00
Kurt Himebauch 9f94140bbf refactor(ui): share End session's step-out as StepOut 2026-10-09 12:17:35 -04:00
22 changed files with 2005 additions and 297 deletions

No files matched your search

@@ -916,6 +916,7 @@ class MainActivity : ComponentActivity() {
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
@@ -924,7 +925,15 @@ class MainActivity : ComponentActivity() {
}
return true
}
return super.dispatchKeyEvent(event)
// Compose throws when its focus tree has lost the focused node; one key is dropped
// instead of the app.
return try {
super.dispatchKeyEvent(event)
} catch (e: IllegalStateException) {
if (e.message?.contains("active focus target") != true) throw e
android.util.Log.w("MainActivity", "key ${event.keyCode} dropped: ${e.message}")
true
}
}
override fun onResume() {
@@ -47,6 +47,11 @@ class StoreLoginActivity : ComponentActivity() {
private const val TAG = "StoreLogin"
const val EXTRA_STORE = "store"
private const val KEY_STATE = "oauth_state"
/**
* The colour the Stores page flooded to before opening this page, read once: the page opens
* on it, with no window animation, and its chrome rises in, as a session opens on Play's blue.
*/
@Volatile internal var floodColor: Int? = null
private const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
fun generateState(): String {
@@ -80,6 +85,8 @@ class StoreLoginActivity : ComponentActivity() {
private lateinit var titleText: TextView
private var oauthState: String? = null
private val captured = AtomicBoolean(false)
/** Opened behind a flood: it opens and closes with no window animation. */
private var flooded = false
private fun dp(value: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
@@ -88,8 +95,20 @@ class StoreLoginActivity : ComponentActivity() {
store = Store.byId(intent.getStringExtra(EXTRA_STORE))
oauthState = savedInstanceState?.getString(KEY_STATE) ?: generateState()
flow = store?.let { LoginFlows.forStore(it, oauthState!!) }
floodColor?.let { c ->
floodColor = null
flooded = true
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(c))
@Suppress("DEPRECATION") overridePendingTransition(0, 0)
}
if (flow == null) { finish(); return }
setContentView(buildChrome())
val chrome = buildChrome()
setContentView(chrome)
if (flooded) {
chrome.alpha = 0f
chrome.translationY = dp(14).toFloat()
chrome.animate().alpha(1f).translationY(0f).setStartDelay(60).setDuration(320).start()
}
val webView = newWebView(flow!!.userAgent, isPopup = false)
webViewRef = webView
contentHost.addView(webView, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
@@ -105,6 +124,12 @@ class StoreLoginActivity : ComponentActivity() {
webView.loadUrl(flow!!.startUrl)
}
override fun finish() {
super.finish()
// Back to the Stores page, which is still covered in the flood and drains it from there.
@Suppress("DEPRECATION") if (flooded) overridePendingTransition(0, 0)
}
private fun buildChrome(): View {
val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(0xFF0A0B0D.toInt()) }
val bar = LinearLayout(this).apply {
@@ -158,6 +158,11 @@ object StoresState {
if (force || !shelves.containsKey(store)) backend.loadShelves(app, force)
}
/** A store not signed into: its public storefront only, for its sign-in page to show. */
fun preview(context: Context, store: Store) {
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
}
fun signIn(context: Context, store: Store) {
expired.remove(store)
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
@@ -45,11 +45,15 @@ object AmazonBackend : StoreBackend {
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
val cached = AmazonLibrary.cached(app)
if (cached.isNotEmpty()) StoresState.post { publish(app, cached) }
StoresState.post { StoresState.status[store] = "Fetching your library…" }
Thread({
val result = AmazonLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } }
// The cache off the main thread; with it on screen a background sync keeps quiet.
val cached = AmazonLibrary.cached(app)
val quiet = cached.isNotEmpty() && !force
StoresState.post {
if (cached.isNotEmpty()) publish(app, cached)
if (!quiet) StoresState.status[store] = "Fetching your library…"
}
val result = AmazonLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
@@ -41,11 +41,15 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
val cached = EpicLibrary.cached(app)
if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { EpicLibrary.toCatalogItem(app, it) }) }
StoresState.post { StoresState.status[store] = "Fetching your library…" }
Thread({
val result = EpicLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } }
// The cache off the main thread; with it on screen a background sync keeps quiet.
val cached = EpicLibrary.cached(app).map { EpicLibrary.toCatalogItem(app, it) }
val quiet = cached.isNotEmpty() && !force
StoresState.post {
if (cached.isNotEmpty()) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached)
if (!quiet) StoresState.status[store] = "Fetching your library…"
}
val result = EpicLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
@@ -54,7 +58,7 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
}
is EpicLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store)
EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { EpicLibrary.toCatalogItem(app, it) })
EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached)
EpicLibrary.SyncResult.Busy -> {}
}
}
@@ -31,12 +31,16 @@ object GogBackend : StoreBackend {
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
// The cache first, so the tab has something while the sync runs.
val cached = GogLibrary.cached(app)
if (cached.isNotEmpty()) StoresState.post { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { GogLibrary.toCatalogItem(app, it) }) }
StoresState.post { StoresState.status[store] = "Fetching your library…" }
Thread({
val result = GogLibrary.sync(app, force) { line -> StoresState.post { StoresState.status[store] = line } }
// The cache first, so the tab has something while the sync runs; read here, not on the
// main thread. With a cache on screen a background sync keeps quiet: only Refresh shows its bar.
val cached = GogLibrary.cached(app).map { GogLibrary.toCatalogItem(app, it) }
val quiet = cached.isNotEmpty() && !force
StoresState.post {
if (cached.isNotEmpty()) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached)
if (!quiet) StoresState.status[store] = "Fetching your library…"
}
val result = GogLibrary.sync(app, force) { line -> if (!quiet) StoresState.post { StoresState.status[store] = line } }
StoresState.post {
StoresState.status.remove(store)
when (result) {
@@ -23,6 +23,9 @@ object GogLibrary {
// v2: the card art moved from the store page's backdrop to the Galaxy library art; an older cache is re-fetched.
private const val CACHE_KEY = "library_cache_v2"
private const val LAST_SYNC_KEY = "library_synced_at"
// Owned products that are not Windows games (DLC, films, soundtracks, other platforms): never in
// the cache, so without this every sync would fetch each of them again.
private const val SKIPPED_KEY = "library_skipped"
private const val THROTTLE_MS = 6L * 60L * 60L * 1000L
private val syncing = AtomicBoolean(false)
@@ -75,18 +78,22 @@ object GogLibrary {
val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS
val heavy = force || cachedList.isEmpty() || stale
val cachedIds = cachedList.mapTo(HashSet()) { it.gameId }
val idsToFetch = if (heavy) ids else ids.filter { it !in cachedIds }
val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
if (!heavy) skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty())
val idsToFetch = if (heavy) ids else ids.filter { it !in cachedIds && it !in skipped }
val fetchSet = idsToFetch.toHashSet()
val merged = LinkedHashMap<String, GogGame>()
for (g in cachedList) if (g.gameId in ownedSet && g.gameId !in fetchSet) merged[g.gameId] = g
if (idsToFetch.isEmpty()) {
val list = merged.values.toList()
skipped.retainAll(ownedSet)
p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply()
saveCache(p, list)
return SyncResult.Ok(list.sortedBy { it.title.lowercase() }, 0)
}
onStatus("Syncing ${idsToFetch.size} game${if (idsToFetch.size == 1) "" else "s"}…")
val pool = Executors.newFixedThreadPool(5)
val futures = idsToFetch.map { id -> pool.submit(Callable<GogGame?> { fetchGame(p, id, token) }) }
val futures = idsToFetch.map { id -> pool.submit(Callable<GogGame?> { fetchGame(p, id, token, skipped) }) }
pool.shutdown()
var fetched = 0
for ((idx, f) in futures.withIndex()) {
@@ -96,6 +103,8 @@ object GogLibrary {
}
val finalList = merged.values.toList()
saveCache(p, finalList)
skipped.retainAll(ownedSet)
p.edit().putStringSet(SKIPPED_KEY, HashSet(skipped)).apply()
if (heavy) p.edit().putLong(LAST_SYNC_KEY, System.currentTimeMillis()).apply()
Log.i(TAG, "sync: owned=${ids.size} fetched=$fetched cached=${finalList.size} heavy=$heavy")
return SyncResult.Ok(finalList.sortedBy { it.title.lowercase() }, fetched)
@@ -108,16 +117,18 @@ object GogLibrary {
}
/** One product's details, or null for anything that is not a Windows game (DLC, movies, other platforms). */
private fun fetchGame(prefs: SharedPreferences, id: String, token: String): GogGame? {
private fun fetchGame(prefs: SharedPreferences, id: String, token: String, skipped: MutableSet<String>): GogGame? {
// Not a game this app installs: remembered, so the next sync does not ask again.
fun skip(): GogGame? { skipped.add(id); return null }
try {
val productJson = StoreNet.get("https://api.gog.com/products/$id?expand=downloads,description", bearer = token) ?: return null
val prod = JSONObject(productJson)
if (prod.optBoolean("is_secret", false)) return null
if (prod.optBoolean("is_secret", false)) return skip()
val gameType = prod.optString("game_type", "")
if (gameType.isNotEmpty() && gameType != "game" && gameType != "pack") return null
if (gameType.isNotEmpty() && gameType != "game" && gameType != "pack") return skip()
var title = prod.optJSONObject("title")?.optString("*")
if (title.isNullOrEmpty()) title = prod.optString("title")
if (title.isNullOrEmpty()) return null
if (title.isNullOrEmpty()) return skip()
// Card art from the v2 game record, the way the Galaxy client draws its library: the dark
// Galaxy background for the wide card and the box art for the tall one. The product's
// `images.background` is the store page's fade-out backdrop (it ends in white on a
@@ -164,7 +175,7 @@ object GogLibrary {
prod.optJSONObject("downloads")?.optJSONArray("installers")?.let { arr ->
for (di in 0 until arr.length()) if (arr.optJSONObject(di)?.optString("os", "") == "windows") { hasWindowsInstaller = true; break }
}
if (!hasWindowsBuild && !hasWindowsInstaller) return null
if (!hasWindowsBuild && !hasWindowsInstaller) return skip()
var verticalCover: String? = boxArt.ifEmpty { prefs.getString("vcover_$id", null) }
if (verticalCover.isNullOrEmpty()) {
@@ -147,6 +147,10 @@ private fun follow(): AnimationSpec<Float> = Motion.sp(1f, 2400f)
/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */
private const val SETTLE_MS = 450L
/** How long the control must stand still before the ring stops checking where it is. */
private const val STILL_MS = 120L
/** The longest the ring keeps checking after a move, for a control that never stands still. */
private const val TRACK_MAX_MS = 2_000L
/** Moves closer together than this are a held direction: the ring runs along as one, no stretch. */
private const val REPEAT_MS = 180L
@@ -213,17 +217,23 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
launch { corner.animateTo(r, Motion.tw(90)) }
dot(here).forEachIndexed { i, v -> launch { edges[i].animateTo(v, Motion.tw(90)) } }
}
// Across as a dot, every edge together, on a timed ease.
// Across as a dot, every edge together, on a timed ease, steering each frame for
// where the control is now: one still rising into place is met where it ends up.
val distance = hypot(b.center.x - here.x, b.center.y - here.y) / density.density
val travel = (180 + distance * 0.12f).toInt().coerceIn(220, 380)
dot(b.center).forEachIndexed { i, v ->
jobs[i] = launch { edges[i].animateTo(v, Motion.tw(travel, easing = FastOutSlowInEasing)) }
}
val p = Animatable(0f)
val flight = launch { p.animateTo(1f, Motion.tw(travel, easing = FastOutSlowInEasing)) }
// Takes the control's shape as it arrives, not after: no resting as a dot.
delay(Motion.ms((travel * 0.78f).toInt()).toLong())
while (flight.isActive && p.value < 0.78f) {
val to = (glide.boundsOf(s) ?: b).center
dot(Offset(here.x + (to.x - here.x) * p.value, here.y + (to.y - here.y) * p.value)).forEachIndexed { i, v -> edges[i].snapTo(v) }
withFrameNanos { }
}
flight.cancel()
val at = glide.boundsOf(s) ?: b
launch { solid.animateTo(0f, Motion.tw(150)) }
launch { corner.animateTo(endCorner, Motion.sp(0.8f, 600f)) }
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v ->
listOf(at.left, at.top, at.right, at.bottom).forEachIndexed { i, v ->
jobs[i] = launch { edges[i].animateTo(v, Motion.sp(0.7f, 750f)) }
}
}
@@ -267,8 +277,11 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
var first = src !== shown
val start = System.nanoTime()
var last: Rect? = null
var seen: Rect? = null
var stillSince = start
while (true) {
val b = glide.boundsOf(src)
if (b != seen) { seen = b; stillSince = System.nanoTime() }
if (b == null) {
if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
} else if (first) {
@@ -318,9 +331,12 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
last = b
}
// Layer animations (a tile growing, a row sliding) move it without a relayout, so
// look again each frame for a moment after anything changes.
if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break
// Layer animations (a tile growing, a row sliding, a page rising in) move it without
// a relayout, so look again each frame until it has stood still a moment: a fixed
// window let the ring park mid-rise and jump over when something next laid out.
val now = System.nanoTime()
val settled = (now - start) / 1_000_000 > SETTLE_MS && (now - stillSince) / 1_000_000 > STILL_MS
if (settled || (now - start) / 1_000_000 > TRACK_MAX_MS) break
withFrameNanos { }
}
}
@@ -193,8 +193,9 @@ private fun RailItem(
if (count > 0) Text(
if (count > 9) "9+" else count.toString(),
fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
// Pops with the Downloads chip's count as an install's dot lands there.
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 4.dp else 6.dp, end = if (iconOnly) 5.dp else 12.dp)
.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 5.dp, vertical = 1.dp),
.landingPop().clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 5.dp, vertical = 1.dp),
)
}
}
@@ -22,7 +22,13 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
@@ -67,7 +73,13 @@ internal const val PAGE_RETURN_MS = 900
* The focus ring stays out of it until it is done.
*/
@Composable
internal fun PageFlood(from: Origin, leaving: Boolean, content: @Composable () -> Unit) {
internal fun PageFlood(
from: Origin,
leaving: Boolean,
/** What the tile shows instead of the control's fill: a game card's art, cropped to the tile as it grows. */
art: Painter? = null,
content: @Composable () -> Unit,
) {
val pal = LocalPalette.current
val glide = LocalFocusGlide.current
// Left, top, right, bottom: 0 on the control, 1 on the pane's edge.
@@ -113,7 +125,7 @@ internal fun PageFlood(from: Origin, leaving: Boolean, content: @Composable () -
Box(Modifier.fillMaxSize().clipToBounds().onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }) {
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value; translationY = (1f - shown.value) * 14.dp.toPx() }) { content() }
Canvas(Modifier.fillMaxSize()) {
if (tile.value > 0.01f) drawTile(from, edges.map { it.value }, pal.surface, pal.line2, tile.value)
if (tile.value > 0.01f) drawTile(from, edges.map { it.value }, pal.surface, pal.line2, tile.value, art)
}
}
}
@@ -122,7 +134,7 @@ internal fun PageFlood(from: Origin, leaving: Boolean, content: @Composable () -
* The control's tile with its edges [v] of the way to the pane's (left, top, right, bottom):
* sinking with the page behind, blobby in flight, square once it fills the pane.
*/
private fun DrawScope.drawTile(from: Origin, v: List<Float>, fill: Color, line: Color, alpha: Float) {
private fun DrawScope.drawTile(from: Origin, v: List<Float>, fill: Color, line: Color, alpha: Float, art: Painter? = null) {
val (l, t, r, b) = v
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The control sinks with the page behind it (scaled to 0.95 about the pane's centre).
@@ -139,6 +151,17 @@ private fun DrawScope.drawTile(from: Origin, v: List<Float>, fill: Color, line:
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(from.corner * (1f - mean), minOf(w, h) * 0.42f, blob)
drawRoundRect(fill, Offset(left, top), Size(w, h), CornerRadius(corner), alpha = alpha)
val pic = art?.intrinsicSize?.takeIf { it.isSpecified && it.width > 0f && it.height > 0f }
if (art != null && pic != null && w > 0f && h > 0f) {
// The art covers the tile at every size (a centre crop), so a portrait card opens into the
// wide pane without stretching.
val k = maxOf(w / pic.width, h / pic.height)
val dw = pic.width * k
val dh = pic.height * k
clipPath(Path().apply { addRoundRect(RoundRect(left, top, left + w, top + h, CornerRadius(corner))) }) {
translate(left + (w - dw) / 2f, top + (h - dh) / 2f) { with(art) { draw(Size(dw, dh), alpha = alpha) } }
}
}
// The control's outline, kept while it is still the size of one.
val edge = (1f - mean * 3f).coerceIn(0f, 1f)
if (edge > 0f) drawRoundRect(line, Offset(left, top), Size(w, h), CornerRadius(corner), alpha = alpha * edge, style = Stroke(1.dp.toPx()))
@@ -1003,45 +1003,6 @@ private val StepBoxCorner = 16.dp
private val StepFillet = 10.dp
private val StepBoxMaxWidth = 264.dp
/**
* The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge
* and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and
* the inside corner where the pull meets the box is rounded the other way. While the box is still
* no wider than the pill it is just the pill, stretched down.
*/
private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) {
path.reset()
val r = p.right; val tl = p.left; val tt = p.top
val pb = maxOf(bottom, p.bottom)
val l = minOf(left, tl)
val pt = minOf(pullTop, pb)
val sw = tl - l
val h = (pb - pt).coerceAtLeast(0f)
val rt = minOf(p.height / 2f, p.width / 2f)
val rbr = minOf(box, (pb - tt) / 2f)
val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f)
val rpl = minOf(box, sw / 2f, h / 2f)
val f = minOf(fillet, sw / 2f)
val yl = minOf(pt + rpl, pb - rbl)
fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) {
if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false)
}
path.moveTo(tl + rt, tt)
path.lineTo(r - rt, tt)
corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt)
path.lineTo(r, pb - rbr)
corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb)
path.lineTo(l + rbl, pb)
corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl)
path.lineTo(l, yl)
corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt)
path.lineTo(tl - f, pt)
corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f)
path.lineTo(tl, tt + rt)
corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt)
path.close()
}
/**
* Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width
* drops out of it and a wider box steps out to the left under it for the choices, right edges
@@ -115,6 +115,11 @@ import androidx.compose.ui.window.PopupProperties
private val RowShape = RoundedCornerShape(12.dp)
private val GroupShape = RoundedCornerShape(14.dp)
/** Whether a pad's A is held right now, as the activity sees it: a focus move waits for its release. */
internal object HeldKeys {
@Volatile var confirm = false
}
internal fun Modifier.controllerConfirm(enabled: Boolean = true, onClick: () -> Unit): Modifier = onPreviewKeyEvent { event ->
val keyEvent = event.nativeKeyEvent
if (keyEvent.keyCode != KeyEvent.KEYCODE_BUTTON_A) {
@@ -126,6 +126,20 @@ internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
) = TiltedWall(games.size, driftMs, modifier) { i, m ->
Capsule(if (games.isEmpty()) null else games[i], RoundedCornerShape(10.dp), m)
}
/**
* [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] draws number i
* (0 until [count], or 0 for each blank one when [count] is 0) at the modifier it is given.
*/
@Composable
internal fun TiltedWall(
count: Int,
driftMs: Int,
modifier: Modifier = Modifier,
capsule: @Composable (index: Int, modifier: Modifier) -> Unit,
) {
val capW = 112.dp
val capH = 168.dp
@@ -165,8 +179,7 @@ internal fun CapsuleWall(
},
) {
repeat(perRun * 2) { i ->
val g = if (games.isEmpty()) null else games[(c * 5 + (i % perRun) * 3) % games.size]
Capsule(g, RoundedCornerShape(10.dp), Modifier.size(capW, capH))
capsule(if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count, Modifier.size(capW, capH))
}
}
}
@@ -0,0 +1,326 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// End session's confirm, for any control: a pull drops out of the control and a wider box steps
// out under it for the choices, one edge flush with the control. Stop draws it from its pill on
// the drawer; the stores draw it from Install, the Library tab and a download's cancel.
/**
* The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge
* and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and
* the inside corner where the pull meets the box is rounded the other way. While the box is still
* no wider than the pill it is just the pill, stretched down. [pillCorner] is the control's own
* corner: a pill's is half its height.
*/
internal fun stepPath(
path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float,
pillCorner: Float = minOf(p.height / 2f, p.width / 2f),
) {
path.reset()
val r = p.right; val tl = p.left; val tt = p.top
val pb = maxOf(bottom, p.bottom)
val l = minOf(left, tl)
val pt = minOf(pullTop, pb)
val sw = tl - l
val h = (pb - pt).coerceAtLeast(0f)
val rt = minOf(pillCorner, p.height / 2f, p.width / 2f)
val rbr = minOf(box, (pb - tt) / 2f)
val rbl = minOf(lerpF(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f)
val rpl = minOf(box, sw / 2f, h / 2f)
val f = minOf(fillet, sw / 2f)
val yl = minOf(pt + rpl, pb - rbl)
fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) {
if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false)
}
path.moveTo(tl + rt, tt)
path.lineTo(r - rt, tt)
corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt)
path.lineTo(r, pb - rbr)
corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb)
path.lineTo(l + rbl, pb)
corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl)
path.lineTo(l, yl)
corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt)
path.lineTo(tl - f, pt)
corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f)
path.lineTo(tl, tt + rt)
corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt)
path.close()
}
/** [stepPath] for a box that steps out to the right of the pill, out to [right], left edges flush. */
internal fun stepPathRight(
path: Path, p: Rect, right: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float,
pillCorner: Float = minOf(p.height / 2f, p.width / 2f),
) {
// Drawn as the left-hand outline about the pill's centre, which maps the pill onto itself,
// then mirrored back: x -> (p.left + p.right) - x.
val axis = p.left + p.right
stepPath(path, p, axis - right, bottom, pullTop, box, fillet, pillCorner)
path.transform(Matrix().apply { this[0, 0] = -1f; this[3, 0] = axis })
}
private fun lerpF(a: Float, b: Float, t: Float) = a + (b - a) * t
/** Which way the box steps out of its control. */
internal enum class StepSide {
/** Out to the left, right edges flush (a control at the right: Stop, a row's cancel). */
Left,
/** Out to the right, left edges flush (a control at the left: Install, a tab). */
Right,
}
/** The choices' side of a [StepOut]: each piece fades in on its own beat, and [first] takes focus. */
internal class StepOutScope(private val items: List<Animatable<Float, AnimationVector1D>>, val first: FocusRequester, val open: Boolean) {
/** Piece [i] (0 first) of the box: it fades and rises in after the box has stepped out. */
fun Modifier.stepItem(i: Int): Modifier = graphicsLayer {
val v = items[i.coerceIn(0, items.lastIndex)].value
alpha = v
translationY = (1f - v) * 6.dp.toPx()
}
}
/** Room between the control and the box that steps out under it: the pull. */
private val OutPull = 6.dp
private val OutBoxCorner = 16.dp
private val OutFillet = 10.dp
/**
* A confirm or a short list grown out of the control at [pill] (px, in this element's space, which
* should fill the screen area it may cover): a pull the control's width drops out of it, then a
* box steps out to [side] for [content]. [onDismiss] (B, a tap outside) asks to fold it; it folds
* back width first once [open] is false, then [onClosed]. Exactly StopConfirm's springs.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun StepOut(
open: Boolean,
pill: Rect,
side: StepSide,
accent: Color,
onDismiss: () -> Unit,
onClosed: () -> Unit,
pillCorner: Dp? = null,
maxWidth: Dp = 300.dp,
items: Int = 4,
handle: @Composable () -> Unit,
content: @Composable StepOutScope.() -> Unit,
) {
val colors = MaterialTheme.colorScheme
val density = LocalDensity.current
val inputMode = LocalInputModeManager.current
val edge = remember { Animatable(0f) }
val bottom = remember { Animatable(0f) }
val tint = remember { Animatable(0f) }
val dim = remember { Animatable(0f) }
val pieces = remember { List(items) { Animatable(0f) } }
var grown by remember { mutableStateOf(false) }
var measured by remember { mutableStateOf<IntSize?>(null) }
val first = remember { FocusRequester() }
val pull = with(density) { OutPull.toPx() }
val corner = pillCorner?.let { with(density) { it.toPx() } } ?: minOf(pill.height, pill.width) / 2f
BoxWithConstraints(Modifier.fillMaxSize()) {
val hostWidth = constraints.maxWidth.toFloat()
val room = if (side == StepSide.Left) pill.right else hostWidth - pill.left
val maxW = with(density) { maxWidth.toPx() }.coerceAtMost(room).coerceAtLeast(pill.width)
LaunchedEffect(open, measured) {
// Asked to close before it ever measured: nothing to fold.
if (!open && !grown) { onClosed(); return@LaunchedEffect }
val c = measured ?: return@LaunchedEffect
val rest = if (side == StepSide.Left) pill.left else pill.right
val out = if (side == StepSide.Left) (pill.right - c.width).coerceAtLeast(0f) else (pill.left + c.width).coerceAtMost(hostWidth)
val boxBottom = pill.bottom + pull + c.height
if (open) {
if (!grown) {
edge.snapTo(rest); bottom.snapTo(pill.bottom); tint.snapTo(0f)
pieces.forEach { it.snapTo(0f) }
grown = true
}
coroutineScope {
launch { dim.animateTo(1f, Motion.tw(260)) }
launch { tint.animateTo(1f, Motion.tw(200)) }
launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) }
launch { delay(Motion.ms(110).toLong()); edge.animateTo(out, Motion.sp(0.55f, 300f)) }
pieces.forEachIndexed { i, a ->
launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) }
}
if (inputMode.inputMode == InputMode.Keyboard) launch {
delay(Motion.ms(300).toLong())
withFrameNanos { }
runCatching { first.requestFocus() }
}
}
} else {
coroutineScope {
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) }
launch { delay(Motion.ms(60).toLong()); edge.animateTo(rest, Motion.sp(1f, 420f)) }
launch { delay(Motion.ms(160).toLong()); bottom.animateTo(pill.bottom, Motion.sp(1f, 380f)) }
launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) }
}
grown = false
onClosed()
}
}
// Opened by touch and then answered with a pad: the pad starts in the box, not behind it.
LaunchedEffect(open, grown, inputMode.inputMode) {
if (open && grown && inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { first.requestFocus() } }
}
// The rest of the screen dims behind it, and a tap there folds it. Never a pad's stop.
Box(
Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000))
.focusProperties { canFocus = false }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onDismiss),
)
val path = remember { Path() }
val boxCorner = with(density) { OutBoxCorner.toPx() }
val fillet = with(density) { OutFillet.toPx() }
val rim = with(density) { 1.4.dp.toPx() }
val restFill = colors.surface
val hotFill = lerp(colors.surface, accent, 0.22f)
Canvas(Modifier.fillMaxSize()) {
if (!grown) return@Canvas
if (side == StepSide.Left) stepPath(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
else stepPathRight(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
drawPath(path, lerp(hotFill, restFill, tint.value))
drawPath(path, accent.copy(alpha = lerpF(0.9f, 0.55f, tint.value)), style = Stroke(rim))
}
// The control's label rides on top, as the pull's handle.
if (grown) Box(
contentAlignment = Alignment.Center,
modifier = Modifier.offset { IntOffset(pill.left.roundToInt(), pill.top.roundToInt()) }
.size(with(density) { pill.width.toDp() }, with(density) { pill.height.toDp() }),
) { handle() }
val x = if (side == StepSide.Left) pill.right - (measured?.width?.toFloat() ?: maxW) else pill.left
Column(
modifier = Modifier
.offset { IntOffset(x.roundToInt(), (pill.bottom + pull).roundToInt()) }
.widthIn(min = with(density) { pill.width.toDp() }, max = with(density) { maxW.toDp() })
.onSizeChanged { if (measured != it) measured = it }
.controllerBack(onDismiss)
// A pad stays in the box until it is answered or folded, as it did in Stop's dialog.
.focusProperties { exit = { FocusRequester.Cancel } }
.focusGroup()
.padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
) { StepOutScope(pieces, first, open).content() }
}
}
/**
* One choice in a [StepOut]: a capsule that fills when focused, like Stop's Cancel and Stop. [danger]
* draws it in red; [icon] and [trailing] make it a row (a place to install to and its free space).
*/
@Composable
internal fun StepChoice(
label: String,
modifier: Modifier = Modifier,
danger: Boolean = false,
icon: ImageVector? = null,
trailing: String? = null,
selected: Boolean = false,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(20.dp)
val accent = if (danger) colors.error else pal.signal
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier.heightIn(min = 40.dp).clip(shape)
.background(if (hot || selected) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent)
.glideBorder(hot, shape, accent, if (selected) accent.copy(alpha = 0.5f) else Color.Transparent)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.Button, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp),
) {
if (icon != null) Icon(icon, null, tint = if (hot) accent else colors.onBackground, modifier = Modifier.size(20.dp))
Text(
label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = if (trailing != null) Modifier.weight(1f) else Modifier,
)
if (trailing != null) Text(trailing, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
}
}
/** A [StepOut]'s heading line, the first piece to fade in. */
@Composable
internal fun StepOutScope.StepTitle(text: String) {
Text(
text, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.stepItem(0).padding(start = 4.dp, bottom = 10.dp),
)
}
/** The choices of a [StepOut] stacked, each the box's width. */
@Composable
internal fun StepList(content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), content = content)
}
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -94,11 +96,11 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) }
}
item?.owned == true && StoresState.isUnfinished(item) -> {
PrimaryButton(stringResource(R.string.stores_resume_install), main = true) { StoresState.requestInstall(ctx, item) }
InstallButton(stringResource(R.string.stores_resume_install)) { StoresState.requestInstall(ctx, item) }
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
}
item?.owned == true -> PrimaryButton(
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), main = true,
item?.owned == true -> InstallButton(
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
) { StoresState.requestInstall(ctx, item) }
item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true) { openStoreUrl(ctx, item) }
item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true) { openStoreUrl(ctx, item) }
@@ -116,3 +118,18 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
Text(description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp))
}
}
/**
* Install (or Resume install) that says where it sits when pressed: where to install grows out of
* it, and the download it starts leaves it as a dot for the Downloads chip.
*/
@Composable
private fun InstallButton(label: String, onInstall: () -> Unit) {
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
Box(Modifier.onGloballyPositioned { placed[0] = it }) {
PrimaryButton(label, main = true) {
StoresMotion.markInstall(placed[0]?.takeIf { it.isAttached }?.boundsInRoot(), label)
onInstall()
}
}
}
@@ -1,11 +1,40 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.ui.draw.alpha
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.automirrored.outlined.Undo
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Pause
@@ -77,37 +106,81 @@ internal fun StoresDownloadsPane(s: FrontEndState, a: FrontEndActions) {
@Composable
private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val scope = rememberCoroutineScope()
val entries = StoresState.downloads
if (entries.isEmpty()) {
Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.stores_downloads_empty), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
return
}
// Clearing: the finished rows go into the divider's X one by one, bottom first, and only then
// leave the queue.
var clearing by remember { mutableStateOf(false) }
// Under way on top; finished below a line whose X clears them all (rows only - a failed
// download's kept files stay until its game page's Clear).
val (active, finished) = entries.partition { it.isActive }
val finishedShown = if (clearing) emptyList() else finished
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
// The list changes only when rows come, go or change state; each row reads its own progress.
active.forEachIndexed { i, row -> key(row.key) { Rise(1 + i.coerceAtMost(5)) { DownloadCard(StoresState.download(row.key) ?: row, s, a) } } }
if (finished.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = if (active.isEmpty()) 0.dp else 4.dp)) {
Box(Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line))
RoundAction(Icons.Outlined.Close, stringResource(R.string.stores_dl_clear_all), size = 30.dp) { DownloadQueue.dismissFinished() }
AnimatedVisibility(entries.isEmpty(), enter = fadeIn(Motion.tw(300, 300)), exit = fadeOut(Motion.tw(120))) {
Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.stores_downloads_empty), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
finished.forEach { row ->
key(row.key) {
val d = StoresState.download(row.key) ?: row
if (d.state == DownloadState.FAILED) DownloadCard(d, s, a) else FinishedRow(d, s, a)
}
// The list changes only when rows come, go or change state; each row reads its own progress.
// A row pours out of the count's side (the right), and leaves the way it came; one that
// installed stays a beat for its bar to gather into Play first.
AnimatedRows(
active, key = { it.key },
enter = { i, later ->
val wait = if (later) 0 else 90 * i.coerceAtMost(4)
expandHorizontally(Motion.tw(360, wait), expandFrom = Alignment.End) + expandVertically(Motion.tw(320, wait), expandFrom = Alignment.Top) +
fadeIn(Motion.tw(300, wait + 60))
},
exit = { row, _, _ ->
if (StoresState.download(row.key)?.state == DownloadState.INSTALLED)
fadeOut(Motion.tw(220, GATHER_HOLD_MS)) + shrinkVertically(Motion.tw(300, GATHER_HOLD_MS))
else shrinkHorizontally(Motion.tw(260), shrinkTowards = Alignment.End) + shrinkVertically(Motion.tw(300, 120)) + fadeOut(Motion.tw(200, 80))
},
) { row, _ -> DownloadCard(StoresState.download(row.key) ?: row, s, a) }
AnimatedVisibility(
finishedShown.isNotEmpty(),
enter = expandVertically(Motion.tw(260)) + fadeIn(Motion.tw(220)),
exit = fadeOut(Motion.tw(200, 60 * finished.size.coerceAtMost(8) + 160)) + shrinkVertically(Motion.tw(220, 60 * finished.size.coerceAtMost(8) + 200)),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = if (active.isEmpty()) 0.dp else 4.dp)) {
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
RoundAction(Icons.Outlined.Close, stringResource(R.string.stores_dl_clear_all), size = 30.dp) {
if (!clearing) {
val n = finished.size.coerceAtMost(8)
clearing = true
scope.launch {
delay(Motion.ms(60 * n + 420).toLong())
DownloadQueue.dismissFinished()
clearing = false
}
}
}
}
}
AnimatedRows(
finishedShown, key = { it.key },
// One that just finished joins after its row above has gathered; the rest are simply there.
enter = { i, later -> if (later) expandVertically(Motion.tw(300, GATHER_HOLD_MS)) + fadeIn(Motion.tw(220, GATHER_HOLD_MS + 50)) else fadeIn(Motion.tw(300, 60 * i.coerceAtMost(5))) },
// Into the X at the top right, bottom row first.
exit = { _, i, n ->
val wait = 60 * (n - 1 - i).coerceIn(0, 8)
scaleOut(Motion.tw(220, wait), targetScale = 0.2f, transformOrigin = TransformOrigin(1f, 0f)) +
fadeOut(Motion.tw(200, wait + 40)) + shrinkVertically(Motion.tw(240, wait + 80))
},
) { row, later ->
val d = StoresState.download(row.key) ?: row
if (d.state == DownloadState.FAILED) DownloadCard(d, s, a) else FinishedRow(d, s, a, bloom = later)
}
}
}
/** A finished download: its name and store, and Play when it installed. */
/** How long a row that installed holds for its bar to gather into Play before it moves under the line. */
private const val GATHER_HOLD_MS = 900
/** A finished download: its name and store, and Play when it installed - blooming out of a dot when it just did. */
@Composable
private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) {
private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, bloom: Boolean = false) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
Row(
@@ -117,10 +190,24 @@ private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
SourceChip(d.store.id, small = true)
Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, modifier = Modifier.weight(1f))
if (d.state == DownloadState.INSTALLED) RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) }
if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) {
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) }
}
}
}
/** Grows out of a dot (sp 0.5 / 300) after [waitMs], when [on]; otherwise just there. */
private fun Modifier.bloomIn(on: Boolean, waitMs: Int): Modifier = composed {
val k = remember { Animatable(if (on && Motion.scale > 0f) 0f else 1f) }
LaunchedEffect(Unit) {
if (k.value < 1f) {
delay(Motion.ms(waitMs).toLong())
k.animateTo(1f, Motion.sp(0.5f, 300f))
}
}
graphicsLayer { scaleX = k.value; scaleY = k.value; alpha = k.value.coerceIn(0f, 1f) }
}
/**
* A row's action as a round icon button - the label is its description - sized like the old
* compact buttons and focusable by pad. [primary] fills it with the accent, [danger] in red.
@@ -146,18 +233,62 @@ internal fun RoundAction(
) { androidx.compose.material3.Icon(icon, description, tint = tint, modifier = Modifier.size(size * 0.5f)) }
}
/** Cancel in two taps: the stop icon, then a red delete and a keep that undoes it (back by itself after a few seconds). */
/**
* Cancel as End session's confirm: the X drops a pull and Keep and Delete step out to its left,
* in red. Keep's rim runs out the four seconds after which it folds back by itself.
*/
@Composable
private fun CancelAction(onDelete: () -> Unit) {
var armed by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(armed) { if (armed) { kotlinx.coroutines.delay(4000); armed = false } }
if (!armed) RoundAction(Icons.Outlined.Close, stringResource(R.string.stores_dl_cancel)) { armed = true }
else {
RoundAction(Icons.Outlined.Delete, stringResource(R.string.stores_dl_cancel_confirm), danger = true) { armed = false; onDelete() }
RoundAction(Icons.AutoMirrored.Outlined.Undo, stringResource(R.string.stores_notification_keep)) { armed = false }
val colors = MaterialTheme.colorScheme
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
val keep = stringResource(R.string.stores_notification_keep)
val delete = stringResource(R.string.stores_dl_cancel_confirm)
Box(Modifier.onGloballyPositioned { placed[0] = it }) {
RoundAction(Icons.Outlined.Close, stringResource(R.string.stores_dl_cancel)) {
val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() ?: return@RoundAction
StoresMotion.ask(StepAsk(
anchor = at, side = StepSide.Left, accent = colors.error, items = 2,
handle = { androidx.compose.material3.Icon(Icons.Outlined.Close, null, tint = colors.error, modifier = Modifier.size(18.dp)) },
) {
val left = remember { Animatable(1f) }
LaunchedEffect(Unit) {
// Wall-clock seconds, as before; the rim follows them when animations run.
if (Motion.scale > 0f) launch { left.animateTo(0f, androidx.compose.animation.core.tween((CANCEL_HOLD_MS / Motion.scale).toInt(), easing = LinearEasing)) }
delay(CANCEL_HOLD_MS.toLong())
StoresMotion.fold()
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) {
StepChoice(keep, enabled = open, modifier = Modifier.stepItem(0).focusRequester(first).timeRim({ left.value }, colors.onBackground)) { StoresMotion.fold() }
StepChoice(delete, danger = true, enabled = open, modifier = Modifier.stepItem(1)) { StoresMotion.fold(); onDelete() }
}
})
}
}
}
private const val CANCEL_HOLD_MS = 4000
/** A capsule's outline drawn for [left] (1 to 0) of its length, clockwise from the top centre: time running out. */
private fun Modifier.timeRim(left: () -> Float, color: Color): Modifier = drawWithContent {
drawContent()
val v = left()
if (v <= 0f) return@drawWithContent
val w = 1.5.dp.toPx()
val r = size.height / 2f
val outline = androidx.compose.ui.graphics.Path().apply {
moveTo(size.width / 2f, w / 2f)
lineTo(size.width - r, w / 2f)
arcTo(Rect(size.width - 2 * r + w / 2f, w / 2f, size.width - w / 2f, size.height - w / 2f), -90f, 180f, false)
lineTo(r, size.height - w / 2f)
arcTo(Rect(w / 2f, w / 2f, 2 * r - w / 2f, size.height - w / 2f), 90f, 180f, false)
lineTo(size.width / 2f, w / 2f)
}
val measure = androidx.compose.ui.graphics.PathMeasure().apply { setPath(outline, false) }
val part = androidx.compose.ui.graphics.Path()
measure.getSegment(0f, measure.length * v, part, true)
drawPath(part, color.copy(alpha = 0.8f), style = androidx.compose.ui.graphics.drawscope.Stroke(w))
}
@Composable
private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
@@ -216,7 +347,10 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
CancelAction { DownloadQueue.cancel(ctx, d.key) }
}
DownloadState.FAILED -> RoundAction(Icons.Filled.Refresh, stringResource(R.string.stores_dl_resume), primary = true) { DownloadQueue.retry(ctx, d.key) }
DownloadState.INSTALLED -> RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) }
// Just installed (this row is on its way under the line): Play blooms where the bar gathered.
DownloadState.INSTALLED -> Box(Modifier.bloomIn(true, 420)) {
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) }
}
DownloadState.CANCELLED -> {}
}
}
@@ -227,29 +361,87 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
/**
* One segment per stage - Manifest, Download, Verify, Install - equal widths: a passed or skipped
* stage full, the active one filling with its own progress (a sliver while it has nothing to count),
* the rest empty.
* the rest empty. Fills ride a spring; a stage that completes swells and settles (the bloop); a
* paused one breathes; and once the download has installed the four gather into one green line
* that draws up into a dot, where Play blooms.
*/
@Composable
private fun StageBar(d: DownloadEntry) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val stages = listOf(DownloadStage.MANIFEST, DownloadStage.DOWNLOAD, DownloadStage.VERIFY, DownloadStage.INSTALL)
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth()) {
for (st in stages) {
val active = d.state != DownloadState.INSTALLED && d.stage == st
val fill = when {
active -> d.stageFraction.let { if (it < 0f) 0.04f else it }
// A stage a store skips or folds into another (Epic checks chunks while it fetches,
// Amazon writes while it downloads) reads complete once a later one has started.
d.passed(st) || st.ordinal < d.stage.ordinal || d.stage == DownloadStage.DONE -> 1f
else -> 0f
}
Box(Modifier.weight(1f)) {
ProgressBarThin(
fill, paused = active && d.state == DownloadState.PAUSED, verify = active && st == DownloadStage.VERIFY,
height = 6.dp, done = !active && fill >= 1f,
)
}
val installed = d.state == DownloadState.INSTALLED
val actives = stages.map { st -> !installed && d.stage == st }
val fills = stages.mapIndexed { i, st ->
when {
actives[i] -> d.stageFraction.let { if (it < 0f) 0.04f else it }
// A stage a store skips or folds into another (Epic checks chunks while it fetches,
// Amazon writes while it downloads) reads complete once a later one has started.
d.passed(st) || st.ordinal < d.stage.ordinal || d.stage == DownloadStage.DONE -> 1f
else -> 0f
}
}
val done = fills.mapIndexed { i, f -> !actives[i] && f >= 1f }
val shown = fills.mapIndexed { i, f -> animateFloatAsState(f, Motion.sp(0.8f, 300f), label = "stage$i") }
val bloops = remember { List(4) { Animatable(1f) } }
val wasDone = remember { done.toBooleanArray() }
done.forEachIndexed { i, dn ->
LaunchedEffect(i, dn) {
if (dn && !wasDone[i] && Motion.scale > 0f) {
bloops[i].animateTo(1.6f, Motion.tw(90))
bloops[i].animateTo(1f, Motion.sp(0.45f, 600f))
}
wasDone[i] = dn
}
}
val gather = remember { Animatable(0f) }
LaunchedEffect(installed) { if (installed) gather.animateTo(1f, Motion.tw(520, easing = androidx.compose.animation.core.CubicBezierEasing(0.6f, 0f, 0.15f, 1f))) }
val paused = d.state == DownloadState.PAUSED
val breath = breathing(paused)
val verifyColor = Color(0xFF9B6DFF)
val pausedColor = Color(0xFFFFC24D)
Canvas(Modifier.fillMaxWidth().height(10.dp)) {
val h = 6.dp.toPx()
val cy = size.height / 2f
val g = gather.value
if (g < 0.5f) {
// The segments: the gaps close and every fill turns done-green as the gather starts.
val join = (g * 2f).coerceIn(0f, 1f)
val gap = 3.dp.toPx() * (1f - join)
val segW = (size.width - gap * 3f) / 4f
for (i in 0 until 4) {
val x = i * (segW + gap)
drawRoundRect(colors.surfaceVariant, Offset(x, cy - h / 2f), Size(segW, h), CornerRadius(h / 2f))
val base = when {
done[i] -> pal.good
actives[i] && paused -> pausedColor.copy(alpha = breath)
actives[i] && stages[i] == DownloadStage.VERIFY -> verifyColor
else -> pal.signal
}
val fill = androidx.compose.ui.util.lerp(shown[i].value.coerceIn(0f, 1f), 1f, join)
val hh = h * bloops[i].value
if (fill > 0f) drawRoundRect(
androidx.compose.ui.graphics.lerp(base, pal.good, join), Offset(x, cy - hh / 2f), Size(segW * fill, hh), CornerRadius(hh / 2f),
)
}
} else {
// One line drawing up into a dot at its right end.
val p = ((g - 0.5f) * 2f).coerceIn(0f, 1f)
val dot = 10.dp.toPx()
val left = androidx.compose.ui.util.lerp(0f, size.width - dot, p)
val hh = androidx.compose.ui.util.lerp(h, dot, p)
drawRoundRect(pal.good, Offset(left, cy - hh / 2f), Size(size.width - left, hh), CornerRadius(hh / 2f), alpha = 1f - p * 0.4f)
}
}
}
/** 1, or while [on] (and animations run), an alpha going 1 → 0.5 → 1 over the rail's live-dot period. */
@Composable
private fun breathing(on: Boolean): Float {
if (!on || Motion.scale == 0f) return 1f
val t = rememberInfiniteTransition(label = "breath")
val v by t.animateFloat(1f, 0.5f, infiniteRepeatable(androidx.compose.animation.core.tween(800), RepeatMode.Reverse), label = "breathe")
return v
}
private fun eta(seconds: Long): String = when {
@@ -0,0 +1,390 @@
package com.droiddeck.launcher.ui
import android.os.SystemClock
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.droiddeck.launcher.stores.Store
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.max
import kotlin.math.sin
// The Stores section's moves, in the launcher's own vocabulary: an install bloops into a dot that
// flies to the Downloads chip, sign-in floods in the store's colour and comes home to the chip's
// dot, and the confirms step out of the control that asked (StepOut). Everything here is in root
// coordinates; the Stores page draws it in its own space.
internal object StoresMotion {
/** The Install (or Resume install) button last pressed, its label and when: a download that starts soon after flies from it. */
var installFrom: Rect? = null
private set
var installLabel: String = ""
private set
private var installAt = 0L
fun markInstall(bounds: Rect?, label: String) {
installFrom = bounds
installLabel = label
installAt = SystemClock.uptimeMillis()
}
/** The button just pressed, if it was pressed in the last few seconds; not consumed. */
fun recentInstall(): Rect? = installFrom.takeIf { SystemClock.uptimeMillis() - installAt < 5_000 }
/** The button a download that just started came from, once; stale after 20 s (a slow manifest). */
fun takeInstall(): Rect? = installFrom.takeIf { SystemClock.uptimeMillis() - installAt < 20_000 }.also { installAt = 0L }
/** Where the Downloads chip sits, for a download's dot to land in. */
var downloadsChip by mutableStateOf<Rect?>(null)
/** Bumped each time a dot lands: the chip's count and the rail's badge pop on it. */
var landed by mutableIntStateOf(0)
/** Dots in flight to the Downloads chip. */
val flights = mutableStateListOf<Flight>()
fun fly(from: Rect) {
if (Motion.scale == 0f || downloadsChip == null) { landed++; return }
flights += Flight(from, SystemClock.uptimeMillis())
}
/** The confirm or list stepping out of a control on the Stores page now, and whether it is open. */
var step by mutableStateOf<StepAsk?>(null)
private set
var stepOpen by mutableStateOf(false)
private set
fun ask(a: StepAsk) {
step?.onDismiss()
step = a
stepOpen = true
}
/** Drops whatever is stepped out or flooding, at once: the page it belonged to has gone. */
fun drop() {
step?.let { step = null; it.onDismiss() }
stepOpen = false
signIn = null
}
/** Folds the current one back into its control. */
fun fold() { stepOpen = false }
internal fun closed(a: StepAsk) {
if (step === a) step = null
a.onDismiss()
}
/** A store's sign-in flooding the page, or draining back out of it. */
var signIn by mutableStateOf<SignInFlood?>(null)
/** Per store, bumped to send its chip's dot one pulse ring (signed in, picked). */
val pulses = androidx.compose.runtime.mutableStateMapOf<Store, Int>()
fun pulse(store: Store) { pulses[store] = (pulses[store] ?: 0) + 1 }
/** Where each store's dot sits on its chip, for a sign-in to come home to. */
val dots = HashMap<Store, Rect>()
/** The card a game page is about to open from. */
private var card: CardMark? = null
private var cardAt = 0L
fun markCard(c: CardMark) {
card = c
cardAt = SystemClock.uptimeMillis()
}
/** The card game [key] was just opened from, once; a page opened another way shows without a flood. */
fun takeCard(key: String): CardMark? = card?.takeIf { it.key == key && SystemClock.uptimeMillis() - cardAt < 1_000 }.also { card = null }
}
/** A game card at [bounds] (root px) with corner [corner] (px) and its art, for the page it opens to flood out of. */
internal class CardMark(val key: String, val bounds: Rect, val corner: Float, val art: String?)
internal class Flight(val from: Rect, val id: Long)
/**
* A confirm or short list for [StepOut] on the Stores page, asked by a control that sits at [anchor]
* (root px). [content] gets a close that folds it; [onDismiss] runs once it has folded, however.
*/
internal class StepAsk(
val anchor: Rect,
val side: StepSide,
val accent: Color,
val pillCorner: androidx.compose.ui.unit.Dp? = null,
val items: Int = 4,
val onDismiss: () -> Unit = {},
val handle: @Composable () -> Unit,
val content: @Composable StepOutScope.() -> Unit,
)
internal class SignInFlood(val store: Store, val from: Rect, val start: Color, val color: Color) {
/** Covered and handed to the login page; waiting for the page to come back. */
var away by mutableStateOf(false)
/** Back from the login page: draining into [to]. */
var drainTo by mutableStateOf<Rect?>(null)
}
/** The dots flying to the Downloads chip, drawn over the page whose root offset is [origin]. */
@Composable
internal fun FlightLayer(origin: Offset) {
for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) }
}
/**
* One install's dot: the button squashes into a 14dp dot (width first, a little bloop), which then
* flies on an arc to the Downloads chip, stretched along its path like the focus ring's drop.
*/
@Composable
private fun FlightDot(f: Flight, origin: Offset) {
val pal = LocalPalette.current
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) {
t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing))
t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing))
StoresMotion.landed++
StoresMotion.flights.remove(f)
}
Canvas(Modifier.fillMaxSize()) {
val r = 7.dp.toPx()
val from = f.from.translate(-origin)
val chip = (StoresMotion.downloadsChip ?: return@Canvas).translate(-origin)
val a = from.center
// The count sits at the chip's right end.
val b = Offset(chip.right - 22.dp.toPx(), chip.center.y)
val v = t.value
if (v < 1f) {
// Squash: the width goes first, then the height, into the dot; a bloop at the end.
val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f))
val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f))
val k = 1f - 0.15f * sin(PI * v).toFloat()
drawRoundRect(pal.signal, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2))
} else {
val p = v - 1f
val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx())
val q = 1f - p
val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y)
val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y))
val stretch = sin(PI * p).toFloat()
val angle = atan2(d.y, d.x) * 180f / PI.toFloat()
rotate(angle, pos) {
val w = 2 * r * (1f + 0.5f * stretch)
val h = 2 * r * (1f - 0.25f * stretch)
drawOval(pal.signal, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h))
}
}
}
}
/** Pops (1.3 to 1, with one ring) each time a download's dot lands; for the count on the chip and the rail. */
internal fun Modifier.landingPop(): Modifier = composed {
val pal = LocalPalette.current
val scale = remember { Animatable(1f) }
val ring = remember { Animatable(1f) }
val seen = remember { intArrayOf(StoresMotion.landed) }
LaunchedEffect(StoresMotion.landed) {
if (StoresMotion.landed == seen[0]) return@LaunchedEffect
seen[0] = StoresMotion.landed
coroutineScope {
launch { scale.snapTo(1.3f); scale.animateTo(1f, Motion.sp(0.45f, 500f)) }
launch { ring.snapTo(0f); ring.animateTo(1f, Motion.tw(400)) }
}
}
this.drawBehind {
if (ring.value < 1f) {
val rr = max(size.width, size.height) / 2f * lerp(0.4f, 1.6f, ring.value)
drawCircle(pal.signal.copy(alpha = 0.5f * (1f - ring.value)), rr, center, style = Stroke(1.5.dp.toPx()))
}
}.graphicsLayer { scaleX = scale.value; scaleY = scale.value }
}
/**
* A pressed button in [start] stretching out over this element in [color] (LaunchFlood's move:
* each edge on its own loose spring, the furthest first, blobby in flight). [from] is in this
* element's px. Calls [onCovered] once, when nothing but [color] shows; then holds.
*/
@Composable
internal fun ColorFlood(from: Rect, start: Color, color: Color, onCovered: () -> Unit) {
val edges = remember { List(4) { Animatable(0f) } }
val reached = remember { BooleanArray(4) }
var size by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(size != Size.Zero) {
if (size == Size.Zero) return@LaunchedEffect
val travel = listOf(from.left, from.top, size.width - from.right, size.height - from.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
coroutineScope {
edges.forEachIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((120 * (1f - lead)).toInt()).toLong())
// Overshoots past the edge, off the element, so the wobble is felt in the pull.
edge.animateTo(1f, if (Motion.scale == 0f) Motion.sp() else spring(dampingRatio = 0.48f, stiffness = lerp(110f, 190f, lead))) {
if (value >= 1f) reached[i] = true
}
reached[i] = true
}
}
snapshotFlow { edges.indices.all { reached[it] || edges[it].value >= 1f } }.first { it }
onCovered()
}
}
Canvas(Modifier.fillMaxSize().onSizeChangedCompat { size = it }) {
val v = edges.mapIndexed { i, e -> if (reached[i]) 1f else e.value }
drawFloodShape(from, from.minDimension / 2f, v, lerp(start, color, (v.average().toFloat() * 2f).coerceIn(0f, 1f)))
}
}
/**
* [color] over the whole element drawing down into [to] (px, here), critically damped, the near
* edges first (PageFlood's way back), then fading out on it; [onDone] after.
*/
@Composable
internal fun ColorDrain(color: Color, to: Rect, toCorner: Float, onDone: () -> Unit) {
val edges = remember { List(4) { Animatable(1f) } }
val alpha = remember { Animatable(1f) }
var size by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(size != Size.Zero) {
if (size == Size.Zero) return@LaunchedEffect
val travel = listOf(to.left, to.top, size.width - to.right, size.height - to.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
coroutineScope {
edges.mapIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((120 * (1f - lead)).toInt()).toLong())
edge.animateTo(0f, if (Motion.scale == 0f) Motion.sp() else spring(dampingRatio = 1f, stiffness = lerp(170f, 260f, lead)))
}
}.forEach { it.join() }
}
alpha.animateTo(0f, Motion.tw(160))
onDone()
}
Canvas(Modifier.fillMaxSize().onSizeChangedCompat { size = it }) {
drawFloodShape(to, toCorner, edges.map { it.value }, color.copy(alpha = color.alpha * alpha.value))
}
}
private fun Modifier.onSizeChangedCompat(onSize: (Size) -> Unit) =
onSizeChanged { onSize(Size(it.width.toFloat(), it.height.toFloat())) }
/** A rect from [r] with its edges [v] of the way to this scope's (left, top, right, bottom): blobby in flight. */
private fun DrawScope.drawFloodShape(r: Rect, restCorner: Float, v: List<Float>, color: Color) {
val left = lerp(r.left, 0f, v[0])
val top = lerp(r.top, 0f, v[1])
val right = lerp(r.right, size.width, v[2])
val bottom = lerp(r.bottom, size.height, v[3])
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(restCorner * (1f - mean), minOf(w, h) * 0.42f, blob)
drawRoundRect(color, Offset(left, top), Size(w, h), CornerRadius(corner))
}
/** Sends one pulse ring out of a dot each time [trigger] changes after the first composition. */
internal fun Modifier.pulseRing(trigger: Int, color: Color): Modifier = composed {
val ring = remember { Animatable(1f) }
val seen = remember { intArrayOf(trigger) }
LaunchedEffect(trigger) {
if (trigger == seen[0]) return@LaunchedEffect
seen[0] = trigger
if (Motion.scale == 0f) return@LaunchedEffect
ring.snapTo(0f)
ring.animateTo(1f, Motion.tw(600))
}
drawBehind {
if (ring.value < 1f) drawCircle(
color.copy(alpha = 0.6f * (1f - ring.value)), size.minDimension / 2f * lerp(0.4f, 1.6f, ring.value) * 1.4f, center,
style = Stroke(1.5.dp.toPx()),
)
}
}
/**
* [items] in a column where a row that comes enters with [enter] and a row that goes stays on to
* leave with [exit] (a Column has no item animations of its own). [content] gets the row and
* whether it came after the list was first shown.
*/
@Composable
internal fun <T> AnimatedRows(
items: List<T>,
key: (T) -> Any,
enter: (index: Int, later: Boolean) -> EnterTransition,
exit: (item: T, index: Int, count: Int) -> ExitTransition,
content: @Composable (item: T, later: Boolean) -> Unit,
) {
val order = remember { mutableListOf<Any>() }
val last = remember { mutableMapOf<Any, T>() }
val states = remember { mutableMapOf<Any, MutableTransitionState<Boolean>>() }
val later = remember { mutableMapOf<Any, Boolean>() }
val mounted = remember { booleanArrayOf(false) }
val now = items.associateBy(key)
// Keep a leaving row where it was among the ones that stay.
val merged = ArrayList<Any>()
var j = 0
for (k in now.keys) {
val pos = order.indexOf(k)
if (pos >= 0) {
while (j < pos) { val o = order[j]; if (o !in now && o !in merged) merged += o; j++ }
j = maxOf(j, pos + 1)
}
merged += k
}
while (j < order.size) { val o = order[j]; if (o !in now && o !in merged) merged += o; j++ }
now.forEach { (k, v) -> last[k] = v }
val shown = merged.filter { k ->
val st = states.getOrPut(k) { MutableTransitionState(false).also { later[k] = mounted[0] } }
st.targetState = k in now
val gone = k !in now && st.isIdle && !st.currentState
if (gone) { states.remove(k); last.remove(k); later.remove(k) }
!gone
}
order.clear(); order.addAll(shown)
SideEffect { mounted[0] = true }
shown.forEachIndexed { i, k ->
val item = last[k] ?: return@forEachIndexed
val st = states[k] ?: return@forEachIndexed
key(k) {
AnimatedVisibility(st, enter = enter(i, later[k] == true), exit = exit(item, i, shown.size)) { content(item, later[k] == true) }
}
}
}
File diff suppressed because it is too large. Load diff
@@ -358,8 +358,11 @@ internal fun AdbTextField(
onFocusChange: (Boolean) -> Unit = {},
onNext: (() -> Unit)? = null,
onDone: (() -> Unit)? = null,
/** Takes focus (and the keyboard) once, as soon as it is shown: a field that opened on request. */
focusOnShow: Boolean = false,
) {
val context = LocalContext.current
val focusAsked = remember { booleanArrayOf(false) }
val palette = LocalPalette.current
val onValueChangeLatest = rememberUpdatedState(onValueChange)
val onNextLatest = rememberUpdatedState(onNext)
@@ -434,6 +437,14 @@ internal fun AdbTextField(
}
},
update = { editText ->
if (focusOnShow && !focusAsked[0]) {
focusAsked[0] = true
editText.post {
editText.requestFocus()
(editText.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
?.showSoftInput(editText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
}
}
editText.setTextColor(colors.onBackground.toArgb())
editText.setHintTextColor(colors.onSurfaceVariant.toArgb())
editText.hint = placeholder
+5
View File
@@ -1979,9 +1979,14 @@
<string name="stores_tab_count">%1$s (%2$d)</string>
<string name="stores_signin">Sign in</string>
<string name="stores_signout_named">%1$s · sign out</string>
<string name="stores_signout_title">Sign out of %1$s?</string>
<string name="stores_signout_note">Installed games stay. The library is fetched again when you sign back in.</string>
<string name="stores_signout_stay">Stay signed in</string>
<string name="stores_signout_go">Sign out</string>
<string name="stores_search_catalog">Search the %1$s catalog…</string>
<string name="stores_search_installed">Search installed games…</string>
<string name="stores_search_library">Search your library…</string>
<string name="stores_search_open">Search</string>
<string name="stores_installed_empty">Nothing installed</string>
<string name="stores_library_loading">Loading</string>
<string name="stores_nothing_matches">No games</string>
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class StepOutTest {
private val pill = Rect(400f, 100f, 500f, 140f)
@Test fun leftOutlineRunsFromTheBoxLeftToThePillRight() {
val path = Path()
stepPath(path, pill, left = 240f, bottom = 300f, pullTop = 146f, box = 16f, fillet = 10f)
val b = path.getBounds()
assertEquals(240f, b.left, 0.5f)
assertEquals(500f, b.right, 0.5f)
assertEquals(100f, b.top, 0.5f)
assertEquals(300f, b.bottom, 0.5f)
}
@Test fun rightOutlineMirrorsItAboutThePill() {
val path = Path()
stepPathRight(path, pill, right = 660f, bottom = 300f, pullTop = 146f, box = 16f, fillet = 10f)
val b = path.getBounds()
assertEquals(400f, b.left, 0.5f)
assertEquals(660f, b.right, 0.5f)
assertEquals(100f, b.top, 0.5f)
assertEquals(300f, b.bottom, 0.5f)
}
@Test fun aClosedOutlineIsJustThePill() {
val path = Path()
stepPathRight(path, pill, right = pill.right, bottom = pill.bottom, pullTop = pill.bottom + 6f, box = 16f, fillet = 10f, pillCorner = 12f)
val b = path.getBounds()
assertEquals(pill.left, b.left, 0.5f)
assertEquals(pill.right, b.right, 0.5f)
assertEquals(pill.bottom, b.bottom, 0.5f)
}
@Test fun aCardMarkIsTakenOnceAndOnlyForItsGame() {
StoresMotion.markCard(CardMark("gog:1", pill, 10f, null))
assertNull(StoresMotion.takeCard("gog:2"))
StoresMotion.markCard(CardMark("gog:1", pill, 10f, null))
assertNotNull(StoresMotion.takeCard("gog:1"))
assertNull(StoresMotion.takeCard("gog:1"))
}
}
@@ -4,15 +4,15 @@ import com.droiddeck.launcher.stores.Store
import org.junit.Assert.assertEquals
import org.junit.Test
/** Amazon has no catalog of its own: Installed and Library only, and Store falls back to Library. */
/** A store's modes are Store, Library and Installed; Amazon has no catalog of its own, so no Store. */
class StoreTabsTest {
@Test fun storeAndLibraryAmazonLibraryOnly() {
assertEquals(listOf("store", "library"), tabsFor(Store.GOG))
assertEquals(listOf("library"), tabsFor(Store.AMAZON))
@Test fun storeLibraryInstalledAmazonWithoutStore() {
assertEquals(listOf("store", "library", "installed"), tabsFor(Store.GOG))
assertEquals(listOf("library", "installed"), tabsFor(Store.AMAZON))
assertEquals("library", tabFor(Store.AMAZON, "store"))
// An old saved Installed or All tab opens Library.
// An old saved All tab opens Library; Installed is a mode again.
assertEquals("library", tabFor(Store.GOG, "all"))
assertEquals("library", tabFor(Store.EPIC, "installed"))
assertEquals("installed", tabFor(Store.EPIC, "installed"))
assertEquals("store", tabFor(Store.GOG, "store"))
}
}