From 4b28b4f044d26436459c337fe20a177a4281a542 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 16:50:38 +0100 Subject: [PATCH] Match Discover captions and catalogue collection states --- .../tv/ConductarrDiscoverOperations.kt | 24 +++++- wolfskin-design/discover-tokens.json | 47 ++++++++++ .../justinvisser/wolfskin/design/Caption.kt | 31 +++++-- .../wolfskin/design/DiscoverCollection.kt | 86 +++++++++++++++++++ .../design/DiscoverCollectionState.kt | 63 ++++++++++++++ .../wolfskin/design/DiscoverFixture.kt | 14 +-- .../wolfskin/design/DiscoverModel.kt | 2 + .../wolfskin/design/DiscoverOverlay.kt | 16 ++-- .../wolfskin/design/DiscoverScreen.kt | 27 ++++-- .../wolfskin/design/DiscoverShelf.kt | 45 +++++----- .../wolfskin/design/DiscoverState.kt | 42 ++++++--- .../design/DiscoverTitleDestination.kt | 8 +- .../org/justinvisser/wolfskin/design/Glyph.kt | 7 ++ .../wolfskin/design/WolfskinTokens.kt | 7 ++ .../src/main/res/drawable/wolfskin_check.xml | 4 + .../src/main/res/drawable/wolfskin_clock.xml | 5 ++ .../src/main/res/drawable/wolfskin_down.xml | 4 + .../src/main/res/drawable/wolfskin_hide.xml | 4 + .../src/main/res/drawable/wolfskin_left.xml | 4 + .../src/main/res/drawable/wolfskin_plus.xml | 4 + .../src/main/res/drawable/wolfskin_undo.xml | 5 ++ .../wolfskin/design/DiscoverCaptureTest.kt | 56 +++++++++++- .../wolfskin/design/DiscoverKeysTest.kt | 43 ++++++++++ wolfskin-design/tokens.css | 6 ++ wolfskin-design/tokens.sha256 | 6 +- 25 files changed, 486 insertions(+), 74 deletions(-) create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollectionState.kt create mode 100644 wolfskin-design/src/main/res/drawable/wolfskin_check.xml create mode 100644 wolfskin-design/src/main/res/drawable/wolfskin_clock.xml create mode 100644 wolfskin-design/src/main/res/drawable/wolfskin_down.xml create mode 100644 wolfskin-design/src/main/res/drawable/wolfskin_hide.xml create mode 100644 wolfskin-design/src/main/res/drawable/wolfskin_left.xml create mode 100644 wolfskin-design/src/main/res/drawable/wolfskin_plus.xml create mode 100644 wolfskin-design/src/main/res/drawable/wolfskin_undo.xml diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt index 697e573b..0e8556d8 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt @@ -44,11 +44,26 @@ class ConductarrDiscoverOperations( } } - override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = client.mutate(TvRoutes.hide(profile, - RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV, - title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) is MutationResult.Success + override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { + val result = client.mutate(TvRoutes.hide(profile, + RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV, + title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) + return result is MutationResult.Success || observedHidden(profile, title) == true + } - override suspend fun restore(profile: String, title: DiscoverTitle): Boolean = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType())) is MutationResult.Success + override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { + val result = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType())) + return result is MutationResult.Success || observedHidden(profile, title) == false + } + + private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? { + return when (val result = client.read(TvRoutes.dismissals(profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { + is ReadResult.Value -> result.saved.value?.dismissals?.any { + it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name + } + is ReadResult.Failure -> null + } + } override suspend fun refresh(): DiscoverSnapshot = refreshSnapshot() } @@ -56,4 +71,5 @@ private fun DiscoverTitle.mediaType(): MediaType = if (type == "movie") MediaTyp private fun RequestReceipt.outcome(): DiscoverRequestResult = DiscoverRequestResult( phase = if (state == RequestReceipt.State.PENDING) RequestPhase.Pending else if (state == RequestReceipt.State.RETIRED) RequestPhase.Refused else RequestPhase.Receipt, actor = actor.name, + requester = requester, ) diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index addb4fd0..a1a49947 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -239,6 +239,53 @@ "px": 24, "dp": 12, "source": "locked index.html:202" + }, + "captionFactsGap": { + "$type": "dimension", + "px": 18, + "dp": 9, + "source": "locked index.html:154" + }, + "captionSeparatorOpacity": { + "$type": "number", + "$value": 0.4, + "source": "locked index.html:1207" + }, + "badgeRing": { + "$type": "dimension", + "px": 3, + "dp": 1.5, + "source": "locked index.html:144" + }, + "badgeIcon": { + "$type": "dimension", + "px": 28, + "dp": 14, + "source": "locked index.html:142" + }, + "entryPaddingX": { + "$type": "dimension", + "px": 22, + "dp": 11, + "source": "locked index.html:149" + }, + "entryPaddingY": { + "$type": "dimension", + "px": 20, + "dp": 10, + "source": "locked index.html:149" + }, + "entryText": { + "$type": "typography", + "px": 30, + "sp": 15, + "weight": 800, + "letterSpacingEm": -0.01, + "$value": { + "fontSize": "15sp", + "fontWeight": 800 + }, + "source": "locked index.html:149" } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 0de442ff..77f4a916 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -1,21 +1,38 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text +/** Accent facts identify availability without colouring the intervening separators. */ +data class CaptionFact(val text: String, val accent: Boolean = false) + /** Captions fade without moving the reserved band under the focused poster. */ @Composable -fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { - val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade") - Column(modifier.graphicsLayer { this.alpha = alpha }) { - Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1) - Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1) +fun Caption(title: String, facts: List, visible: Boolean, modifier: Modifier = Modifier) { + val alpha = remember { Animatable(0f) } + LaunchedEffect(visible, title) { + alpha.snapTo(0f) + if (visible) alpha.animateTo(1f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) + } + Column(modifier.graphicsLayer { this.alpha = alpha.value }) { + Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Discover.captionFactsGap)) { + facts.forEachIndexed { index, fact -> + if (index > 0) Text("·", color = WolfskinTokens.Color.muted.copy(alpha = WolfskinTokens.Color.muted.alpha * WolfskinTokens.Discover.captionSeparatorOpacity), style = WolfskinTokens.Type.caption, maxLines = 1) + Text(fact.text, color = if (fact.accent) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1) + } + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt new file mode 100644 index 00000000..9242408c --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollection.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Catalogue See all shares poster anatomy with shelves and never introduces a page selector. */ +@Composable +fun DiscoverCollection(state: DiscoverState, collection: DiscoverCollectionState, modifier: Modifier = Modifier) { + val list = state.shell.view.listState + val density = LocalDensity.current + val headerRows = if (collection.hasChips) 2 else 1 + LaunchedEffect(collection.row, collection.chipsFocused, state.shell.navFocused) { + if (!state.shell.navFocused) { + if (collection.row == 0 || collection.chipsFocused) list.animateScrollToItem(0) + else list.animateScrollToItem(collection.row + headerRows, -with(density) { L.pinnedLine.roundToPx() }) + } + } + LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = modifier.fillMaxSize()) { + item(key = "collection-header", contentType = "header") { + Row(Modifier.fillMaxWidth().height(L.row_lhead).padding(start = L.safeSide, top = D.headerTop), horizontalArrangement = Arrangement.spacedBy(D.headerGap), verticalAlignment = Alignment.CenterVertically) { + Avatar(state.shell.person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader) + Text(collection.lane.title, color = C.fg, style = WolfskinTokens.Type.screenTitle) + Text("${collection.items.size} titles", color = C.muted, style = WolfskinTokens.Type.nav) + } + } + if (collection.hasChips) item(key = "collection-chips", contentType = "chips") { + Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { + listOf("All", "Movies", "Series").forEachIndexed { index, label -> + val focused = collection.chipsFocused && collection.chip == index && state.contentFocused + val selected = collection.filter == listOf("all", "movie", "tv")[index] + Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip)) + .border(if (!focused && selected) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && selected) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip)) + .clickable { collection.chipsFocused = true; collection.chip = index; collection.key(ShellKey.Ok, true) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { + Text(label, color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label) + } + } + } + } + items(collection.rowCount, key = { "grid-$it" }, contentType = { "poster" }) { row -> + val offset = row * WolfskinTokens.Behaviour.gridColumns.toInt() + Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { + Row(Modifier.offset(x = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + if (offset >= collection.items.size) DiscoverTailCard(collection.lane, state.contentFocused && collection.row == row) { collection.focus(row, 0); state.refresh() } + else collection.items.subList(offset, minOf(offset + 6, collection.items.size)).forEachIndexed { column, item -> + DiscoverPoster(state, item, state.contentFocused && !collection.chipsFocused && collection.row == row && collection.column == column, false, + Modifier.clickable { collection.focus(row, column); state.openTitle(item) }) + } + } + if (state.contentFocused && !collection.chipsFocused && collection.row == row) { + val item = collection.items.getOrNull(collection.index) + Caption( + item?.title ?: if (collection.lane.tail == DiscoverTail.Retry) "Try again" else "Show more", + if (item != null) captionFacts(item, state, false) else listOf(CaptionFact("The last read only repeated titles")), + true, Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(D.captionWidth), + ) + } + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollectionState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollectionState.kt new file mode 100644 index 00000000..746419c6 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCollectionState.kt @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** See all retains a continuous six-column grid and its exact origin beneath the title stack. */ +class DiscoverCollectionState( + val shell: ShellState, + val lane: DiscoverLane, + private val openTitle: (DiscoverTitle) -> Unit, + private val refresh: () -> Unit, + private val pop: () -> Unit, +) { + var filter by mutableStateOf("all") + var chipsFocused by mutableStateOf(false) + var chip by mutableStateOf(0) + private var armed: Int? = null + private val filteredItems = derivedStateOf { if (filter == "all") lane.fullTitles else lane.fullTitles.filter { it.type == filter } } + val items: List get() = filteredItems.value + val hasChips: Boolean = lane.fullTitles.any { it.type == "movie" } && lane.fullTitles.any { it.type == "tv" } + val row: Int get() = shell.view.row + val column: Int get() = shell.view.cards["grid-$row"] ?: 0 + val index: Int get() = row * 6 + column + val hasTail: Boolean get() = lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore) + val rowCount: Int get() = (items.size + 5) / 6 + if (hasTail) 1 else 0 + + fun focus(row: Int, column: Int) { shell.navFocused = false; chipsFocused = false; shell.view.row = row; shell.view.cards["grid-$row"] = column } + + fun key(key: ShellKey, down: Boolean): Boolean { + if (key == ShellKey.Ok && !chipsFocused) { + if (down) armed = index else { + if (armed == index) { val item = items.getOrNull(index); if (item != null) openTitle(item) else if (hasTail) refresh() } + armed = null + } + return true + } + if (!down) return true + armed = null + if (key == ShellKey.Back) { pop(); return true } + if (chipsFocused) { + when (key) { + ShellKey.Up -> shell.focusNav(shell.currentTab) + ShellKey.Down -> chipsFocused = false + ShellKey.Left -> chip = (chip - 1).coerceAtLeast(0) + ShellKey.Right -> chip = (chip + 1).coerceAtMost(2) + ShellKey.Ok -> { filter = listOf("all", "movie", "tv")[chip]; focus(0, 0); chipsFocused = true } + else -> Unit + } + } else when (key) { + ShellKey.Up -> if (row > 0) focus(row - 1, column.coerceAtMost(rowSize(row - 1) - 1)) else if (hasChips) chipsFocused = true else shell.focusNav(shell.currentTab) + ShellKey.Down -> if (row < rowCount - 1) focus(row + 1, column.coerceAtMost(rowSize(row + 1) - 1)) + ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0)) + ShellKey.Right -> focus(row, (column + 1).coerceAtMost(rowSize(row) - 1)) + else -> Unit + } + return true + } + + private fun rowSize(row: Int): Int = if (hasTail && row == rowCount - 1) 1 else (items.size - row * 6).coerceIn(1, 6) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt index 11cb734e..7c85ee4f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt @@ -22,17 +22,18 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = { .filter { it.optBoolean("played") } val groups = linkedMapOf>>() picks.sortedBy { if (it.type == "movie") 0 else 1 }.forEach { pick -> - played.map { it to it.getJSONArray("genres").strings().intersect(pick.genres.toSet()).size } + played.map { it to normalizedGenres(it.getJSONArray("genres").strings()).intersect(normalizedGenres(pick.genres)).size } .filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) -> groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick) } } val lanes = services.mapIndexed { index, service -> - DiscoverLane("new-$index", "New on ${service.name}", rotate(pool, index * 7).take(12).sortedByDescending { it.year }) - } + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size)) + + val source = rotate(pool, index * 7).sortedByDescending { it.year } + DiscoverLane("new-$index", "New on ${service.name}", source.take(12), fullTitles = source) + } + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size, fullTitles = pool)) + listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").map { genre -> - val items = pool.filter { genre in it.genres && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } - DiscoverLane("genre-$genre", genre, items.take(20), items.size) + val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } + DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items) } return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready) } @@ -70,6 +71,9 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = { private fun identity(json: JSONObject): String = "${json.getString("type")}:${json.optLong("tmdbId", json.getString("id").hashCode().toLong().let { if (it < 0) -it else it })}" private fun rotate(items: List, offset: Int): List = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size) + private fun normalizedGenres(genres: List): Set = genres.flatMap { + when (it) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(it) } + }.toSet() } private fun JSONArray.objects(): List = List(length()) { getJSONObject(it) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt index a50677b5..f95c10d4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt @@ -55,6 +55,7 @@ data class DiscoverLane( val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None, val pick: Boolean = false, val hidden: Boolean = false, + val fullTitles: List = titles, ) /** Caller identity is deliberately absent: the authenticated client supplies the actor. */ @@ -65,6 +66,7 @@ data class DiscoverRequestResult( val phase: RequestPhase, val actor: String? = null, val stage: String = "Waiting", + val requester: String? = null, ) enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index c74ac981..323bb51f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -39,7 +39,6 @@ fun DiscoverOverlay(state: DiscoverState) { when (overlay) { is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) is DiscoverOverlayState.Hidden -> { - Text("Hidden from your picks", color = C.fg, style = WolfskinTokens.Type.sheetTitle) ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) } @@ -71,15 +70,12 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = WolfskinTokens.Type.body) if (state.isSignedIn) DiscoverRatings(sheet.title.ratings) when (sheet.phase) { - RequestPhase.Choose -> RequestChoices(state, sheet) + RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.LoadingRecipients -> { Text("For", color = C.dim, style = WolfskinTokens.Type.label) repeat(3) { Box(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))) } DiscoverButton("Close", true) { state.overlay = null } } - RequestPhase.Sending -> { - ReceiptPanel("Request", "Checking the request.") - } else -> RequestOutcome(state, sheet) } } @@ -143,8 +139,14 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { @Composable private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui.graphics.Color = C.fg, stage: String? = null) { Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) { - Text(title, color = color, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold)) + Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { + Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon)) + Text(title, color = color, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold)) + } Text(text, color = C.muted, style = WolfskinTokens.Type.body) - if (stage != null) Text(stage, color = C.muted, style = WolfskinTokens.Type.label) + if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(L.navBlobAvatarPad), verticalAlignment = Alignment.CenterVertically) { + Glyph("clock", C.muted, Modifier.size(L.buttonIcon)) + Text(stage, color = C.muted, style = WolfskinTokens.Type.label) + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index ef02f2ea..6ab0fa08 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -38,13 +39,22 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L @Composable fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { val shell = state.shell + val input = remember(state) { state::key } DisposableEffect(state) { - shell.contentInput = state::key - onDispose { shell.contentInput = null; shell.contentOverlayVisible = false } + shell.contentInput = input + onDispose { if (shell.contentInput === input) { shell.contentInput = null; shell.contentOverlayVisible = false } } } - LaunchedEffect(state.overlay) { shell.contentOverlayVisible = state.overlay != null } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } + val focusedTitle = if (state.collection != null && state.title == null) state.collection?.let { it.items.getOrNull(it.index) } else state.focusedLane?.titles?.getOrNull(state.column) + LaunchedEffect(focusedTitle?.key, state.contentFocused) { + state.plateKey = null + if (state.contentFocused && focusedTitle != null) { + if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) + state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key + } + } if (state.title != null) { DiscoverTitleDestination(state, modifier); return } + state.collection?.let { DiscoverCollection(state, it, modifier); return } val density = LocalDensity.current val list = shell.view.listState LaunchedEffect(state.focusedRow, shell.navFocused) { @@ -52,14 +62,14 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { val row = state.focusedRow val alreadyFits = state.service != null && row <= 2 if (row <= 1 || alreadyFits) list.animateScrollToItem(0) - else list.animateScrollToItem(row, -with(density) { L.pinnedLine.roundToPx() }) + else list.animateScrollToItem(row + if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) 1 else 0, -with(density) { L.pinnedLine.roundToPx() }) } } Box(modifier.fillMaxSize().testTag("discover-screen")) { if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.offset(y = -with(density) { list.firstVisibleItemScrollOffset.toDp() })) } LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = Modifier.fillMaxSize()) { item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") { - if (state.hasHero && state.service == null) DiscoverHero(state) else DiscoverHeader(state.service?.name ?: "Discover", if (state.service != null) "General interest" else "") + if (state.hasHero && state.service == null) DiscoverHero(state) else DiscoverHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "") } item(key = "discover-chips", contentType = "chips") { if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state) @@ -77,8 +87,9 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { /** Header and type chips use the same safe area as the shell. */ @Composable -private fun DiscoverHeader(title: String, subtitle: String) { +private fun DiscoverHeader(person: ShellPerson, title: String, subtitle: String) { Row(Modifier.fillMaxWidth().height(L.row_lhead).padding(start = L.safeSide, top = D.headerTop), horizontalArrangement = Arrangement.spacedBy(D.headerGap), verticalAlignment = Alignment.CenterVertically) { + Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader) Text(title, color = C.fg, style = WolfskinTokens.Type.screenTitle) if (subtitle.isNotEmpty()) Text(subtitle, color = C.muted, style = WolfskinTokens.Type.nav) } @@ -107,9 +118,9 @@ private fun DiscoverHero(state: DiscoverState) { Row(verticalAlignment = Alignment.CenterVertically) { DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) { - Text(if (state.contentFocused && state.focusedRow == 0) "‹" else "", Modifier.width(D.dotChevron), color = C.fg, style = WolfskinTokens.Type.sheetTitle) + Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) } - Text(if (state.contentFocused && state.focusedRow == 0) "›" else "", Modifier.width(D.dotChevron), color = C.fg, style = WolfskinTokens.Type.sheetTitle) + Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 8778c9b6..50f31ed3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -60,8 +60,9 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) - .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.Center) { - Text(service.name, style = WolfskinTokens.Type.listTitle, color = C.fg) + .border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)) + .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { + Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = D.entryText, color = C.fg) } } else { itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> @@ -77,10 +78,11 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } if (!services && active) { val item = lane.titles.getOrNull(column) - Column(Modifier.offset(L.safeSide, L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth)) { - Text(item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(if (item != null) captionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1) - } + Caption( + item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", + if (item != null) captionFacts(item, state, lane.hidden) else listOf(CaptionFact(if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles")), + true, Modifier.offset(L.safeSide, L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth), + ) } } } @@ -88,11 +90,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { /** Only the focused poster reads the ratings plate; cached observations paint immediately. */ @Composable fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, hidden: Boolean, modifier: Modifier = Modifier) { - var plate by remember(title.key) { mutableStateOf(false) } - val observed = remember(state) { mutableSetOf() } - LaunchedEffect(focused, title.key) { - if (!focused) plate = false else { if (title.key !in observed) delay(WolfskinTokens.Motion.ratingDwell.toLong()); observed.add(title.key); plate = true } - } + val plate = focused && state.plateKey == title.key Box(modifier.size(K.posterW, K.posterH).zIndex(if (focused) 1f else 0f) .focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)) { @@ -108,8 +106,8 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, if (state.shell.person.key in title.heldBy || result != null) { Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge) .background(if (result == null) C.ok else C.badgeRest, CircleShape) - .border(WolfskinTokens.Focus.ringCardRing, if (result?.stage == "Failed") C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) { - Text(if (result == null) "✓" else if (result.stage == "Failed") "!" else "◷", color = if (result == null) C.onFocus else if (result.stage == "Failed") C.fail else C.accent, style = WolfskinTokens.Type.button) + .border(if (result == null) WolfskinTokens.Shell.zero else D.badgeRing, if (result?.stage == "Failed") C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) { + Glyph(if (result == null) "check" else if (result.stage == "Failed") "alert" else "clock", if (result == null) C.onFocus else if (result.stage == "Failed") C.fail else C.accent, Modifier.size(D.badgeIcon)) } } } @@ -117,7 +115,7 @@ fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, } @Composable -private fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) { +internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) { Column(Modifier.size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)).clickable(onClick = onClick).padding(L.buttonGap), @@ -128,11 +126,14 @@ private fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () - } } -private fun captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String = buildList { - if (hidden) add("Hidden from your picks") - title.year?.let { add(it.toString()) } - add(if (title.type == "tv") title.seasonCount?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series" else title.runtimeSeconds?.let { "${it / 3600}h ${(it / 60) % 60}m" } ?: "Movie") - if (state.shell.person.key in title.heldBy) add("In your library") - else if (state.requests.containsKey(title.key)) add("Requested for you · ${state.requests.getValue(title.key).stage}") - else title.heldBy.firstOrNull()?.let { key -> add("In ${state.shell.people.firstOrNull { it.key == key }?.name ?: key}’s library") } -}.joinToString(" · ") +internal fun captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List = buildList { + if (hidden) add(CaptionFact("Hidden from your picks", true)) + title.year?.let { add(CaptionFact(it.toString())) } + add(CaptionFact(if (title.type == "tv") title.seasonCount?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series" else title.runtimeSeconds?.let { "${it / 3600}h ${(it / 60) % 60}m" } ?: "Movie")) + if (state.shell.person.key in title.heldBy) add(CaptionFact("In your library", true)) + else if (state.requests.containsKey(title.key)) { + val request = state.requests.getValue(title.key) + val recipient = if (request.requester == state.shell.person.key) "you" else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty() + add(CaptionFact("Requested for $recipient · ${request.stage}", true)) + } else title.heldBy.firstOrNull()?.let { key -> add(CaptionFact("In ${state.shell.people.firstOrNull { it.key == key }?.name ?: key}’s library", true)) } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 8e5680ae..71c30ed2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -27,12 +27,15 @@ class DiscoverState( var slide by mutableStateOf(0) var title by mutableStateOf(null) var service by mutableStateOf(null) + var collection by mutableStateOf(null) var titleButton by mutableStateOf(0) private var activeOverlay by mutableStateOf(null) var overlay: DiscoverOverlayState? get() = activeOverlay set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null } var toast by mutableStateOf(null) + val observedRatings = mutableSetOf() + var plateKey by mutableStateOf(null) val requests = mutableStateMapOf() private val hidden = mutableStateMapOf().apply { initial.hidden.forEach { put(it.key, it) } } private var armedCard: String? = null @@ -85,7 +88,12 @@ class DiscoverState( title = item; titleButton = 0; armedCard = null onPush("discover-title/${item.key}") } + fun openCollection(lane: DiscoverLane) { + collection = DiscoverCollectionState(shell, lane, ::openTitle, ::refresh) { collection = null; onPop() } + onPush("discover-see-all/${lane.id}") + } fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden + fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item) fun openService(item: DiscoverService) { rootFilter = filter; service = item; filter = "movie"; revision++ @@ -94,8 +102,8 @@ class DiscoverState( } fun enterRoute(route: String) { - if (route == "discover") { title = null; if (service != null) filter = rootFilter; service = null } - else if (route.startsWith("discover-service")) title = null + if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null } + else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null } /** The shell consumes repeat events before this handler; rows activate only on key-up. */ @@ -105,6 +113,7 @@ class DiscoverState( return true } if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false + if (title == null) collection?.let { return it.key(key, down) } if (isCard && key == ShellKey.Ok) { val identity = "$rowKey:$column" if (down) armedCard = identity else { @@ -152,7 +161,7 @@ class DiscoverState( val item = lane.titles.getOrNull(column) if (item == null) { if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh() - else { onPush("discover-see-all/${lane.id}") } + else openCollection(lane) } else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item) else openTitle(item) } @@ -161,9 +170,9 @@ class DiscoverState( when (key) { ShellKey.Up -> shell.focusNav(shell.currentTab) ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0) - ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(2) + ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1) ShellKey.Ok -> when (titleButton) { - 0 -> if (requests.containsKey(requireNotNull(title).key)) shell.requestsVisible = true else openRequest(false) + 0 -> if (requests[requireNotNull(title).key]?.requester == shell.person.key) shell.requestsVisible = true else openRequest(false) 1 -> openRequest(true) 2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) } } @@ -178,7 +187,13 @@ class DiscoverState( private fun overlayKey(key: ShellKey) { when (val sheet = overlay) { - is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }, ::refresh) + is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) { + scope.launch { + snapshot = operations.refresh(); revision++ + sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable + sheet.state.focus = sheet.state.buttonIndex + } + } is DiscoverOverlayState.Hidden -> when (key) { ShellKey.Left, ShellKey.Up -> sheet.focus = 0 ShellKey.Right, ShellKey.Down -> sheet.focus = 1 @@ -200,7 +215,7 @@ class DiscoverState( sheet.phase = RequestPhase.Sending scope.launch { val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, shell.person.key) - val result = if (check) operations.check(intent) else operations.request(intent) + val result = (if (check) operations.check(intent) else operations.request(intent)).copy(requester = intent.requester) sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex if (result.phase == RequestPhase.Receipt) requests[sheet.title.key] = result } @@ -238,17 +253,20 @@ class DiscoverState( val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden } val count = all.count { it.key !in seen } val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt())) - if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true) + if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all) } val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt()) - groups.forEachIndexed { index, seed -> val items = take(seed.items, size); if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true) } + groups.forEachIndexed { index, seed -> val items = take(seed.items, size); if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items) } } if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList()) snapshot.lanes.forEach { lane -> - val items = lane.titles.filter { matches(it) && it.key !in seen && it.key !in hidden } - if (items.isNotEmpty() || lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) result += lane.copy(titles = items) + val source = lane.fullTitles.filter { matches(it) && it.key !in seen && it.key !in hidden } + val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt()) + if (items.isNotEmpty() || lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) + result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size, + tail = if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None) } - val hiddenItems = hidden.values.filter(::matches) + val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) } if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true) return result } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt index 50af9783..fa4b7246 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt @@ -35,12 +35,12 @@ fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3) Spacer(Modifier.height(D.heroOverviewGap)) Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - val receipt = state.requests[title.key] - DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0) { + val receipt = state.requests[title.key]?.takeIf { it.requester == state.shell.person.key } + DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") { state.titleButton = 0; state.key(ShellKey.Ok, true) } - DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, primary = false) { state.openRequest(true) } - DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, primary = false) { state.titleButton = 2; state.key(ShellKey.Ok, true) } + DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) } + if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok, true) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt index 82fd5a45..1d6b5772 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt @@ -20,6 +20,13 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { "seerr" -> R.drawable.wolfskin_sparkle "advanced" -> R.drawable.wolfskin_sort "alert" -> R.drawable.wolfskin_alert + "plus" -> R.drawable.wolfskin_plus + "check" -> R.drawable.wolfskin_check + "clock" -> R.drawable.wolfskin_clock + "hide" -> R.drawable.wolfskin_hide + "undo" -> R.drawable.wolfskin_undo + "left" -> R.drawable.wolfskin_left + "down" -> R.drawable.wolfskin_down else -> R.drawable.wolfskin_right } Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index b791d602..7553cf9c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -321,5 +321,12 @@ object WolfskinTokens { val noteTop = 7.0.dp val headerTop = 75.0.dp val headerGap = 12.0.dp + val captionFactsGap = 9.0.dp + const val captionSeparatorOpacity = 0.4f + val badgeRing = 1.5.dp + val badgeIcon = 14.0.dp + val entryPaddingX = 11.0.dp + val entryPaddingY = 10.0.dp + val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em) } } diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_check.xml b/wolfskin-design/src/main/res/drawable/wolfskin_check.xml new file mode 100644 index 00000000..948e1e20 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_check.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml b/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml new file mode 100644 index 00000000..be066ab9 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_down.xml b/wolfskin-design/src/main/res/drawable/wolfskin_down.xml new file mode 100644 index 00000000..e55cd1cb --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_down.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml b/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml new file mode 100644 index 00000000..fe20e18d --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_left.xml b/wolfskin-design/src/main/res/drawable/wolfskin_left.xml new file mode 100644 index 00000000..62060544 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_left.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml b/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml new file mode 100644 index 00000000..dc780c85 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml b/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml new file mode 100644 index 00000000..a600fb81 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index 21fc2966..35faea93 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -40,9 +40,9 @@ class DiscoverCaptureTest { rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY } - private fun capture(name: String, person: String = "justin", configure: (DiscoverState) -> Unit = {}) { + private fun capture(name: String, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) { val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination("discover") } - val snapshot = fixture.snapshot(person) + val snapshot = snapshotTransform(fixture.snapshot(person)) val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate), onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service) configure(state) @@ -61,6 +61,58 @@ class DiscoverCaptureTest { } @Test fun st029_discover() = capture("st-029") + @Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) } + @Test fun st031_hidden() = capture("st-031") { hidden(it) } + @Test fun st032_restore() = capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) } + @Test fun st033_failedBadge() = capture("st-033") { state -> + val lane = state.lanes.first { it.id == "pk-movie" } + state.requests[lane.titles[1].key] = DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed") + state.focus(state.lanes.indexOf(lane) + 2, 1) + } + @Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot -> + snapshot.copy(lanes = snapshot.lanes.map { if (it.id == "trending") it.copy(tail = DiscoverTail.Retry) else it }) + }) { state -> val lane = state.lanes.first { it.id == "trending" }; state.focus(state.lanes.indexOf(lane) + 2, lane.titles.size) } + @Test fun st035_duplicateTail() = capture("st-035") { state -> + val titles = state.snapshot.lanes.first { it.id == "trending" }.fullTitles.take(36) + state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore)) + state.collection!!.focus(6, 0) + } + @Test fun st036_needsSignIn() = capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) }) + @Test fun st037_series() = capture("st-037", person = "mindel") { it.filter = "tv" } + @Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) }) + @Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) } + @Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) } + @Test fun st041_unreachable() = capture("st-041", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.Unavailable) }) + @Test fun st058_hiddenReceipt() = capture("st-058") { state -> state.openTitle(state.snapshot.picks.first()); state.titleButton = 2; state.hide(state.title!!) } + @Test fun st059_hiddenTitle() = capture("st-059") { state -> state.hide(state.snapshot.picks.first()); state.overlay = null; state.openTitle(state.snapshot.picks.first()); state.titleButton = 2 } + @Test fun st071_requestSelf() = capture("st-071") { request(it) } + @Test fun st072_requestOther() = capture("st-072") { request(it, other = true) } + @Test fun st073_addHeld() = capture("st-073") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) } + @Test fun st074_receipt() = capture("st-074") { request(it, other = true, phase = RequestPhase.Receipt) } + @Test fun st075_notConfirmed() = capture("st-075") { request(it, phase = RequestPhase.NotConfirmed) } + @Test fun st076_pending() = capture("st-076") { request(it, phase = RequestPhase.Pending) } + @Test fun st077_refused() = capture("st-077", person = "mindel") { request(it, other = true, phase = RequestPhase.Refused) } + @Test fun st078_recipientsLoading() = capture("st-078") { request(it, other = true, phase = RequestPhase.LoadingRecipients) } + @Test fun st079_recipientsUnavailable() = capture("st-079") { request(it, other = true, phase = RequestPhase.RecipientsUnavailable) } + @Test fun st080_seasonsUnknown() = capture("st-080") { state -> request(state, state.snapshot.picks.first { it.type == "tv" }) } + @Test fun st087_sharedReceipt() = capture("st-087", person = "shared") { request(it, phase = RequestPhase.Receipt) } + @Test fun st105_pickPlate() = capture("st-105") { state -> val lane = state.lanes.first { it.id == "pk-movie" }; state.focus(state.lanes.indexOf(lane) + 2, 1) } + @Test fun st110_heldRatings() = capture("st-110") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) } + + private fun hidden(state: DiscoverState) { + state.hide(state.snapshot.picks[7]); state.hide(state.snapshot.picks[20]); state.overlay = null + val lane = state.lanes.first { it.hidden } + state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == state.snapshot.picks[7].key }) + } + + private fun request(state: DiscoverState, title: DiscoverTitle = state.snapshot.picks.first(), other: Boolean = false, phase: RequestPhase = RequestPhase.Choose) { + state.openTitle(title); state.openRequest(other, phase = phase) + if (phase == RequestPhase.Receipt) { + val sheet = (state.overlay as DiscoverOverlayState.Request).state + val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key) + sheet.result = receipt; state.requests[title.key] = receipt + } + } } internal fun discoverTestFixture(): DiscoverFixture { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index e95fbd93..24e5328e 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey import kotlinx.coroutines.CoroutineScope @@ -137,4 +138,46 @@ class DiscoverKeysTest { press(Key.DirectionCenter) assertNotNull(state.title) } + @Test fun downClampsToOccupiedCardsAndThenTheTail() { + shell.pickerVisible = false + val items = snapshot.picks.take(8) + state.openCollection(DiscoverLane("short", "Picks", items, tail = DiscoverTail.ShowMore)) + launch() + repeat(5) { press(Key.DirectionRight) } + assertEquals(5, state.collection!!.column) + press(Key.DirectionDown) + assertEquals(1, state.collection!!.row) + assertEquals(1, state.collection!!.column) + assertEquals(items.last().key, state.collection!!.items[state.collection!!.index].key) + press(Key.DirectionDown, Key.DirectionRight) + assertEquals(2, state.collection!!.row) + assertEquals(0, state.collection!!.column) + assertTrue(state.collection!!.hasTail) + } + + @Test fun collectionBackRestoresTheExactOrigin() { + shell.pickerVisible = false; shell.enterDestination("discover") + state.focus(3, 2) + val origin = shell.view + state.openCollection(state.lanes.first { it.id == "pk-movie" }) + launch() + press(Key.DirectionDown, Key.Back) + assertNull(state.collection) + assertSame(origin, shell.view) + assertEquals(3, shell.view.row) + assertEquals(2, shell.view.cards[state.rowKey]) + } + + @Test + @Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi") + fun dutchPlateUsesLocaleAndStaysOnOneLine() { + shell.pickerVisible = false + val title = snapshot.picks.first().copy(ratings = listOf(DiscoverRating("tmdb", 7.8))) + state.openCollection(DiscoverLane("locale", "Picks", listOf(title))) + launch() + rule.mainClock.advanceTimeBy(1000) + rule.onNodeWithText("7,8").assertExists() + assertEquals("7.8", formatDiscoverRating(title.ratings.single(), java.util.Locale.ENGLISH)) + assertEquals("91%", formatDiscoverRating(DiscoverRating("rtCritics", 91.7), java.util.Locale.forLanguageTag("nl-NL"))) + } } diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index c0ce6cca..3b66ea2f 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -213,4 +213,10 @@ --discover-noteTop: 14.0px; --discover-headerTop: 150.0px; --discover-headerGap: 24.0px; + --discover-captionFactsGap: 18.0px; + --discover-badgeRing: 3.0px; + --discover-badgeIcon: 28.0px; + --discover-entryPaddingX: 22.0px; + --discover-entryPaddingY: 20.0px; + --discover-entryText: 30.0px; } diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index c9b917e4..efa36ad9 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,6 +1,6 @@ -d98e9ff63dc1010e7af362b36ff3dce7f4052d7de7da405c1ed6c06191a6205f discover-tokens.json +145606da685f1d54ce0225a880220676dac34470eba45b3eda1a92000bb4937d discover-tokens.json fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py 6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json -54e91e53f3c6e928aac6f4419db2bdde458957b57e2271aef45c6d521ad6162e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -fb3018e251c2dd795e0ed522a331da307ab6477a3fd45b246d1cb7fd514dc069 tokens.css +dfde5d83a088d19f017a16ef21a9591f4721e33769ee3fadc5591421f507bbcd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +b284e80a0ad867aebd06412be5feb4262bcefa1945b507ec6e7e8f19a4365cf8 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json