mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef37b16c8 | ||
|
|
bed4c1040b | ||
|
|
3f45835ed6 | ||
|
|
29abf954ce | ||
|
|
fa3037de68 | ||
|
|
9419f1efda | ||
|
|
d73c4e50c2 | ||
|
|
bcaeb27780 | ||
|
|
fc515aa0e4 | ||
|
|
f164af81e7 | ||
|
|
90af0b9c0c | ||
|
|
67cf1cdd9c | ||
|
|
571751f15c | ||
|
|
768ed0c505 | ||
|
|
9f94140bbf |
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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user