feat(ui): Flathub in the stores' style, in its own teal

This commit is contained in:
xXJsonDeruloXx committed 2026-10-11 11:23:46 -04:00
1 parent 18bf767694
commit 308f53432c
6 files changed
+622 -254

No files matched your search

@@ -123,18 +123,20 @@ internal fun SideRail(
// Where each item sits in the column, for the selection that glides between them.
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, androidx.compose.ui.geometry.Rect>() }
val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal)
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
var railAt by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).onGloballyPositioned { railAt = it.boundsInRoot().topLeft }) {
// One selection for the whole rail, under the items: it glides from item to item as the
// chips' does, its leading edge first, and a jump of more than one item goes as a drop.
if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown], tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f)
if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown]?.translate(-railAt), tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
val place: (String) -> Modifier = { key ->
// In root px: an item that comes and goes sits in its own wrapper, not the column.
Modifier.onGloballyPositioned {
bounds[key] = it.boundsInParent()
bounds[key] = it.boundsInRoot()
Hops.railBounds[key] = it.boundsInRoot()
}
}
@@ -151,19 +151,28 @@ internal fun TiltedWall(
count: Int,
driftMs: Int,
modifier: Modifier = Modifier,
/** One tile's size, and the gap between tiles: portrait capsules unless a wall says otherwise. */
capW: androidx.compose.ui.unit.Dp = 112.dp,
capH: androidx.compose.ui.unit.Dp = 168.dp,
gap: androidx.compose.ui.unit.Dp = 14.dp,
capsule: @Composable (index: Int, modifier: Modifier) -> Unit,
) {
val capW = 112.dp
val capH = 168.dp
val gap = 14.dp
val transition = rememberInfiniteTransition(label = "wall")
val drift = transition.animateFloat(
0f, 1f,
infiniteRepeatable(tween(driftMs, easing = LinearEasing)),
label = "drift",
)
// The drift's phase, advanced each frame by the time since the last over the period now: the
// period can change (a wall slowing while something works) without the wall jumping.
val drift = remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
val period = androidx.compose.runtime.rememberUpdatedState(driftMs)
// Animations off in the system settings: a still wall.
val still = Motion.scale == 0f
androidx.compose.runtime.LaunchedEffect(still) {
if (still) return@LaunchedEffect
var last = 0L
while (true) {
androidx.compose.runtime.withFrameNanos { t ->
if (last != 0L) drift.floatValue = (drift.floatValue + (t - last) / 1e6f / (period.value * Motion.scale)) % 1f
last = t
}
}
}
BoxWithConstraints(modifier.fillMaxSize()) {
// Centred on the page and big enough that, once tilted, it still covers every corner.
val tilt = Math.toRadians(13.0)
@@ -186,7 +195,7 @@ internal fun TiltedWall(
Column(
verticalArrangement = Arrangement.spacedBy(gap),
modifier = Modifier.wrapContentHeight(Alignment.Top, unbounded = true).graphicsLayer {
val shift = if (still) 0f else run.toPx() * drift.value
val shift = if (still) 0f else run.toPx() * drift.floatValue
translationY = if (c % 2 == 0) -shift else shift - run.toPx()
},
) {
@@ -19,6 +19,13 @@ import com.droiddeck.launcher.stores.Store
// Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder.
/** Flathub's own accent, as each store has one, and the ink that reads on it. */
internal val FlathubTeal = Color(0xFF3FB8AF)
internal val FlathubInk = Color(0xFF04211F)
/** The Store rail section's source id, for its colours. */
internal const val FLATHUB = "flathub"
/** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */
internal class SourceColours(val fill: Color, val ink: Color, val dot: Color)
@@ -27,6 +34,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
}
@@ -1,283 +1,414 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
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.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Verified
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
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.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.store.FlathubApi
import com.droiddeck.launcher.store.StoreState
import kotlinx.coroutines.delay
// The Store rail section: Flathub's apps for this device's architecture, installed into the
// Linux runtime with Flatpak, and the ones already installed.
// Linux runtime with Flatpak, and the ones already installed - in the stores' style, in Flathub's
// teal. Until Flatpak is set up it is the stores' sign-in hero: a wall of real app icons, one word,
// one button that becomes its own progress. Then a chip row, shelves of square cards that flood
// open into an app's page, and installs that fly home to the Installed chip.
private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed)
private const val MODE_DISCOVER = "discover"
private const val MODE_INSTALLED = "installed"
private const val MODE_UPDATES = "updates"
/** One app as a card shows it: Flathub's summary or what Flatpak says is installed. */
private class CardApp(val id: String, val name: String, val icon: Any?, val summary: String? = null, val verified: Boolean = false)
/** Where the Installed chip is, for an install's dot to land in, and how many have landed. */
private object FlathubMotion {
var installedChip: Rect? = null
var landed by mutableIntStateOf(0)
}
@Composable
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
var tab by rememberSaveable { mutableStateOf(0) }
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
LaunchedEffect(Unit) { StoreState.loadSections() }
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
// a tab's contents - and would be lost with it. Each move says where focus goes next.
val ff = LocalFrontFocus.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } }
var focusMove by remember { mutableStateOf<Pair<String, Int>?>(null) }
var lastApp by remember { mutableStateOf<String?>(null) }
fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 }
fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 }
BackHandler(enabled = openApp != null) { closeApp() }
val scroll = rememberScrollState()
LaunchedEffect(openApp, tab) { scroll.scrollTo(0) }
LaunchedEffect(focusMove, openApp) {
if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
val target = when {
openApp != null -> ff?.primary
focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab]
focusMove?.first == "tab" -> tabFocus[tab]
else -> null
} ?: return@LaunchedEffect
var landed = false
focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } }
}
Column(
modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV)
.bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) },
onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }),
) {
val id = openApp
if (id != null) {
AppDetail(s, a, id) { closeApp() }
return@Column
FlathubTheme {
// The storefront takes over from the hero once Flatpak is ready: the setup rail rings
// once, then the hero sinks away and the storefront rises.
var front by remember { mutableStateOf(StoreState.ready && s.ready) }
var ring by remember { mutableIntStateOf(0) }
LaunchedEffect(StoreState.ready, s.ready) {
val ready = StoreState.ready && s.ready
if (ready && !front) {
ring++
delay(Motion.ms(450).toLong())
front = true
} else if (!ready) front = false
}
Rise(0) {
PageHeader(stringResource(R.string.store_title)) {
Chip(stringResource(R.string.store_chip_flathub), ok = false)
if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true)
}
}
if (!s.ready) {
Rise(1) { Note(stringResource(R.string.store_needs_runtime)) }
return@Column
}
if (!StoreState.ready) Rise(1) { SetupCard() }
Rise(2) {
TabStrip(
TABS.mapIndexed { i, id -> stringResource(id).let { t -> if (i == 2 && StoreState.installed.isNotEmpty()) stringResource(R.string.store_tab_count, t, StoreState.installed.size) else t } },
tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus,
)
}
BusyBar()
when (tab) {
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) })
1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it })
else -> Installed(a, onOpen = { openApp = it })
AnimatedContent(
front,
modifier = modifier,
transitionSpec = {
EnterTransition.None.togetherWith(fadeOut(Motion.tw(320)) + scaleOut(Motion.tw(320), targetScale = 0.95f))
},
label = "flathub",
) { ready ->
if (ready) Storefront(s, a) else FlathubHero(s, ring)
}
}
}
/** Flatpak is not in the runtime yet: one button puts it there. */
/**
* Flatpak is not set up yet: the stores' sign-in shape in teal. A tilted wall of the popular apps'
* icons (Flathub's public pages need no Flatpak) drifts behind, fading toward the words; one big
* button sets it up, and stretches into its own progress while it does.
*/
@Composable
private fun SetupCard() {
private fun FlathubHero(s: FrontEndState, ring: Int) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val icons = remember(StoreState.sections.values.toList()) {
StoreState.sections.values.flatten().mapNotNull { it.icon }.distinct().take(48).takeIf { it.size >= 8 }.orEmpty()
}
val busy = StoreState.busy == "setup"
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Text(stringResource(R.string.store_setup_title), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(
stringResource(R.string.store_setup_text),
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
Actions {
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) }
val tile = RoundedCornerShape(17.dp)
val ground = Color(0xFF0B2E2B)
BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp)) {
val shape = RoundedCornerShape(20.dp)
Box(Modifier.fillMaxWidth().height(maxHeight.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) {
// Slower while it sets up, as the empty wall drifts.
androidx.compose.animation.Crossfade(icons, animationSpec = Motion.tw(600), label = "flathubWall") { tiles ->
TiltedWall(tiles.size, if (tiles.isEmpty() || busy) 75_000 else 50_000, capW = 68.dp, capH = 68.dp, gap = 14.dp) { i, m ->
Box(m.clip(tile).background(ground).border(1.dp, FlathubTeal.copy(alpha = 0.18f), tile), contentAlignment = Alignment.Center) {
if (tiles.isNotEmpty()) AsyncImage(model = tiles[i], contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(42.dp))
}
}
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to colors.background.copy(alpha = 0.97f), 0.45f to colors.background.copy(alpha = 0.9f),
0.78f to colors.background.copy(alpha = 0.35f), 1f to colors.background.copy(alpha = 0.15f),
),
),
)
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.5f to Color.Transparent, 1f to colors.background.copy(alpha = 0.92f))))
Box(
Modifier.fillMaxSize().drawBehind {
drawRect(Brush.radialGradient(listOf(FlathubTeal.copy(alpha = 0.22f), Color.Transparent), center = Offset(size.width * 0.16f, size.height * 0.9f), radius = size.height * 0.75f))
},
)
BoxWithConstraints(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(start = if (narrow) 20.dp else 40.dp, bottom = if (narrow) 20.dp else 36.dp, end = 20.dp)) {
val full = maxWidth
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Rise(1) {
Text(stringResource(R.string.flathub_eyebrow), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp, color = FlathubTeal)
}
Rise(2) {
Text(
stringResource(R.string.flathub_name), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp,
fontWeight = FontWeight.Black, color = colors.onBackground,
)
}
Rise(3) { Text(stringResource(R.string.flathub_line), fontSize = 15.sp, color = colors.onSurfaceVariant) }
Spacer(Modifier.height(8.dp))
Rise(4) { SetupButton(s, busy, full, ring) { StoreState.setup(ctx) } }
}
}
}
}
}
/** The running install, update or setup, whichever page it was started from. */
/**
* Set up Flathub, and its own progress: while it sets up the button springs out to the full width
* and fills in teal from the left with how far it is, the stage and the figure inside it. Without
* a runtime it says what is missing instead.
*/
@Composable
private fun BusyBar() {
private fun SetupButton(s: FrontEndState, busy: Boolean, full: Dp, ring: Int, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val busy = StoreState.busy ?: return
val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name
?: when (busy) { "setup" -> stringResource(R.string.store_flatpak); "update-all" -> stringResource(R.string.store_updates); else -> busy.substringAfterLast('.') }
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) {
val stage = StoreState.stage
Text(
if (stage != null && StoreState.percent >= 0) stringResource(R.string.store_busy_percent, name, stage, StoreState.percent) else stringResource(R.string.store_busy, name, stage ?: stringResource(R.string.store_starting)),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 6.dp),
)
if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
@Composable
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
val density = LocalDensity.current
val ff = LocalFrontFocus.current
val inputMode = LocalInputModeManager.current
var restW by remember { mutableStateOf(0.dp) }
val grow by animateFloatAsState(if (busy) 1f else 0f, Motion.sp(0.7f, 380f), label = "setupGrow")
val fill by animateFloatAsState((StoreState.percent.coerceAtLeast(0) / 100f), Motion.tw(300), label = "setupFill")
if (!busy && grow < 0.01f) {
Box(Modifier.onSizeChanged { restW = with(density) { it.width.toDp() } }) {
PrimaryButton(
if (s.ready) stringResource(R.string.flathub_setup) else stringResource(R.string.store_needs_runtime),
enabled = s.ready && StoreState.busy == null, main = true, large = true, icon = Icons.Filled.Download, onClick = onClick,
)
}
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
Rise(4) { Note(stringResource(R.string.store_unreachable)) }
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
return
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
// A pad keeps its place on the rail that took the button's.
if (ff != null) LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusWithinFrames({ false }) { ff.primary } }
val width = androidx.compose.ui.unit.lerp(restW.coerceAtLeast(120.dp), full, grow)
val shape = RoundedCornerShape(12.dp)
val shimmer = rememberInfiniteTransition(label = "setupShimmer")
val sweep by shimmer.animateFloat(-0.3f, 1.3f, infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), label = "sweep")
val stage = StoreState.stage ?: stringResource(R.string.store_starting)
val label = if (StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.width(width).heightIn(min = 56.dp)
.then(if (ff != null) Modifier.focusRequester(ff.primary) else Modifier)
.pulseRing(ring, FlathubTeal)
.clip(shape).background(colors.surfaceVariant).border(1.dp, FlathubTeal.copy(alpha = 0.5f), shape)
.drawBehind {
if (StoreState.percent >= 0) drawRect(FlathubTeal, size = size.copy(width = size.width * fill))
else if (Motion.scale != 0f) {
val w = size.width * 0.3f
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, FlathubTeal.copy(alpha = 0.55f), Color.Transparent), startX = size.width * sweep - w, endX = size.width * sweep + w))
}
}
.focusable()
.semantics { contentDescription = label }
.padding(horizontal = 20.dp, vertical = 16.dp),
) {
Text(label, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = if (StoreState.percent >= 50) FlathubInk else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
// ---- storefront ------------------------------------------------------------------------------------
@Composable
private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) {
val view = androidx.compose.ui.platform.LocalView.current
val run = {
// The field is an Android EditText; Done leaves its keyboard over the results otherwise.
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
?.hideSoftInputFromWindow(view.windowToken, 0)
private fun Storefront(s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
val colors = MaterialTheme.colorScheme
val density = LocalDensity.current
val padH = if (narrow) 14.dp else 22.dp
var mode by rememberSaveable { mutableStateOf(MODE_DISCOVER) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(null) }
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
val updates = StoreState.updates
if (mode == MODE_UPDATES && updates.isEmpty()) mode = MODE_INSTALLED
// Once a session is plenty: the Updates chip shows itself when there is something to update.
LaunchedEffect(StoreState.ready) { if (StoreState.ready && !StoreState.updatesChecked && !StoreState.checkingUpdates) StoreState.checkUpdates(ctx) }
// A search runs a beat after typing stops, or as a category is picked.
LaunchedEffect(query, category) {
if (query.isBlank() && category == null) { StoreState.clearSearch(); return@LaunchedEffect }
delay(350)
StoreState.search(query.trim(), category)
}
Rise(3) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
AdbTextField(
value = query, onValueChange = onQuery, label = stringResource(R.string.store_search_label), keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done, placeholder = stringResource(R.string.store_search_placeholder), modifier = Modifier.weight(1f), onDone = run,
)
PrimaryButton(stringResource(R.string.store_search), main = true, onClick = run)
val ff = LocalFrontFocus.current
val inputMode = LocalInputModeManager.current
// Cards, where they are, for the page that opens from one.
val cards = remember { HashMap<String, Rect>() }
var viewportAt by remember { mutableStateOf(Offset.Zero) }
var detail by remember { mutableStateOf<String?>(null) }
var leaving by remember { mutableStateOf(false) }
var detailFrom by remember { mutableStateOf<Origin?>(null) }
var lastApp by remember { mutableStateOf<String?>(null) }
LaunchedEffect(openApp) {
val id = openApp
if (id != null) {
detailFrom = cards[id]?.let { Origin(it.translate(-viewportAt), 16f * density.density) }
leaving = false
detail = id
if (inputMode.inputMode == InputMode.Keyboard && ff != null) { delay(Motion.ms(300).toLong()); focusWithinFrames({ false }) { ff.primary } }
} else if (detail != null) {
lastApp = detail
if (detailFrom != null) { leaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) }
detail = null; leaving = false; detailFrom = null
}
}
Rise(4) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
FlathubApi.categories.forEach { c ->
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
// Back on the grid: focus goes to the card the page came from.
LaunchedEffect(detail) {
if (detail != null || inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect
val id = lastApp ?: return@LaunchedEffect
ff.items["tile:store:$id"]?.let { req -> focusWithinFrames({ false }) { req } }
}
BackHandler(enabled = openApp != null) { openApp = null }
val covering = detail != null && !leaving
val sink by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "flathubSink")
val fade by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "flathubFade")
val modes = buildList { add(MODE_DISCOVER); add(MODE_INSTALLED); if (updates.isNotEmpty()) add(MODE_UPDATES) }
Column(
Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
.bumpers(
onPrevious = { if (openApp == null) mode = modes[(modes.indexOf(mode) + modes.size - 1) % modes.size] },
onNext = { if (openApp == null) mode = modes[(modes.indexOf(mode) + 1) % modes.size] },
),
) {
Rise(0) { FlathubChips(mode, modes, StoreState.installed.size, updates.size) { mode = it } }
Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().onGloballyPositioned { viewportAt = it.positionInRoot() }) {
Column(
Modifier.fillMaxSize().graphicsLayer { scaleX = sink; scaleY = sink; alpha = fade }.verticalScroll(rememberScrollState()).padding(bottom = 16.dp),
) {
val open: (String) -> Unit = { openApp = it }
when (mode) {
MODE_DISCOVER -> Discover(query, category, { query = it }, { category = if (category == it) null else it }, cards, open)
MODE_INSTALLED -> InstalledMode(cards, open)
else -> UpdatesMode(cards, open)
}
}
detail?.let { id ->
val from = detailFrom
val icon = StoreState.details[id]?.icon ?: StoreState.sections.values.flatten().firstOrNull { it.id == id }?.icon ?: FlathubApi.iconUrl(id)
val body: @Composable () -> Unit = {
Column(
Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } }
.verticalScroll(rememberScrollState()).padding(bottom = 16.dp).focusGroup(),
) { AppDetail(s, a, id) { openApp = null } }
}
if (from == null) body()
else {
// The card's own icon, cropped over the tile as it grows: the page arrives in the app's colour.
val art = coil.compose.rememberAsyncImagePainter(
remember(icon) { coil.request.ImageRequest.Builder(ctx).data(icon).size(256, 256).build() },
)
PageFlood(from, leaving, art) { body() }
}
}
}
}
val results = StoreState.searchResults
when {
StoreState.searching -> Rise(5) { SectionTitle(stringResource(R.string.store_searching), null) }
results == null -> Rise(5) { Note(stringResource(R.string.store_search_hint)) }
results.isEmpty() -> Rise(5) { Note(stringResource(R.string.store_no_results)) }
else -> {
Rise(5) { SectionTitle(stringResource(R.string.store_results), results.size.toString()) }
Rise(6) { AppGrid(results, first = true, onOpen = onOpen) }
}
}
}
/**
* Discover, Installed (with its count) and Updates (when there are any): one selection glides under
* them in teal; the Installed count pops as an install's dot lands in it.
*/
@Composable
private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
private fun FlathubChips(mode: String, modes: List<String>, installed: Int, updates: Int, onPick: (String) -> Unit) {
val pal = LocalPalette.current
val apps = StoreState.installed
Rise(3) {
Actions {
SecondaryButton(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) {
StoreState.checkUpdates(ctx)
}
if (StoreState.updates.isNotEmpty()) PrimaryButton(stringResource(R.string.store_update_all, StoreState.updates.size), enabled = StoreState.busy == null) {
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), ok = true)
}
}
if (apps.isEmpty()) {
Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) stringResource(R.string.store_no_apps) else stringResource(R.string.store_setup_first_note)) } }
return
}
Rise(4) { SectionTitle(stringResource(R.string.store_tab_installed), apps.size.toString()) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
apps.forEachIndexed { i, app ->
key(app.id) {
Rise(5 + i.coerceAtMost(6)) {
val colors = MaterialTheme.colorScheme
val bounds = remember { mutableStateMapOf<String, Rect>() }
val tint = rememberUpdatedState(pal.signal)
Box(Modifier.fillMaxWidth()) {
ChipGlide(modes.indexOf(mode), bounds[mode], tint)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
for (m in modes) {
key(m) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
val on = m == mode
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
modifier = Modifier.weight(1f).paneItem("flathub:$m")
.onGloballyPositioned {
bounds[m] = it.boundsInParent()
if (m == MODE_INSTALLED) FlathubMotion.installedChip = it.boundsInRoot()
}
.heightIn(min = 44.dp).clip(shape)
.background(if (on) Color.Transparent else colors.surface)
.glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab) { onPick(m) }
.controllerConfirm { onPick(m) }
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44)
Column(modifier = Modifier.weight(1f)) {
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
(app.summary ?: app.id).let { if (app.id in StoreState.updates) stringResource(R.string.store_update_available_prefix, it) else it },
fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
PrimaryButton(stringResource(R.string.store_open), compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) }
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) }
SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) }
Text(
stringResource(when (m) { MODE_DISCOVER -> R.string.store_tab_discover; MODE_INSTALLED -> R.string.store_tab_installed; else -> R.string.store_updates }),
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1,
)
val count = when (m) { MODE_INSTALLED -> installed; MODE_UPDATES -> updates; else -> -1 }
if (count >= 0) Text(
count.toString(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
modifier = Modifier.then(if (m == MODE_INSTALLED) Modifier.landingPop(FlathubMotion.landed) else Modifier)
.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 6.dp, vertical = 1.dp),
)
}
}
}
@@ -285,8 +416,200 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
}
}
private fun s_busyFor(id: String) = StoreState.busy == id
/**
* The card width for a width: room for a name on two lines and a two-line summary under the art,
* so a long name wraps instead of being cut to a few letters.
*/
private fun cardSize(width: Dp): Dp = when {
width >= 700.dp -> 164.dp
width >= 480.dp -> 148.dp
else -> 132.dp
}
@Composable
private fun Discover(
query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String) -> Unit,
cards: HashMap<String, Rect>, onOpen: (String) -> Unit,
) {
Rise(1) {
ModeHeader(
stringResource(R.string.store_tab_discover), query, onQuery, stringResource(R.string.store_search_placeholder),
) {
// The categories as small pills, at the right of the header.
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.widthIn(max = 420.dp).horizontalScroll(rememberScrollState()),
) {
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id) } }
}
}
}
val results = StoreState.searchResults
if (query.isNotBlank() || category != null) {
when {
StoreState.searching && results == null -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_searching)) } }
results.isNullOrEmpty() -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_results)) } }
else -> Rise(2) { CardGrid(results.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, cards, onOpen) }
}
return
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_unreachable)) } }
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
return
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
Rise(2 + i) { Shelf(stringResource(title), apps?.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, first = i == 0, cards, onOpen) }
}
}
@Composable
private fun InstalledMode(cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
val ctx = LocalContext.current
val apps = StoreState.installed
Rise(1) {
ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_tab_installed), apps.size), "", {}, "") {
SmallLink(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = !StoreState.checkingUpdates && StoreState.busy == null) {
StoreState.checkUpdates(ctx)
}
}
}
if (apps.isEmpty()) { Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_apps)) } }; return }
Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) }
}
@Composable
private fun UpdatesMode(cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
val ctx = LocalContext.current
val apps = StoreState.installed.filter { it.id in StoreState.updates }
Rise(1) {
ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_updates), apps.size), "", {}, "") {
PrimaryButton(stringResource(R.string.store_update_all, apps.size), compact = true, enabled = StoreState.busy == null) {
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
}
}
}
Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) }
}
/** A horizontal row of cards under a title, the stores' shelf; nothing while it is empty. */
@Composable
private fun Shelf(title: String, apps: List<CardApp>?, first: Boolean, cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
Column(Modifier.fillMaxWidth()) {
Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp))
if (apps == null) Text(stringResource(R.string.store_section_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
else if (apps.isNotEmpty()) BoxWithConstraints(Modifier.fillMaxWidth()) {
val size = cardSize(maxWidth)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 2.dp)) {
apps.take(18).forEachIndexed { i, app -> key(app.id) { AppCard(app, size, first && i == 0, cards) { onOpen(app.id) } } }
}
}
}
}
/** The Installed and Updates modes, and search: a grid of the same cards. */
@Composable
private fun CardGrid(apps: List<CardApp>, cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 10.dp)) {
val size = cardSize(maxWidth)
val columns = ((maxWidth + 10.dp) / (size + 10.dp)).toInt().coerceAtLeast(1)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
apps.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEachIndexed { i, app -> key(app.id) { AppCard(app, size, r == 0 && i == 0, cards) { onOpen(app.id) } } }
}
}
}
}
}
/**
* One app: its icon over a blurred, darkened copy of itself (darkened only, before Android 12),
* with one status on the art - Get, ✓, or how far an install is - and under it the name, on two
* lines if it needs them, and what the app is.
*/
@Composable
private fun AppCard(app: CardApp, size: Dp, first: Boolean, cards: HashMap<String, Rect>, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val shape = RoundedCornerShape(16.dp)
val installed = StoreState.installed.any { it.id == app.id }
val update = app.id in StoreState.updates
val busy = StoreState.busy == app.id
val artH = size * 0.62f
Column(
Modifier.width(size).paneItem("tile:store:${app.id}").then(if (first) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = app.name },
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { cards[app.id] = it.boundsInRoot() }
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(Color(0xFF0B2E2B)),
) {
if (app.icon != null) {
// The icon's own colour, blurred and darkened, as the card's ground.
AsyncImage(
model = app.icon, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.55f }
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
AsyncImage(model = app.icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.62f))
} else Icon(Icons.Outlined.Apps, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.5f))
// One status, on the art: what A on the card leads to.
Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) {
when {
busy -> Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) {
if (StoreState.percent >= 0) CircularProgressIndicator(progress = { StoreState.percent / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp))
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
}
else -> Text(
when {
update -> stringResource(R.string.common_update)
installed -> "✓"
else -> stringResource(R.string.comp_get)
},
fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1,
color = if (installed && !update) FlathubInk else pal.onSignal,
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (installed && !update) pal.good else pal.signal)
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
}
Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
app.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground,
maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
)
if (app.verified) Icon(Icons.Outlined.Verified, stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.padding(top = 2.dp).size(13.dp))
}
// Two lines kept for the summary even when it is short, so cards in a row line up.
Text(
app.summary.orEmpty(), fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant,
minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp),
)
}
}
}
/**
* An app's page, flooded out of its card: its icon, name and what it is, one action (Install or
* Open, with Update beside it when there is one) and Website; then its screenshots and details.
* Install flies a dot to the Installed chip; Remove asks by stepping out of its button, in red.
*/
@Composable
private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) {
val ctx = LocalContext.current
@@ -296,44 +619,53 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
val d = StoreState.details[id]
val local = StoreState.installed.firstOrNull { it.id == id }
val name = d?.name ?: local?.name ?: id
Rise(0) { BackLink(stringResource(R.string.store_title), onClick = onBack) }
val busyHere = StoreState.busy == id
val installed = local != null
Rise(0) { BackLink(stringResource(R.string.flathub_name), onClick = onBack) }
Rise(1) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) {
AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72)
Column(modifier = Modifier.weight(1f)) {
Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) }
val meta = listOfNotNull(
d?.developer, d?.version?.let { "v$it" },
d?.downloadSize?.takeIf { it > 0 }?.let { stringResource(R.string.store_download_size, FileUtils.sizeToString(it)) },
).joinToString(" · ")
if (meta.isNotEmpty()) Text(meta, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
(d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) }
}
}
}
val busyHere = StoreState.busy == id
val installed = local != null
// Remove asks twice: one stray press of A should not cost a download. The second press has
// to come within a few seconds; the app's own data in ~/.var/app stays either way.
var confirmRemove by remember(id) { mutableStateOf(false) }
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
Rise(2) {
Actions {
if (installed) {
PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
PrimaryButton(if (busyHere) stringResource(R.string.common_working) else stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) }
SecondaryButton(if (busyHere) stringResource(R.string.common_working) else if (confirmRemove) stringResource(R.string.store_press_again) else stringResource(R.string.store_remove), enabled = StoreState.busy == null) {
if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true
}
} else {
PrimaryButton(
if (busyHere) stringResource(R.string.store_installing) else stringResource(R.string.store_install), main = true,
enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
) { StoreState.install(ctx, id, name) }
if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
PrimaryButton(
if (busyHere) (if (StoreState.percent >= 0) "${StoreState.stage ?: stringResource(R.string.store_installing)} · ${StoreState.percent}%" else stringResource(R.string.store_installing))
else stringResource(R.string.store_install),
main = true,
enabled = StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
) {
StoreState.install(ctx, id, name)
// The button squashes into a dot that flies to the Installed chip, whose count pops.
val from = at
if (from != null) Flights.fly(Flight(from, to = { FlathubMotion.installedChip?.center }, onLand = { FlathubMotion.landed++ }))
else FlathubMotion.landed++
}
}
}
d?.version?.let { ActionChip("v$it", ok = false) }
if (d != null && d.downloadSize > 0) ActionChip(stringResource(R.string.store_download_size, FileUtils.sizeToString(d.downloadSize)), ok = false)
if (installed) ActionChip(stringResource(R.string.store_installed_chip), ok = true)
d?.homepage?.let { url ->
SecondaryButton(stringResource(R.string.store_website)) {
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))) }
}
}
if (installed) RemoveButton(id, name, enabled = StoreState.busy == null)
}
}
if (busyHere) BusyBar()
if (d == null) {
Rise(3) {
Box(Modifier.padding(top = 14.dp)) {
@@ -379,52 +711,45 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
}
}
/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */
/** Remove, which asks first: are you sure steps out of it in red, Keep first, so a stray A costs nothing. */
@Composable
private fun AppGrid(apps: List<FlathubApi.AppSummary>, first: Boolean, onOpen: (String) -> Unit) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
apps.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, app ->
key(app.id) {
AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) }
}
private fun RemoveButton(id: String, name: String, enabled: Boolean) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.store_remove)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 3,
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(ctx.getString(R.string.flathub_remove_title, name))
Text(ctx.getString(R.string.flathub_remove_note), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(2)) {
StepChoice(ctx.getString(R.string.flathub_keep), enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); StoreState.uninstall(ctx, id, name) }
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
})
}
}
}
/** A small text action at the right of a header. */
@Composable
private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
private fun SmallLink(text: String, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale")
val installed = StoreState.installed.any { it.id == app.id }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(app.icon, app.name, 44)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.size(14.dp))
}
Text(if (installed) stringResource(R.string.store_tab_installed) else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
val hot = rememberHot(src) && enabled
Text(
text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (!enabled) colors.onSurfaceVariant.copy(alpha = 0.5f) else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.Button, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick).padding(horizontal = 8.dp, vertical = 12.dp),
)
}
/** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */
@@ -446,13 +771,13 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Text(
label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.paneItem("pill:$label").clip(shape)
modifier = Modifier.paneItem("pill:$label").heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
.background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 9.dp),
.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
@@ -887,7 +887,7 @@ private fun Storefront(
* search field across the row, and [trailing] (the account) at the right, over a rule.
*/
@Composable
private fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// Open while it holds a query; an empty field closes again once focus leaves it.
@@ -943,6 +943,22 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
)
}
/** The Store section's accent: Flathub's teal, with its dark ink on it. */
internal fun flathubPalette(base: Palette): Palette = Palette(
base.id, base.label, base.detail, base.background, base.surface, base.surfaceVariant, base.line, base.line2,
base.onBackground, base.onSurfaceVariant, primary = FlathubTeal, primary2 = Color(0xFF2E958D), onPrimary = FlathubInk, signal = FlathubTeal,
good = base.good, error = base.error,
)
/** [content] with the focus ring, buttons, step-outs and the chip glide in Flathub's teal. */
@Composable
internal fun FlathubTheme(content: @Composable () -> Unit) {
val base = LocalPalette.current
val p = remember(base) { flathubPalette(base) }
val scheme = MaterialTheme.colorScheme.copy(primary = p.primary, onPrimary = p.onPrimary)
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, typography = MaterialTheme.typography, content = content) }
}
/** [content] with the focus ring, buttons and step-outs in [store]'s colour. */
@Composable
internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) {
@@ -1002,7 +1018,7 @@ private fun SignOutButton(store: Store, name: String) {
/** The header's magnifier: a round button the search field opens out of. */
@Composable
private fun SearchIcon(description: String, onClick: () -> Unit) {
internal fun SearchIcon(description: String, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
+8
View File
@@ -2209,4 +2209,12 @@
<string name="setup_group_device">This device</string>
<string name="setup_group_rail">Sections on the rail</string>
<string name="setup_group_art">Library art</string>
<!-- Flathub, in the stores' style. -->
<string name="flathub_eyebrow">LINUX APPS</string>
<string name="flathub_name">Flathub</string>
<string name="flathub_line">Desktop apps for the Linux desktop, opened from Desktop.</string>
<string name="flathub_setup">Set up Flathub</string>
<string name="flathub_remove_title">Remove %1$s?</string>
<string name="flathub_remove_note">Its settings and files are kept.</string>
<string name="flathub_keep">Keep</string>
</resources>