Build the Wolfskin TV shell and navigation
This commit is contained in:
parent
de452448ba
commit
f60a22e435
97 changed files with 4363 additions and 91 deletions
|
|
@ -0,0 +1,21 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Only transforms animate: focus never changes measured geometry. */
|
||||
@Composable
|
||||
fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
||||
val animated by animateFloatAsState(
|
||||
if (focused) scale else 1f,
|
||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)),
|
||||
label = "focus-scale",
|
||||
)
|
||||
return graphicsLayer { scaleX = animated; scaleY = animated }
|
||||
}
|
||||
|
||||
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
|
||||
@Composable
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color): Modifier {
|
||||
val alpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
||||
return drawBehind {
|
||||
if (alpha > 0f) {
|
||||
val outer = (gap + width).toPx()
|
||||
val inner = gap.toPx()
|
||||
drawRoundRect(color.copy(alpha = color.alpha * alpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
|
||||
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.tv.material3.Icon
|
||||
|
||||
/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */
|
||||
@Composable
|
||||
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
||||
val resource = when (name) {
|
||||
"gear" -> R.drawable.wolfskin_gear
|
||||
"requests" -> R.drawable.wolfskin_tray
|
||||
"interface" -> R.drawable.wolfskin_grid
|
||||
"playback" -> R.drawable.wolfskin_play
|
||||
"nextup" -> R.drawable.wolfskin_tv
|
||||
"profile" -> R.drawable.wolfskin_users
|
||||
"about" -> R.drawable.wolfskin_info
|
||||
"seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
else -> R.drawable.wolfskin_right
|
||||
}
|
||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/** Fixed household colours never depend on preference or avatar presets. */
|
||||
fun personColor(key: String): Color = when (key.lowercase()) {
|
||||
"justin" -> WolfskinTokens.PersonColor.justin
|
||||
"mindel" -> WolfskinTokens.PersonColor.mindel
|
||||
"shared" -> WolfskinTokens.PersonColor.shared
|
||||
else -> WolfskinTokens.Color.accent
|
||||
}
|
||||
|
||||
/** The same mark anchors the picker and the persistent navigation. */
|
||||
@Composable
|
||||
fun Wordmark(modifier: Modifier = Modifier) {
|
||||
Row(modifier) {
|
||||
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
|
||||
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
|
||||
}
|
||||
}
|
||||
|
||||
/** Initials are drawn over opaque person colour at the exact slot size. */
|
||||
@Composable
|
||||
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
|
||||
Text(person.initial, color = WolfskinTokens.Color.onFocus, style = textStyle)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
|
||||
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
||||
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
||||
|
||||
fun navBoxes(tabs: List<String>): List<NavBox> {
|
||||
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
|
||||
return tabs.map { tab ->
|
||||
val width = when (tab) {
|
||||
"home" -> S.navHomeWidth
|
||||
"discover" -> S.navDiscoverWidth
|
||||
"movies" -> S.navMoviesWidth
|
||||
"shows" -> S.navShowsWidth
|
||||
else -> S.navAnimeWidth
|
||||
}
|
||||
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
|
||||
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
|
||||
}
|
||||
|
||||
/** There is one blob, including when focus wraps around the nav avatar. */
|
||||
@Composable
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||
val box = boxes[index]
|
||||
val avatarFocused = state.navFocused && index == state.tabs.size
|
||||
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
|
||||
val damping = WolfskinTokens.Motion.navBlobDampingRatio
|
||||
val stiffness = WolfskinTokens.Motion.navBlobStiffness
|
||||
val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x")
|
||||
val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y")
|
||||
val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width")
|
||||
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
|
||||
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
|
||||
val density = LocalDensity.current.density
|
||||
val band = remember(scrolled) {
|
||||
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f))
|
||||
else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
||||
}
|
||||
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
drawRect(band)
|
||||
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||
}
|
||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
||||
state.tabs.forEachIndexed { itemIndex, tab ->
|
||||
val item = boxes[itemIndex]
|
||||
val focused = state.navFocused && state.navIndex == itemIndex
|
||||
Box(Modifier.offset(item.left, item.top).size(item.width, item.height).clickable {
|
||||
state.focusNav(itemIndex); state.key(ShellKey.Ok)
|
||||
}, contentAlignment = Alignment.Center) {
|
||||
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted)
|
||||
if (itemIndex == state.currentTab && !focused) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
val avatar = boxes.last()
|
||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||
Modifier.offset(avatar.left, avatar.top)
|
||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.background
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
|
||||
/** Settings routes identify real upstream destinations rather than stand-in panels. */
|
||||
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
|
||||
|
||||
val SETTINGS_GROUPS = listOf(
|
||||
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
|
||||
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
|
||||
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
|
||||
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
|
||||
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
|
||||
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
|
||||
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
|
||||
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
||||
)
|
||||
|
||||
/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */
|
||||
@Composable
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
|
||||
val list = state.view.listState
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||
if (state.navFocused) return@LaunchedEffect
|
||||
val y = L.row_lhead + L.row_srow * state.settingsRow
|
||||
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
|
||||
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
|
||||
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
|
||||
list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
||||
}
|
||||
LazyColumn(modifier.fillMaxSize(), state = list,
|
||||
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
|
||||
item(key = "settings-header") {
|
||||
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
||||
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
|
||||
Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg)
|
||||
Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
|
||||
}
|
||||
}
|
||||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||
Box(Modifier.height(L.row_srow)) {
|
||||
SettingsRow(group, !state.navFocused && index == state.settingsRow,
|
||||
if (group.key == "requests") openRequests else null,
|
||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The inverse focus fill has no focus ring and never changes row layout. */
|
||||
@Composable
|
||||
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
||||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
||||
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle.copy(fontWeight = FontWeight.Bold), maxLines = 1)
|
||||
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (group.wolfskin) SettingsPill("Wolfskin", focused)
|
||||
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
|
||||
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPill(label: String, focused: Boolean) {
|
||||
val foreground = if (focused) C.onFocus else C.dim
|
||||
Box(Modifier.height(S.settingsPillHeight).drawBehind {
|
||||
val stroke = WolfskinTokens.Focus.ringButtonRing.toPx()
|
||||
drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2))))
|
||||
}.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/** Household identity data contains no credentials or server objects. */
|
||||
data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean)
|
||||
|
||||
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
|
||||
sealed interface ShellAction {
|
||||
data class Destination(val route: String) : ShellAction
|
||||
data class Person(val person: ShellPerson) : ShellAction
|
||||
data class HandOff(val group: String) : ShellAction
|
||||
data object Pop : ShellAction
|
||||
data object RetryPeople : ShellAction
|
||||
}
|
||||
|
||||
/** D-pad inputs are shared by Android events and deterministic JVM replays. */
|
||||
enum class ShellKey { Up, Down, Left, Right, Ok, Back }
|
||||
enum class PeopleStatus { Ready, Loading, Unavailable }
|
||||
|
||||
/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */
|
||||
class RememberedView {
|
||||
val listState = LazyListState()
|
||||
var row by mutableStateOf(0)
|
||||
val cards = mutableStateMapOf<String, Int>()
|
||||
}
|
||||
|
||||
/** Focus and Back transitions are independent of drawing and server availability. */
|
||||
class ShellState(
|
||||
val people: List<ShellPerson>,
|
||||
initialPerson: String,
|
||||
private val onAction: (ShellAction) -> Unit = {},
|
||||
) {
|
||||
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
|
||||
private set
|
||||
var route by mutableStateOf("home")
|
||||
private set
|
||||
var pickerVisible by mutableStateOf(true)
|
||||
var pickerLaunch by mutableStateOf(true)
|
||||
var pickerIndex by mutableStateOf(people.indexOf(person))
|
||||
var gearFocused by mutableStateOf(false)
|
||||
var navFocused by mutableStateOf(false)
|
||||
var navIndex by mutableStateOf(0)
|
||||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||
var recoveryIndex by mutableStateOf(0)
|
||||
var requestsVisible by mutableStateOf(false)
|
||||
var handOff by mutableStateOf<String?>(null)
|
||||
var settingsRow by mutableStateOf(0)
|
||||
private var rootRoute by mutableStateOf("home")
|
||||
private var pickerReturnRoute = "home"
|
||||
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
|
||||
private var armedCard: String? = null
|
||||
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
|
||||
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
|
||||
|
||||
/** External intents and upstream pops remain authoritative over the screen. */
|
||||
fun enterDestination(destination: String) {
|
||||
route = destination
|
||||
if (destination in tabs) rootRoute = destination
|
||||
handOff = null
|
||||
}
|
||||
|
||||
/** Pointer navigation uses the same transitions as D-pad selection. */
|
||||
fun focusNav(index: Int) { navIndex = index; navFocused = true }
|
||||
fun focusPerson(index: Int) { pickerIndex = index; gearFocused = false }
|
||||
|
||||
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
|
||||
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
if (card != null) {
|
||||
if (down) armedCard = card else {
|
||||
if (armedCard == card) activate()
|
||||
armedCard = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!down) return true
|
||||
} else {
|
||||
if (!down) return true
|
||||
armedCard = null
|
||||
}
|
||||
if (requestsVisible) {
|
||||
if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false
|
||||
return true
|
||||
}
|
||||
if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun pickerKey(key: ShellKey) {
|
||||
if (peopleStatus != PeopleStatus.Ready) {
|
||||
when (key) {
|
||||
ShellKey.Left -> recoveryIndex = 0
|
||||
ShellKey.Right -> if (peopleStatus == PeopleStatus.Unavailable) recoveryIndex = 1
|
||||
ShellKey.Back -> closePicker()
|
||||
ShellKey.Ok -> if (peopleStatus == PeopleStatus.Unavailable && recoveryIndex == 0) onAction(ShellAction.RetryPeople) else choosePerson(person)
|
||||
else -> Unit
|
||||
}
|
||||
} else if (gearFocused) {
|
||||
when (key) {
|
||||
ShellKey.Up -> gearFocused = false
|
||||
ShellKey.Ok -> {
|
||||
pickerVisible = false
|
||||
navFocused = false
|
||||
settingsRow = 0
|
||||
route = "settings"
|
||||
onAction(ShellAction.Destination(route))
|
||||
}
|
||||
ShellKey.Back -> closePicker()
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
when (key) {
|
||||
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
|
||||
ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex)
|
||||
ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true
|
||||
ShellKey.Ok -> choosePerson(people[pickerIndex])
|
||||
ShellKey.Back -> closePicker()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun choosePerson(selected: ShellPerson) {
|
||||
person = selected
|
||||
pickerVisible = false
|
||||
pickerLaunch = false
|
||||
route = "home"
|
||||
navFocused = false
|
||||
navIndex = 0
|
||||
rootRoute = "home"
|
||||
views[person.key to route] = RememberedView()
|
||||
onAction(ShellAction.Person(selected))
|
||||
}
|
||||
|
||||
private fun closePicker() {
|
||||
if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size }
|
||||
}
|
||||
|
||||
private fun navKey(key: ShellKey) {
|
||||
when (key) {
|
||||
ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1)
|
||||
ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1)
|
||||
ShellKey.Down -> navFocused = false
|
||||
ShellKey.Ok -> activate()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentKey(key: ShellKey) {
|
||||
when (key) {
|
||||
ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab }
|
||||
ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex)
|
||||
ShellKey.Ok -> activate()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun activate() {
|
||||
if (navFocused) {
|
||||
if (navIndex == tabs.size) {
|
||||
pickerReturnRoute = route
|
||||
pickerVisible = true
|
||||
pickerLaunch = false
|
||||
pickerIndex = people.indexOf(person)
|
||||
gearFocused = false
|
||||
} else {
|
||||
val target = tabs[navIndex]
|
||||
if (target == route) views[person.key to target] = RememberedView()
|
||||
route = target
|
||||
rootRoute = target
|
||||
navFocused = false
|
||||
onAction(ShellAction.Destination(target))
|
||||
}
|
||||
} else if (route == "settings") {
|
||||
val group = SETTINGS_GROUPS[settingsRow].key
|
||||
if (group == "requests") requestsVisible = true else {
|
||||
handOff = group
|
||||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Back never discards a root view, including the card below a focused nav. */
|
||||
fun back() {
|
||||
if (route == "settings") {
|
||||
route = pickerReturnRoute
|
||||
onAction(ShellAction.Pop)
|
||||
pickerVisible = true
|
||||
pickerLaunch = false
|
||||
gearFocused = true
|
||||
pickerIndex = people.lastIndex
|
||||
} else if (route !in tabs) {
|
||||
onAction(ShellAction.Pop)
|
||||
} else if (!navFocused) {
|
||||
navFocused = true
|
||||
navIndex = currentTab
|
||||
} else if (route != "home") {
|
||||
route = "home"
|
||||
rootRoute = "home"
|
||||
navIndex = 0
|
||||
onAction(ShellAction.Destination(route))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val BASE_TABS = listOf("home", "discover", "movies", "shows")
|
||||
private val ANIME_TABS = BASE_TABS + "anime"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Map Android key events once, preserving repeat count and key-up semantics. */
|
||||
fun ShellState.handleKey(event: KeyEvent): Boolean {
|
||||
val key = when (event.key) {
|
||||
Key.DirectionUp -> ShellKey.Up
|
||||
Key.DirectionDown -> ShellKey.Down
|
||||
Key.DirectionLeft -> ShellKey.Left
|
||||
Key.DirectionRight -> ShellKey.Right
|
||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
|
||||
Key.Back, Key.Escape -> ShellKey.Back
|
||||
else -> return false
|
||||
}
|
||||
return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount)
|
||||
}
|
||||
|
||||
/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */
|
||||
@Composable
|
||||
fun ShellSurface(
|
||||
state: ShellState,
|
||||
modifier: Modifier = Modifier,
|
||||
handlesContentKeys: Boolean = true,
|
||||
settingsOpenRequests: Int? = null,
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||
else contentFocus.requestFocus()
|
||||
}
|
||||
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-shell")
|
||||
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
|
||||
.focusRequester(focus)
|
||||
.onPreviewKeyEvent {
|
||||
if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false
|
||||
}
|
||||
.onKeyEvent {
|
||||
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
|
||||
state.navFocused = true; state.navIndex = state.currentTab; true
|
||||
} else false
|
||||
}.focusable()) {
|
||||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
if (state.requestsVisible) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
|
||||
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||
.width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide),
|
||||
verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label)
|
||||
Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
PickerButton("Close", true, true) { state.requestsVisible = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Future screens are explicitly out of scope, never simulated library or title data. */
|
||||
@Composable
|
||||
fun ShellPlaceholder(route: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Text(route.replaceFirstChar { it.uppercase() }, color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
Text("Wolfskin screen — not built in this slice", color = C.accentText, style = WolfskinTokens.Type.body)
|
||||
Text("Home and Library remain available through the Wolfskin screens switch in Advanced settings.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
|
||||
/** The picker covers the nav and exposes Settings only below the final person. */
|
||||
@Composable
|
||||
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
|
||||
if (state.peopleStatus == PeopleStatus.Ready) PeoplePicker(state) else RecoveryPicker(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeoplePicker(state: ShellState) {
|
||||
Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) {
|
||||
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||
state.people.forEachIndexed { index, person ->
|
||||
val focused = !state.gearFocused && state.pickerIndex == index
|
||||
Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
|
||||
state.focusPerson(index); state.key(ShellKey.Ok)
|
||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg))
|
||||
Spacer(Modifier.height(S.pickerNameGap))
|
||||
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
|
||||
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
|
||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
||||
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||
if (state.gearFocused) {
|
||||
Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) {
|
||||
Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecoveryPicker(state: ShellState) {
|
||||
val loading = state.peopleStatus == PeopleStatus.Loading
|
||||
val titleTop = if (loading) S.pickerLoadingTitleTop else S.pickerErrorTitleTop
|
||||
Box(Modifier.fillMaxWidth().offset(y = titleTop), contentAlignment = Alignment.TopCenter) {
|
||||
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
}
|
||||
if (loading) {
|
||||
Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
|
||||
repeat(state.people.size) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape))
|
||||
Spacer(Modifier.height(S.pickerNameGap))
|
||||
Box(Modifier.size(S.pickerSkeletonNameWidth, S.pickerSkeletonNameHeight).background(C.surface, RoundedCornerShape(S.skeletonRadius)))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.offset((S.stageWidth - S.pickerErrorPanelWidth) / 2, S.pickerErrorPanelTop)
|
||||
.size(S.pickerErrorPanelWidth, S.pickerErrorPanelHeight).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(horizontal = S.panelPaddingX, vertical = S.panelPaddingY), verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.panelHeadingGap)) {
|
||||
Glyph("alert", C.warn, Modifier.size(S.panelIcon))
|
||||
Text("Can’t read who’s here", color = C.warn, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold))
|
||||
}
|
||||
Text("Conductarr didn’t answer. You can continue as the last person on\nthis TV.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().offset(y = if (loading) S.pickerLoadingButtonTop else S.pickerErrorButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap, Alignment.CenterHorizontally)) {
|
||||
if (!loading) PickerButton("Try again", state.recoveryIndex == 0, true) { state.recoveryIndex = 0; state.key(ShellKey.Ok) }
|
||||
PickerButton("Continue as ${state.person.name}", loading || state.recoveryIndex == 1, false) { state.recoveryIndex = 1; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Recovery actions remain focusable while profiles cannot be read. */
|
||||
@Composable
|
||||
fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) {
|
||||
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
|
||||
/** Both Compose Material families use the same opaque TV palette for hand-offs. */
|
||||
object WolfskinColors {
|
||||
private val c = WolfskinTokens.Color
|
||||
val darkScheme = darkColorScheme(
|
||||
primary = c.accent, onPrimary = c.onFocus,
|
||||
primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText,
|
||||
secondary = c.accent, onSecondary = c.onFocus,
|
||||
secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText,
|
||||
tertiary = c.accent, onTertiary = c.onFocus,
|
||||
tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText,
|
||||
background = c.bg, onBackground = c.fg,
|
||||
surface = c.bg, onSurface = c.fg,
|
||||
surfaceVariant = c.surface2, onSurfaceVariant = c.fg,
|
||||
border = c.accent,
|
||||
)
|
||||
val darkSchemeMaterial = androidx.compose.material3.darkColorScheme(
|
||||
primary = c.accent, onPrimary = c.onFocus,
|
||||
primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText,
|
||||
secondary = c.accent, onSecondary = c.onFocus,
|
||||
secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText,
|
||||
tertiary = c.accent, onTertiary = c.onFocus,
|
||||
tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText,
|
||||
background = c.bg, onBackground = c.fg,
|
||||
surface = c.bg, onSurface = c.fg,
|
||||
surfaceVariant = c.surface2, onSurfaceVariant = c.fg,
|
||||
)
|
||||
}
|
||||
|
||||
/** Upstream content inherits tokens without a fork-specific theme enum. */
|
||||
@Composable
|
||||
fun WolfskinTheme(content: @Composable () -> Unit) {
|
||||
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
// Generated by generate_tokens.py; edit the JSON sources instead.
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** The locked TV design, converted at the source density. */
|
||||
object WolfskinTokens {
|
||||
object Color {
|
||||
val bg = ComposeColor(0xFF07070A)
|
||||
val surface = ComposeColor(0xFF13131A)
|
||||
val surface2 = ComposeColor(0xFF1B1B25)
|
||||
val surface3 = ComposeColor(0xFF252532)
|
||||
val fg = ComposeColor(0xFFF4F4F6)
|
||||
val muted = ComposeColor(0xBDF4F4F6)
|
||||
val dim = ComposeColor(0x8FF4F4F6)
|
||||
val border = ComposeColor(0x1AFFFFFF)
|
||||
val accent = ComposeColor(0xFFCC9FFF)
|
||||
val accentSoft = ComposeColor(0xFF2C1A3F)
|
||||
val accentText = ComposeColor(0xFFE4CBFF)
|
||||
val ok = ComposeColor(0xFF89DA9B)
|
||||
val warn = ComposeColor(0xFFF4C26A)
|
||||
val fail = ComposeColor(0xFFF97770)
|
||||
val onFocus = ComposeColor(0xFF0B0B10)
|
||||
val buttonSecondary = ComposeColor(0x24FFFFFF)
|
||||
val buttonSecondaryFocused = ComposeColor(0x42FFFFFF)
|
||||
val titleButtonSecondary = ComposeColor(0xC72C2C38)
|
||||
val titleButtonSecondaryFocused = ComposeColor(0xE6464654)
|
||||
val chipRest = ComposeColor(0x1AFFFFFF)
|
||||
val navBlobRest = ComposeColor(0x1AFFFFFF)
|
||||
val listRowRest = ComposeColor(0x0DFFFFFF)
|
||||
val sheetScrim = ComposeColor(0x9E000000)
|
||||
val badgeRest = ComposeColor(0xBF07070A)
|
||||
val badgeWatched = ComposeColor(0xD9F4F4F6)
|
||||
val progressTrack = ComposeColor(0x38FFFFFF)
|
||||
val railEmpty = ComposeColor(0x38F4F4F6)
|
||||
}
|
||||
object PersonColor {
|
||||
val justin = ComposeColor(0xFF62C4FF)
|
||||
val mindel = ComposeColor(0xFFFF9D72)
|
||||
val shared = ComposeColor(0xFF9BE38F)
|
||||
}
|
||||
object Gradient {
|
||||
const val heroScrim = "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)"
|
||||
const val detailScrim = "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)"
|
||||
const val navBand = "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))"
|
||||
const val navBandSolid = "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)"
|
||||
const val ratingPlate = "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)"
|
||||
const val tileOverlay = "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))"
|
||||
const val fallbackArt = "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))"
|
||||
const val entryTile = "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))"
|
||||
const val initialsCircle = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
|
||||
const val heroAmbientFilter = "blur(60px) saturate(1.2) brightness(.55)"
|
||||
const val cardAmbientFilter = "blur(26px) brightness(.5)"
|
||||
}
|
||||
object Type {
|
||||
val displayDetail = TextStyle(fontSize = 44.0.sp, fontWeight = FontWeight(800), lineHeight = 44.88.sp, letterSpacing = -0.02.em)
|
||||
val displayHero = TextStyle(fontSize = 42.0.sp, fontWeight = FontWeight(800), lineHeight = 42.84.sp, letterSpacing = -0.02.em)
|
||||
val screenTitle = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = -0.02.em)
|
||||
val sectionTitle = TextStyle(fontSize = 28.0.sp, fontWeight = FontWeight(800), lineHeight = 36.4.sp, letterSpacing = -0.02.em)
|
||||
val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em)
|
||||
val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em)
|
||||
val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
|
||||
val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(700), lineHeight = 22.1.sp, letterSpacing = -0.01.em)
|
||||
val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight(650), lineHeight = 22.1.sp, letterSpacing = 0.0.em)
|
||||
val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(600), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
|
||||
val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(650), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
|
||||
val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
|
||||
val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
|
||||
val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(650), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
||||
val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(700), lineHeight = 16.8.sp, letterSpacing = 0.0.em)
|
||||
val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800))
|
||||
val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800))
|
||||
val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
|
||||
val avatarLetterShelfHead = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800))
|
||||
val avatarLetterChip = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800))
|
||||
val avatarLetterLibHeader = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(800))
|
||||
val avatarLetterSectionHeader = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
|
||||
const val stageLineHeight = 1.3f
|
||||
}
|
||||
object Radius {
|
||||
val card = 8.0.dp
|
||||
val button = 19.0.dp
|
||||
val chip = 17.0.dp
|
||||
val navItem = 16.0.dp
|
||||
val listRow = 12.0.dp
|
||||
val panel = 11.0.dp
|
||||
val menu = 13.0.dp
|
||||
val heroSheet = 9.0.dp
|
||||
val cardSheet = 5.0.dp
|
||||
val thumb = 6.0.dp
|
||||
val pill = 11.0.dp
|
||||
val progress = 2.0.dp
|
||||
}
|
||||
object Layout {
|
||||
val safeSide = 48.0.dp
|
||||
val contentWidth = 864.0.dp
|
||||
val safeTopPicker = 27.0.dp
|
||||
val navBand = 75.0.dp
|
||||
val navPaddingTop = 22.0.dp
|
||||
val navItemHeight = 32.0.dp
|
||||
val navItemPaddingX = 13.0.dp
|
||||
val navItemGap = 6.0.dp
|
||||
val wordmarkGap = 20.0.dp
|
||||
val navUnderline = 2.0.dp
|
||||
val navBlobAvatarPad = 4.0.dp
|
||||
val navSolidAfter = 20.0.dp
|
||||
val pinnedLine = 84.0.dp
|
||||
val windowAhead = 350.0.dp
|
||||
val rowHeadingBand = 30.0.dp
|
||||
val rowGapAfter = 22.0.dp
|
||||
val gridRowGap = 14.0.dp
|
||||
val row_hero = 410.0.dp
|
||||
val row_phero = 370.0.dp
|
||||
val row_section = 75.0.dp
|
||||
val row_chips = 52.0.dp
|
||||
val row_note = 42.0.dp
|
||||
val row_state = 150.0.dp
|
||||
val row_lhead = 118.0.dp
|
||||
val row_dhead = 450.0.dp
|
||||
val row_phead = 235.0.dp
|
||||
val row_gh = 42.0.dp
|
||||
val row_endcta = 100.0.dp
|
||||
val row_srow = 62.0.dp
|
||||
val heroCopyLeft = 48.0.dp
|
||||
val heroCopyBottom = 55.0.dp
|
||||
val heroCopyWidth = 500.0.dp
|
||||
val heroOverviewMax = 480.0.dp
|
||||
val detailCopyTop = 100.0.dp
|
||||
val detailCopyWidth = 530.0.dp
|
||||
val heroSheetWidth = 210.0.dp
|
||||
val heroSheetHeight = 315.0.dp
|
||||
val buttonHeight = 38.0.dp
|
||||
val buttonPaddingX = 19.0.dp
|
||||
val buttonGap = 10.0.dp
|
||||
val buttonIcon = 15.0.dp
|
||||
val iconButton = 38.0.dp
|
||||
val iconCaptionLine = 19.0.dp
|
||||
val sheetWidth = 400.0.dp
|
||||
val sheetRow = 50.0.dp
|
||||
val settingsRowWidth = 650.0.dp
|
||||
val settingsRowHeight = 52.0.dp
|
||||
val stateWidth = 550.0.dp
|
||||
val captionOffset = 9.0.dp
|
||||
val badge = 24.0.dp
|
||||
val badgeInset = 7.0.dp
|
||||
val progressBar = 4.0.dp
|
||||
val pickerAvatar = 110.0.dp
|
||||
val pickerGap = 45.0.dp
|
||||
val pickerStackGap = 35.0.dp
|
||||
val gear = 28.0.dp
|
||||
val navAvatar = 32.0.dp
|
||||
val castCircle = 100.0.dp
|
||||
val personPortrait = 120.0.dp
|
||||
val thumbWidth = 48.0.dp
|
||||
val thumbHeight = 72.0.dp
|
||||
}
|
||||
object Card {
|
||||
val posterW = 128.0.dp
|
||||
val posterH = 192.0.dp
|
||||
val posterGap = 12.0.dp
|
||||
val posterPitch = 140.0.dp
|
||||
val posterCaptionBand = 38.0.dp
|
||||
val tileW = 208.0.dp
|
||||
val tileH = 117.0.dp
|
||||
val tileGap = 12.0.dp
|
||||
val tilePitch = 220.0.dp
|
||||
val tileCaptionBand = 0.0.dp
|
||||
val wideW = 280.0.dp
|
||||
val wideH = 157.5.dp
|
||||
val wideGap = 12.0.dp
|
||||
val widePitch = 292.0.dp
|
||||
val wideCaptionBand = 0.0.dp
|
||||
val entryW = 128.0.dp
|
||||
val entryH = 72.0.dp
|
||||
val entryGap = 12.0.dp
|
||||
val entryPitch = 140.0.dp
|
||||
val entryCaptionBand = 0.0.dp
|
||||
val personW = 100.0.dp
|
||||
val personH = 145.0.dp
|
||||
val personGap = 14.0.dp
|
||||
val personPitch = 114.0.dp
|
||||
val personCaptionBand = 0.0.dp
|
||||
}
|
||||
object Focus {
|
||||
const val scaleCard = 1.08f
|
||||
const val scaleControl = 1.05f
|
||||
const val scaleRow = 1.02f
|
||||
val ringCardGap = 2.0.dp
|
||||
val ringCardRing = 2.0.dp
|
||||
val ringButtonGap = 2.0.dp
|
||||
val ringButtonRing = 2.0.dp
|
||||
val ringAvatarGap = 2.0.dp
|
||||
val ringAvatarRing = 2.0.dp
|
||||
val ringPickerGap = 3.0.dp
|
||||
val ringPickerRing = 3.0.dp
|
||||
const val fillInverse = "background color.fg, text color.onFocus"
|
||||
}
|
||||
object Motion {
|
||||
const val fast = 140
|
||||
const val base = 160
|
||||
const val scroll = 180
|
||||
const val navBlobDampingRatio = 1.0f
|
||||
const val navBlobStiffness = 650.0f
|
||||
const val ratingDwell = 350
|
||||
const val toast = 3200
|
||||
const val reducedMotion = "all transitions instant"
|
||||
}
|
||||
object Image {
|
||||
const val memoryCacheMB = 64.0f
|
||||
const val diskCacheMB = 128.0f
|
||||
}
|
||||
object Behaviour {
|
||||
const val rowLimit = 20.0f
|
||||
const val letterRailMin = 200.0f
|
||||
const val gridColumns = 6.0f
|
||||
const val libraryChunk = 200.0f
|
||||
const val laneLoadRows = 2.0f
|
||||
const val nextPageCards = 10.0f
|
||||
const val heroSlides = 5.0f
|
||||
const val cacheRatingsHours = 6.0f
|
||||
const val laneCacheMinutes = 30.0f
|
||||
const val libraryRefreshMinutes = 10.0f
|
||||
const val requestsPollSeconds = 30.0f
|
||||
const val comingInVisibleRows = 5.0f
|
||||
}
|
||||
object Shell {
|
||||
val zero = 0.0.dp
|
||||
val stageWidth = 960.0.dp
|
||||
val stageHeight = 540.0.dp
|
||||
val wordmarkWidth = 84.5.dp
|
||||
val wordmarkTop = 36.8.dp
|
||||
val navHomeWidth = 74.5.dp
|
||||
val navDiscoverWidth = 99.0.dp
|
||||
val navMoviesWidth = 85.5.dp
|
||||
val navShowsWidth = 80.5.dp
|
||||
val navAnimeWidth = 79.0.dp
|
||||
val navItemTop = 32.5.dp
|
||||
val pickerTitleTop = 136.15.dp
|
||||
val pickerPeopleTop = 212.75.dp
|
||||
val pickerNameGap = 13.0.dp
|
||||
val pickerGearTop = 375.85.dp
|
||||
val pickerGearCaptionTop = 36.0.dp
|
||||
val pickerLetter = 50.0.dp
|
||||
val pickerSkeletonNameWidth = 70.0.dp
|
||||
val pickerSkeletonNameHeight = 20.0.dp
|
||||
val pickerLoadingTitleTop = 123.7.dp
|
||||
val pickerLoadingPeopleTop = 200.3.dp
|
||||
val pickerLoadingButtonTop = 378.3.dp
|
||||
val pickerErrorTitleTop = 148.8.dp
|
||||
val pickerErrorPanelTop = 225.4.dp
|
||||
val pickerErrorPanelWidth = 490.0.dp
|
||||
val pickerErrorPanelHeight = 92.8.dp
|
||||
val pickerErrorButtonTop = 353.2.dp
|
||||
val settingsHeaderTop = 75.0.dp
|
||||
val settingsHeaderAvatar = 26.0.dp
|
||||
val settingsHeaderGap = 12.0.dp
|
||||
val settingsRowTop = 5.0.dp
|
||||
val settingsRowPadding = 15.0.dp
|
||||
val settingsRowGap = 12.0.dp
|
||||
val settingsIcon = 18.0.dp
|
||||
val settingsPillHeight = 20.0.dp
|
||||
val settingsPillPadding = 8.0.dp
|
||||
val settingsPillGap = 6.0.dp
|
||||
val settingsChevron = 15.0.dp
|
||||
val settingsEndPadding = 310.0.dp
|
||||
val panelPaddingX = 15.0.dp
|
||||
val panelPaddingY = 14.0.dp
|
||||
val panelGap = 5.0.dp
|
||||
val panelHeadingGap = 7.0.dp
|
||||
val panelIcon = 18.0.dp
|
||||
val skeletonRadius = 5.0.dp
|
||||
const val gearOpacity = 0.7f
|
||||
const val navBandOpacity = 0.72f
|
||||
const val navSolidStop = 0.84f
|
||||
}
|
||||
}
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_alert.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_alert.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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 3 2 20h20z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M12 10v4.5M12 17v.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
5
wolfskin-design/src/main/res/drawable/wolfskin_gear.xml
Normal file
5
wolfskin-design/src/main/res/drawable/wolfskin_gear.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<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 8.8,12.0 a 3.2,3.2 0 1,0 6.4,0 a 3.2,3.2 0 1,0 -6.4,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
|
||||
<path android:pathData="M12 2.8v2.6M12 18.6v2.6M2.8 12h2.6M18.6 12h2.6M5.5 5.5l1.9 1.9M16.6 16.6l1.9 1.9M5.5 18.5l1.9-1.9M16.6 7.4l1.9-1.9" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" />
|
||||
<path android:pathData="M 5.4,12.0 a 6.6,6.6 0 1,0 13.2,0 a 6.6,6.6 0 1,0 -13.2,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_grid.xml
Normal file
3
wolfskin-design/src/main/res/drawable/wolfskin_grid.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<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="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_info.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_info.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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.0,12.0 a 9.0,9.0 0 1,0 18.0,0 a 9.0,9.0 0 1,0 -18.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
|
||||
<path android:pathData="M12 11v6M12 7.5v.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_play.xml
Normal file
3
wolfskin-design/src/main/res/drawable/wolfskin_play.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<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="M8 5v14l11-7z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_right.xml
Normal file
3
wolfskin-design/src/main/res/drawable/wolfskin_right.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<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 6 6 6-6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_sort.xml
Normal file
3
wolfskin-design/src/main/res/drawable/wolfskin_sort.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<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="M7 5v14m0 0-3-3m3 3 3-3M17 19V5m0 0-3 3m3-3 3 3" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<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 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7z" android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_tray.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_tray.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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 13.5 5.5 5h13l2.5 8.5V19H3z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M3 13.5h5.5a3.5 3.5 0 0 0 7 0H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_tv.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_tv.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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 5.5,5.0 h 13.0 a 2.5,2.5 0 0,1 2.5,2.5 v 7.0 a 2.5,2.5 0 0,1 -2.5,2.5 h -13.0 a 2.5,2.5 0 0,1 -2.5,-2.5 v -7.0 a 2.5,2.5 0 0,1 2.5,-2.5 z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
|
||||
<path android:pathData="M8 21h8M12 17v4" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_users.xml
Normal file
4
wolfskin-design/src/main/res/drawable/wolfskin_users.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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 5.5,8.5 a 3.5,3.5 0 1,0 7.0,0 a 3.5,3.5 0 1,0 -7.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
|
||||
<path android:pathData="M2.5 19.5c.8-3.4 3.4-5 6.5-5s5.7 1.6 6.5 5M15.5 5.2a3.4 3.4 0 0 1 0 6.6M18 14.8c1.8.7 3 2.2 3.5 4.7" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.TargetBasedAnimation
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.spring
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Sample the shipped Compose spring, not a separately approximated motion function. */
|
||||
class NavSpringTest {
|
||||
@Test
|
||||
fun criticallyDampedBlobMatchesLockedCurveWithoutOvershoot() {
|
||||
val animation = TargetBasedAnimation(
|
||||
animationSpec = spring<Float>(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
|
||||
typeConverter = Float.VectorConverter,
|
||||
initialValue = 478f,
|
||||
targetValue = 688f,
|
||||
)
|
||||
val times = listOf(50, 99, 150, 200)
|
||||
val expected = listOf(554.5f, 628.7f, 665.9f, 680.2f)
|
||||
val values = times.map { animation.getValueFromNanos(it * 1_000_000L) }
|
||||
values.zip(expected).forEach { (actual, target) -> assertEquals(target, actual, .2f) }
|
||||
assertTrue((values[1] - 478) / 210 in .55f.. .8f)
|
||||
assertEquals(688f, animation.getValueFromNanos(300_000_000), 1f)
|
||||
var previous = 478f
|
||||
for (millisecond in 0..1000) {
|
||||
val value = animation.getValueFromNanos(millisecond * 1_000_000L)
|
||||
assertTrue("Spring reversed at $millisecond ms", value >= previous)
|
||||
assertTrue("Spring overshot at $millisecond ms", value <= 688f)
|
||||
previous = value
|
||||
}
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"))
|
||||
directory.mkdirs()
|
||||
File(directory, "nav-spring.json").writeText(times.zip(values).joinToString(prefix = "[", postfix = "]") { (time, value) -> "{\"ms\":$time,\"x\":$value}" })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Before
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native 2.0-density captures are compared against the frozen browser states outside the build tree. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ShellCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Before
|
||||
fun fullscreen() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
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, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
|
||||
val state = ShellState(fixturePeople(), "justin")
|
||||
configure(state)
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"))
|
||||
directory.mkdirs()
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun st001_launch() = capture("st-001")
|
||||
@Test fun st002_gear() {
|
||||
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
rule.onNodeWithText("Settings").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
|
||||
assertEquals("The caption must not wrap under the small gear", 1, layouts.single().lineCount)
|
||||
}
|
||||
@Test fun st003_loading() = capture("st-003") { it.peopleStatus = PeopleStatus.Loading }
|
||||
@Test fun st004_unavailable() = capture("st-004") { it.peopleStatus = PeopleStatus.Unavailable }
|
||||
@Test fun st010_homeNav() = capture("st-010") { it.pickerVisible = false; it.navFocused = true }
|
||||
@Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 }
|
||||
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
|
||||
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
|
||||
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
||||
}
|
||||
|
||||
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
|
||||
fun fixturePeople(): List<ShellPerson> {
|
||||
val json = ShellCaptureTest::class.java.getResourceAsStream("/wolfskin-shell.json")!!.bufferedReader().use { JSONObject(it.readText()) }
|
||||
val people = json.getJSONArray("profiles")
|
||||
return List(people.length()) { index ->
|
||||
val person = people.getJSONObject(index)
|
||||
ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
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.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Press counts exercise the actual Android-to-shell event path, not a second focus implementation. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class ShellKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
private val state = ShellState(fixturePeople(), "justin")
|
||||
private val settings = listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionCenter)
|
||||
|
||||
private fun replay(keys: List<Key>, presses: Int) {
|
||||
assertEquals("Acceptance press count", presses, keys.size)
|
||||
rule.setContent { WolfskinTheme { ShellSurface(state) } }
|
||||
keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
||||
}
|
||||
|
||||
@Test fun switchPersonInSixPresses() {
|
||||
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionCenter), 6)
|
||||
assertEquals("mindel", state.person.key)
|
||||
assertEquals("home", state.route)
|
||||
assertFalse(state.pickerVisible)
|
||||
assertFalse(state.navFocused)
|
||||
assertFalse(state.tabs.contains("anime"))
|
||||
}
|
||||
|
||||
@Test fun openSettingsInEightPresses() {
|
||||
replay(settings, 8)
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals(0, state.settingsRow)
|
||||
assertFalse(state.pickerVisible)
|
||||
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun settingsBackBackInTenPresses() {
|
||||
replay(settings + listOf(Key.Back, Key.Back), 10)
|
||||
assertEquals("home", state.route)
|
||||
assertFalse(state.pickerVisible)
|
||||
assertTrue(state.navFocused)
|
||||
assertEquals(state.tabs.size, state.navIndex)
|
||||
}
|
||||
|
||||
@Test fun playbackHandOffInElevenPresses() {
|
||||
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
|
||||
assertEquals("playback", state.handOff)
|
||||
assertEquals(2, state.settingsRow)
|
||||
}
|
||||
|
||||
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
||||
replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3)
|
||||
assertFalse(state.gearFocused)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) }
|
||||
assertTrue(state.gearFocused)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) }
|
||||
assertFalse(state.gearFocused)
|
||||
assertEquals(2, state.pickerIndex)
|
||||
}
|
||||
|
||||
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
|
||||
replay(listOf(Key.DirectionCenter), 1)
|
||||
val view = state.view
|
||||
rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 }
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) }
|
||||
assertSame(view, state.view)
|
||||
assertEquals(4, state.view.row)
|
||||
assertEquals(2, state.view.cards["row-4"])
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp); pressKey(Key.DirectionLeft) }
|
||||
assertEquals(state.tabs.size, state.navIndex)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
|
||||
assertEquals(0, state.navIndex)
|
||||
}
|
||||
|
||||
@Test fun destinationViewsAreIsolatedAndReselectResets() {
|
||||
replay(listOf(Key.DirectionCenter), 1)
|
||||
rule.runOnIdle {
|
||||
state.enterDestination("movies")
|
||||
val movies = state.view
|
||||
movies.row = 3; movies.cards["movies"] = 2
|
||||
state.enterDestination("shows")
|
||||
state.view.row = 1
|
||||
state.enterDestination("movies")
|
||||
assertSame(movies, state.view)
|
||||
state.focusNav(2); state.key(ShellKey.Ok)
|
||||
assertNotSame(movies, state.view)
|
||||
assertEquals(0, state.view.row)
|
||||
assertTrue(state.view.cards.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun heldOkCannotActivateNewOverlay() {
|
||||
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft), 3)
|
||||
rule.runOnIdle {
|
||||
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
repeat(5) { repeat -> state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, repeat + 1))) }
|
||||
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
}
|
||||
assertTrue(state.pickerVisible)
|
||||
assertEquals("justin", state.person.key)
|
||||
}
|
||||
}
|
||||
78
wolfskin-design/src/test/resources/wolfskin-shell.json
Normal file
78
wolfskin-design/src/test/resources/wolfskin-shell.json
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only",
|
||||
"profiles": [
|
||||
{
|
||||
"key": "justin",
|
||||
"name": "Justin",
|
||||
"initial": "J",
|
||||
"color": "#62c4ff",
|
||||
"destinations": {
|
||||
"Home": {
|
||||
"kind": "home"
|
||||
},
|
||||
"Movies": {
|
||||
"kind": "library",
|
||||
"collection": "movies"
|
||||
},
|
||||
"Shows": {
|
||||
"kind": "library",
|
||||
"collection": "shows"
|
||||
},
|
||||
"Anime": {
|
||||
"kind": "library",
|
||||
"collection": "anime"
|
||||
},
|
||||
"Discover": {
|
||||
"kind": "discover",
|
||||
"collection": "discover"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mindel",
|
||||
"name": "Mindel",
|
||||
"initial": "M",
|
||||
"color": "#ff9d72",
|
||||
"destinations": {
|
||||
"Home": {
|
||||
"kind": "home"
|
||||
},
|
||||
"Movies": {
|
||||
"kind": "library",
|
||||
"collection": "movies"
|
||||
},
|
||||
"Shows": {
|
||||
"kind": "library",
|
||||
"collection": "shows"
|
||||
},
|
||||
"Discover": {
|
||||
"kind": "discover",
|
||||
"collection": "discover"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "shared",
|
||||
"name": "Shared",
|
||||
"initial": "S",
|
||||
"color": "#9be38f",
|
||||
"destinations": {
|
||||
"Home": {
|
||||
"kind": "home"
|
||||
},
|
||||
"Movies": {
|
||||
"kind": "library",
|
||||
"collection": "movies"
|
||||
},
|
||||
"Shows": {
|
||||
"kind": "library",
|
||||
"collection": "shows"
|
||||
},
|
||||
"Discover": {
|
||||
"kind": "discover",
|
||||
"collection": "discover"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue