Match Discover captions and catalogue collection states
This commit is contained in:
parent
7e8e232d47
commit
4b28b4f044
25 changed files with 486 additions and 74 deletions
|
|
@ -44,11 +44,26 @@ class ConductarrDiscoverOperations(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = client.mutate(TvRoutes.hide(profile,
|
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean {
|
||||||
RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV,
|
val result = client.mutate(TvRoutes.hide(profile,
|
||||||
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) is MutationResult.Success
|
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()
|
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(
|
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,
|
phase = if (state == RequestReceipt.State.PENDING) RequestPhase.Pending else if (state == RequestReceipt.State.RETIRED) RequestPhase.Refused else RequestPhase.Receipt,
|
||||||
actor = actor.name,
|
actor = actor.name,
|
||||||
|
requester = requester,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,53 @@
|
||||||
"px": 24,
|
"px": 24,
|
||||||
"dp": 12,
|
"dp": 12,
|
||||||
"source": "locked index.html:202"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,38 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
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.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
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. */
|
/** Captions fade without moving the reserved band under the focused poster. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
|
fun Caption(title: String, facts: List<CaptionFact>, 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")
|
val alpha = remember { Animatable(0f) }
|
||||||
Column(modifier.graphicsLayer { this.alpha = alpha }) {
|
LaunchedEffect(visible, title) {
|
||||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1)
|
alpha.snapTo(0f)
|
||||||
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DiscoverTitle> 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)
|
||||||
|
}
|
||||||
|
|
@ -22,17 +22,18 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
|
||||||
.filter { it.optBoolean("played") }
|
.filter { it.optBoolean("played") }
|
||||||
val groups = linkedMapOf<String, Pair<String, MutableList<DiscoverTitle>>>()
|
val groups = linkedMapOf<String, Pair<String, MutableList<DiscoverTitle>>>()
|
||||||
picks.sortedBy { if (it.type == "movie") 0 else 1 }.forEach { pick ->
|
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, _) ->
|
.filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) ->
|
||||||
groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick)
|
groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val lanes = services.mapIndexed { index, service ->
|
val lanes = services.mapIndexed { index, service ->
|
||||||
DiscoverLane("new-$index", "New on ${service.name}", rotate(pool, index * 7).take(12).sortedByDescending { it.year })
|
val source = rotate(pool, index * 7).sortedByDescending { it.year }
|
||||||
} + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size)) +
|
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 ->
|
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") }
|
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)
|
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)
|
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 identity(json: JSONObject): String = "${json.getString("type")}:${json.optLong("tmdbId", json.getString("id").hashCode().toLong().let { if (it < 0) -it else it })}"
|
||||||
private fun <T> rotate(items: List<T>, offset: Int): List<T> = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size)
|
private fun <T> rotate(items: List<T>, offset: Int): List<T> = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size)
|
||||||
|
private fun normalizedGenres(genres: List<String>): Set<String> = 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<JSONObject> = List(length()) { getJSONObject(it) }
|
private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ data class DiscoverLane(
|
||||||
val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None,
|
val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None,
|
||||||
val pick: Boolean = false,
|
val pick: Boolean = false,
|
||||||
val hidden: Boolean = false,
|
val hidden: Boolean = false,
|
||||||
|
val fullTitles: List<DiscoverTitle> = titles,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
|
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
|
||||||
|
|
@ -65,6 +66,7 @@ data class DiscoverRequestResult(
|
||||||
val phase: RequestPhase,
|
val phase: RequestPhase,
|
||||||
val actor: String? = null,
|
val actor: String? = null,
|
||||||
val stage: String = "Waiting",
|
val stage: String = "Waiting",
|
||||||
|
val requester: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused }
|
enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused }
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ fun DiscoverOverlay(state: DiscoverState) {
|
||||||
when (overlay) {
|
when (overlay) {
|
||||||
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
|
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
|
||||||
is DiscoverOverlayState.Hidden -> {
|
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.")
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
|
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)
|
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)
|
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings)
|
||||||
when (sheet.phase) {
|
when (sheet.phase) {
|
||||||
RequestPhase.Choose -> RequestChoices(state, sheet)
|
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
|
||||||
RequestPhase.LoadingRecipients -> {
|
RequestPhase.LoadingRecipients -> {
|
||||||
Text("For", color = C.dim, style = WolfskinTokens.Type.label)
|
Text("For", color = C.dim, style = WolfskinTokens.Type.label)
|
||||||
repeat(3) { Box(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))) }
|
repeat(3) { Box(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))) }
|
||||||
DiscoverButton("Close", true) { state.overlay = null }
|
DiscoverButton("Close", true) { state.overlay = null }
|
||||||
}
|
}
|
||||||
RequestPhase.Sending -> {
|
|
||||||
ReceiptPanel("Request", "Checking the request.")
|
|
||||||
}
|
|
||||||
else -> RequestOutcome(state, sheet)
|
else -> RequestOutcome(state, sheet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,8 +139,14 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui.graphics.Color = C.fg, stage: String? = null) {
|
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)) {
|
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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -38,13 +39,22 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
val shell = state.shell
|
val shell = state.shell
|
||||||
|
val input = remember(state) { state::key }
|
||||||
DisposableEffect(state) {
|
DisposableEffect(state) {
|
||||||
shell.contentInput = state::key
|
shell.contentInput = input
|
||||||
onDispose { shell.contentInput = null; shell.contentOverlayVisible = false }
|
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 } }
|
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 }
|
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
|
||||||
|
state.collection?.let { DiscoverCollection(state, it, modifier); return }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val list = shell.view.listState
|
val list = shell.view.listState
|
||||||
LaunchedEffect(state.focusedRow, shell.navFocused) {
|
LaunchedEffect(state.focusedRow, shell.navFocused) {
|
||||||
|
|
@ -52,14 +62,14 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
val row = state.focusedRow
|
val row = state.focusedRow
|
||||||
val alreadyFits = state.service != null && row <= 2
|
val alreadyFits = state.service != null && row <= 2
|
||||||
if (row <= 1 || alreadyFits) list.animateScrollToItem(0)
|
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")) {
|
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() })) }
|
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()) {
|
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") {
|
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") {
|
item(key = "discover-chips", contentType = "chips") {
|
||||||
if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state)
|
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. */
|
/** Header and type chips use the same safe area as the shell. */
|
||||||
@Composable
|
@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) {
|
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)
|
Text(title, color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||||
if (subtitle.isNotEmpty()) Text(subtitle, color = C.muted, style = WolfskinTokens.Type.nav)
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
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) {
|
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)) }
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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)
|
.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))
|
.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) {
|
.border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||||
Text(service.name, style = WolfskinTokens.Type.listTitle, color = C.fg)
|
.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 {
|
} else {
|
||||||
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
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) {
|
if (!services && active) {
|
||||||
val item = lane.titles.getOrNull(column)
|
val item = lane.titles.getOrNull(column)
|
||||||
Column(Modifier.offset(L.safeSide, L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth)) {
|
Caption(
|
||||||
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)
|
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
||||||
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)
|
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. */
|
/** Only the focused poster reads the ratings plate; cached observations paint immediately. */
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, hidden: Boolean, modifier: Modifier = Modifier) {
|
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, hidden: Boolean, modifier: Modifier = Modifier) {
|
||||||
var plate by remember(title.key) { mutableStateOf(false) }
|
val plate = focused && state.plateKey == title.key
|
||||||
val observed = remember(state) { mutableSetOf<String>() }
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
Box(modifier.size(K.posterW, K.posterH).zIndex(if (focused) 1f else 0f)
|
Box(modifier.size(K.posterW, K.posterH).zIndex(if (focused) 1f else 0f)
|
||||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)) {
|
.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) {
|
if (state.shell.person.key in title.heldBy || result != null) {
|
||||||
Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge)
|
Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge)
|
||||||
.background(if (result == null) C.ok else C.badgeRest, CircleShape)
|
.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) {
|
.border(if (result == null) WolfskinTokens.Shell.zero else D.badgeRing, 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)
|
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
|
@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)
|
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)
|
.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),
|
.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 {
|
internal fun captionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List<CaptionFact> = buildList {
|
||||||
if (hidden) add("Hidden from your picks")
|
if (hidden) add(CaptionFact("Hidden from your picks", true))
|
||||||
title.year?.let { add(it.toString()) }
|
title.year?.let { add(CaptionFact(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")
|
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("In your library")
|
if (state.shell.person.key in title.heldBy) add(CaptionFact("In your library", true))
|
||||||
else if (state.requests.containsKey(title.key)) add("Requested for you · ${state.requests.getValue(title.key).stage}")
|
else if (state.requests.containsKey(title.key)) {
|
||||||
else title.heldBy.firstOrNull()?.let { key -> add("In ${state.shell.people.firstOrNull { it.key == key }?.name ?: key}’s library") }
|
val request = state.requests.getValue(title.key)
|
||||||
}.joinToString(" · ")
|
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)) }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,15 @@ class DiscoverState(
|
||||||
var slide by mutableStateOf(0)
|
var slide by mutableStateOf(0)
|
||||||
var title by mutableStateOf<DiscoverTitle?>(null)
|
var title by mutableStateOf<DiscoverTitle?>(null)
|
||||||
var service by mutableStateOf<DiscoverService?>(null)
|
var service by mutableStateOf<DiscoverService?>(null)
|
||||||
|
var collection by mutableStateOf<DiscoverCollectionState?>(null)
|
||||||
var titleButton by mutableStateOf(0)
|
var titleButton by mutableStateOf(0)
|
||||||
private var activeOverlay by mutableStateOf<DiscoverOverlayState?>(null)
|
private var activeOverlay by mutableStateOf<DiscoverOverlayState?>(null)
|
||||||
var overlay: DiscoverOverlayState?
|
var overlay: DiscoverOverlayState?
|
||||||
get() = activeOverlay
|
get() = activeOverlay
|
||||||
set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null }
|
set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null }
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
|
val observedRatings = mutableSetOf<String>()
|
||||||
|
var plateKey by mutableStateOf<String?>(null)
|
||||||
val requests = mutableStateMapOf<String, DiscoverRequestResult>()
|
val requests = mutableStateMapOf<String, DiscoverRequestResult>()
|
||||||
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
|
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
|
||||||
private var armedCard: String? = null
|
private var armedCard: String? = null
|
||||||
|
|
@ -85,7 +88,12 @@ class DiscoverState(
|
||||||
title = item; titleButton = 0; armedCard = null
|
title = item; titleButton = 0; armedCard = null
|
||||||
onPush("discover-title/${item.key}")
|
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 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) {
|
fun openService(item: DiscoverService) {
|
||||||
rootFilter = filter; service = item; filter = "movie"; revision++
|
rootFilter = filter; service = item; filter = "movie"; revision++
|
||||||
|
|
@ -94,8 +102,8 @@ class DiscoverState(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enterRoute(route: String) {
|
fun enterRoute(route: String) {
|
||||||
if (route == "discover") { title = null; if (service != null) filter = rootFilter; service = null }
|
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
|
||||||
else if (route.startsWith("discover-service")) title = 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. */
|
/** The shell consumes repeat events before this handler; rows activate only on key-up. */
|
||||||
|
|
@ -105,6 +113,7 @@ class DiscoverState(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
|
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) {
|
if (isCard && key == ShellKey.Ok) {
|
||||||
val identity = "$rowKey:$column"
|
val identity = "$rowKey:$column"
|
||||||
if (down) armedCard = identity else {
|
if (down) armedCard = identity else {
|
||||||
|
|
@ -152,7 +161,7 @@ class DiscoverState(
|
||||||
val item = lane.titles.getOrNull(column)
|
val item = lane.titles.getOrNull(column)
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh()
|
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 if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
|
||||||
else openTitle(item)
|
else openTitle(item)
|
||||||
}
|
}
|
||||||
|
|
@ -161,9 +170,9 @@ class DiscoverState(
|
||||||
when (key) {
|
when (key) {
|
||||||
ShellKey.Up -> shell.focusNav(shell.currentTab)
|
ShellKey.Up -> shell.focusNav(shell.currentTab)
|
||||||
ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0)
|
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) {
|
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)
|
1 -> openRequest(true)
|
||||||
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
|
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +187,13 @@ class DiscoverState(
|
||||||
|
|
||||||
private fun overlayKey(key: ShellKey) {
|
private fun overlayKey(key: ShellKey) {
|
||||||
when (val sheet = overlay) {
|
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) {
|
is DiscoverOverlayState.Hidden -> when (key) {
|
||||||
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
||||||
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
||||||
|
|
@ -200,7 +215,7 @@ class DiscoverState(
|
||||||
sheet.phase = RequestPhase.Sending
|
sheet.phase = RequestPhase.Sending
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, shell.person.key)
|
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
|
sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex
|
||||||
if (result.phase == RequestPhase.Receipt) requests[sheet.title.key] = result
|
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 all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
|
||||||
val count = all.count { it.key !in seen }
|
val count = all.count { it.key !in seen }
|
||||||
val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt()))
|
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())
|
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())
|
if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList())
|
||||||
snapshot.lanes.forEach { lane ->
|
snapshot.lanes.forEach { lane ->
|
||||||
val items = lane.titles.filter { matches(it) && it.key !in seen && it.key !in hidden }
|
val source = lane.fullTitles.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 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)
|
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,12 @@ fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier
|
||||||
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
|
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
|
||||||
Spacer(Modifier.height(D.heroOverviewGap))
|
Spacer(Modifier.height(D.heroOverviewGap))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
val receipt = state.requests[title.key]
|
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) {
|
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)
|
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.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", 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) }
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
"seerr" -> R.drawable.wolfskin_sparkle
|
"seerr" -> R.drawable.wolfskin_sparkle
|
||||||
"advanced" -> R.drawable.wolfskin_sort
|
"advanced" -> R.drawable.wolfskin_sort
|
||||||
"alert" -> R.drawable.wolfskin_alert
|
"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
|
else -> R.drawable.wolfskin_right
|
||||||
}
|
}
|
||||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||||
|
|
|
||||||
|
|
@ -321,5 +321,12 @@ object WolfskinTokens {
|
||||||
val noteTop = 7.0.dp
|
val noteTop = 7.0.dp
|
||||||
val headerTop = 75.0.dp
|
val headerTop = 75.0.dp
|
||||||
val headerGap = 12.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
wolfskin-design/src/main/res/drawable/wolfskin_check.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_check.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M5 12.5l4.5 4.5L19 7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
5
wolfskin-design/src/main/res/drawable/wolfskin_clock.xml
Normal file
5
wolfskin-design/src/main/res/drawable/wolfskin_clock.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M 3.5,12.0 a 8.5,8.5 0 1,0 17.0,0 a 8.5,8.5 0 1,0 -17.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
|
||||||
|
<path android:pathData="M12 7.5V12l3 2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
4
wolfskin-design/src/main/res/drawable/wolfskin_down.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_down.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="m6 9 6 6 6-6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
4
wolfskin-design/src/main/res/drawable/wolfskin_hide.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_hide.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7c1.6 0 3-.4 4.3-1" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
4
wolfskin-design/src/main/res/drawable/wolfskin_left.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_left.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="m15 6-6 6 6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
4
wolfskin-design/src/main/res/drawable/wolfskin_plus.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_plus.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12 5v14M5 12h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
5
wolfskin-design/src/main/res/drawable/wolfskin_undo.xml
Normal file
5
wolfskin-design/src/main/res/drawable/wolfskin_undo.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M9 7 4.5 11.5 9 16" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||||
|
<path android:pathData="M5 11.5h9a5.5 5.5 0 0 1 0 11h-2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
|
|
@ -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
|
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 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),
|
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate),
|
||||||
onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service)
|
onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service)
|
||||||
configure(state)
|
configure(state)
|
||||||
|
|
@ -61,6 +61,58 @@ class DiscoverCaptureTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun st029_discover() = capture("st-029")
|
@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 {
|
internal fun discoverTestFixture(): DiscoverFixture {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -137,4 +138,46 @@ class DiscoverKeysTest {
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertNotNull(state.title)
|
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")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,4 +213,10 @@
|
||||||
--discover-noteTop: 14.0px;
|
--discover-noteTop: 14.0px;
|
||||||
--discover-headerTop: 150.0px;
|
--discover-headerTop: 150.0px;
|
||||||
--discover-headerGap: 24.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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
d98e9ff63dc1010e7af362b36ff3dce7f4052d7de7da405c1ed6c06191a6205f discover-tokens.json
|
145606da685f1d54ce0225a880220676dac34470eba45b3eda1a92000bb4937d discover-tokens.json
|
||||||
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
|
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
|
||||||
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
|
||||||
54e91e53f3c6e928aac6f4419db2bdde458957b57e2271aef45c6d521ad6162e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
dfde5d83a088d19f017a16ef21a9591f4721e33769ee3fadc5591421f507bbcd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
fb3018e251c2dd795e0ed522a331da307ab6477a3fd45b246d1cb7fd514dc069 tokens.css
|
b284e80a0ad867aebd06412be5feb4262bcefa1945b507ec6e7e8f19a4365cf8 tokens.css
|
||||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue