Build the Wolfskin Home feed

This commit is contained in:
justinzeus 2026-11-05 16:15:40 +01:00
parent 9be55c570a
commit 0fd0ec035d
683 changed files with 2561 additions and 18 deletions

View file

@ -0,0 +1,8 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import org.justinvisser.wolfskin.design.HomeFixture
import org.justinvisser.wolfskin.design.HomeSnapshot
/** Only debug builds may read the locked prototype household projection. */
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person)

View file

@ -0,0 +1,8 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import org.justinvisser.wolfskin.design.HomeSnapshot
/** Release Home always reads the selected Jellyfin identity, never prototype data. */
@Suppress("UNUSED_PARAMETER")
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = null

View file

@ -0,0 +1,29 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.justinvisser.wolfskin.design.HomeLibrary
import org.justinvisser.wolfskin.design.HomeTitle
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
/** A reopened Home cache preserves playable identity and episode/resume state without network reads. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class HomeLibraryCacheTest {
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
val context = ApplicationProvider.getApplicationContext<Application>()
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42)
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
HomeLibraryCache(context).write("server:person", library, 12345L)
val saved = HomeLibraryCache(context).read("server:person")!!
assertEquals(library, saved.first)
assertEquals(12345L, saved.second)
assertNull(HomeLibraryCache(context).read("server:someone-else"))
}
}

View file

@ -0,0 +1,18 @@
package org.justinvisser.wolfskin.tv
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens
/** Home's continuation targets are explicit seams for the Discover and See all screen slices. */
@Composable
fun HomeContinuationTarget(route: String, modifier: Modifier = Modifier) {
Column(modifier.offset(WolfskinTokens.Layout.safeSide, WolfskinTokens.Layout.detailCopyTop), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.buttonGap)) {
Text(if (route == "discover") "Discover" else "See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.screenTitle)
Text(if (route == "discover") "Discover navigation target — content belongs to the Discover slice" else "See all navigation target — grid belongs to the Libraries and See all slice", color = WolfskinTokens.Color.accentText, style = WolfskinTokens.Type.body)
}
}

View file

@ -0,0 +1,113 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
/** The Home entry owns its source reads and upstream navigation, never the design module. */
@HiltViewModel
class WolfskinHomeViewModel @Inject constructor(
@param:ApplicationContext private val context: Context,
private val home: HomeReadFacade,
private val cache: HomeLibraryCache,
private val navigation: NavigationManager,
) : ViewModel() {
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
private set
var toast by mutableStateOf<String?>(null)
private set
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
if (person != user.name.orEmpty().lowercase()) {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
return
}
val scope = "${server.id}:${user.id}"
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
try {
val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis()
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
}
}
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
when (action) {
HomeAction.Discover -> openRoot(shell, "discover")
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else navigation.navigateTo(Destination.Wolfskin("see-all:${action.row.id}"))
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin("title:${action.title.type}:${action.title.tmdbId ?: action.title.id}"))
is HomeAction.Play -> viewModelScope.launch {
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
return@launch
}
try {
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
}
}
}
}
private fun openRoot(shell: ShellState, route: String) {
shell.enterDestination(route)
shell.focusNav(shell.tabs.indexOf(route))
shell.key(ShellKey.Ok)
}
private fun showToast(message: String) {
toast = message
viewModelScope.launch { delay(WolfskinTokens.Motion.toast.toLong()); if (toast == message) toast = null }
}
}
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
@Composable
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
Box(modifier) {
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
model.toast?.let { message ->
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
}
}
}

View file

@ -171,6 +171,7 @@ fun WolfskinApp(
when {
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
else -> ShellPlaceholder(route, contentModifier)
}
@ -186,5 +187,9 @@ fun WolfskinApp(
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
@Composable
fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) {
if (route == "settings") SettingsList(LocalShellState.current, modifier) else ShellPlaceholder(route, modifier)
when {
route == "settings" -> SettingsList(LocalShellState.current, modifier)
route == "discover" || route.startsWith("see-all:") -> HomeContinuationTarget(route, modifier)
else -> ShellPlaceholder(route, modifier)
}
}

View file

@ -0,0 +1,66 @@
package org.justinvisser.wolfskin.tv.read
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.justinvisser.wolfskin.conductarr.CacheEntry
import org.justinvisser.wolfskin.conductarr.FileCacheStore
import org.justinvisser.wolfskin.design.HomeLibrary
import org.justinvisser.wolfskin.design.HomeTitle
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
@Singleton
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
fun read(scope: String): Pair<HomeLibrary, Long>? {
val entry = store.read(scope, KEY) ?: return null
return try {
val json = JSONObject(entry.body)
val destinations = json.getJSONObject("destinations")
val totals = json.getJSONObject("totals")
HomeLibrary(
decode(json.getJSONArray("resume")), decode(json.getJSONArray("nextUp")), decode(json.getJSONArray("newest")),
destinations.keys().asSequence().associateWith { decode(destinations.getJSONArray(it)) },
totals.keys().asSequence().associateWith { totals.getInt(it) },
) to entry.savedAtMillis
} catch (_: JSONException) { store.remove(scope, KEY); null }
}
fun write(scope: String, library: HomeLibrary, savedAtMillis: Long) {
val json = JSONObject().put("resume", encode(library.resume)).put("nextUp", encode(library.nextUp)).put("newest", encode(library.newest))
.put("destinations", JSONObject().apply { library.destinations.forEach { (key, titles) -> put(key, encode(titles)) } })
.put("totals", JSONObject(library.destinationTotals))
store.write(scope, KEY, CacheEntry(json.toString(), null, savedAtMillis, savedAtMillis, group = "home"))
}
private fun encode(titles: List<HomeTitle>): JSONArray = JSONArray().apply {
titles.forEach { title -> put(JSONObject().apply {
put("id", title.id); put("title", title.title); put("type", title.type); put("year", title.year)
put("runtime", title.runtimeSeconds); put("seasons", title.seasons); put("overview", title.overview)
put("poster", title.poster); put("backdrop", title.backdrop); put("posterOnly", title.posterOnly)
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
}) }
}
private fun decode(items: JSONArray): List<HomeTitle> = List(items.length()) { index ->
val item = items.getJSONObject(index)
fun string(key: String): String? = if (item.isNull(key)) null else item.getString(key)
fun number(key: String): Int? = if (item.isNull(key)) null else item.getInt(key)
val genres = item.getJSONArray("genres")
HomeTitle(
item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"),
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
)
}
companion object { private const val KEY = "home:jellyfin" }
}

View file

@ -0,0 +1,80 @@
package org.justinvisser.wolfskin.tv.read
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.ImageUrlService
import java.util.UUID
import javax.inject.Inject
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
import org.justinvisser.wolfskin.design.HomeLibrary
import org.justinvisser.wolfskin.design.HomeTitle
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService) {
suspend fun read(userId: UUID): HomeLibrary {
val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title)
val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title)
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
val newest = mutableListOf<HomeTitle>()
val totals = linkedMapOf<String, Int>()
for (library in libraries) {
val key = when {
"anime" in library.name.orEmpty().lowercase() -> "anime"
library.collectionType == CollectionType.MOVIES -> "movies"
library.collectionType == CollectionType.TVSHOWS -> "shows"
else -> continue
}
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title))
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map(::title))
}
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
}
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
if (item.type == BaseItemKind.SERIES) {
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
}
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
}
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
}?.id
}
private fun title(item: BaseItemDto): HomeTitle {
val wrapped = BaseItem(item)
return HomeTitle(
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
year = item.productionYear, runtimeSeconds = item.runTimeTicks?.div(TICKS_PER_SECOND)?.toInt(), seasons = item.childCount,
overview = item.overview.orEmpty(), poster = images.getItemImageUrl(wrapped, ImageType.PRIMARY, fillWidth = 256, fillHeight = 384),
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(),
)
}
companion object {
private const val TICKS_PER_SECOND = 10_000_000L
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
}
}

View file

@ -59,8 +59,9 @@ cover the Wolfskin nav.
The independent `:wolfskin-design` module owns the shell components, fixed person
colours, focus policy, per-person root-view memory, palette and motion. Its
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
SHA and provenance. `shell-tokens.json` adds shell measurements from that same
prototype. Generate the committed Kotlin, CSS and checksum manifest together:
SHA and provenance. `shell-tokens.json` and `home-tokens.json` add measured shell
and Home geometry from that same prototype. Generate the committed Kotlin, CSS
and checksum manifest together:
```sh
python3 wolfskin-design/generate_tokens.py
@ -77,7 +78,7 @@ Settings groups hand off to existing upstream pages; the Requests entry explicit
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations,
while retaining Wolfskin's nav, picker and Settings list. Turning it on renders
the labelled placeholders until the corresponding screen slices ship.
Wolfskin Home; later-slice screens remain explicitly labelled navigation targets.
Debug people, tabs and initials come from the shell-only projection of the locked
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
@ -104,6 +105,51 @@ artifact provider and AAPT2. It checks all string, plural and array configuratio
rejecting user-visible upstream branding while preserving the upstream legal
attribution and project URL. The check does not depend on incremental merge files.
## Wolfskin Home
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation.
The final action opens Discover at its top. Discover (`discover`) and See all
(`see-all:{row-id}`) are named integration targets, not implementations of those
later screens. Title navigation remains the shell's later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's card and the
bounded six-hour ratings session cache. Back preserves the viewport; returning
to Home does not re-run focus-follow over a manually scrolled saved view. The
next arrow re-enters visible content when scrolling moved focus off-screen.
Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice.
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
variants read the selected user's Resume, Next up, newest titles and destination
shelves, with app-private last-known-good projections through the shared atomic
disk cache. A missing saved library becomes a recovery panel; a saved library
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
episode and checks Jellyfin before opening upstream's player. No season,
recommendation stub or different person's session is promoted to playback.
Debug variants additionally package the locked fixture and local artwork under
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
The fixture provider supplies the approved household data and Home states without
network reads in tests. Conductarr sign-in is not implemented here: real signed-out
Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is
used. Catalogue data and ratings in debug captures are fixture-backed.
Home images request exact slot sizes, use RGB565 for posters, disable crossfades,
share a 64 MiB memory/128 MiB disk cache, limit image work to four reads and prefetch
the next shelf. Rating plates use the TV locale, start at 14 sp, shrink to 12 sp
and drop trailing entries rather than wrap. Their uncached read gate is 350 ms;
cached focus returns without that delay.
`make test` includes `HomeCaptureTest` (23 native acceptance states) and the
data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover
4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses.
Home captures are written to `build/wolfskin-captures/home/`. Compare those images
side by side with the frozen spec captures; Compose Roboto/JPEG rasterisation,
the approved button-scale consolidation and removal of prototype sample pills
are recorded differences, not a claim of browser pixel equality.
## Toolchain and disk use
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools

View file

@ -30,7 +30,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
}
val checkTokenDrift by tasks.registering {
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "home-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
doLast {
file("tokens.sha256").readLines().forEach { line ->
val (expected, name) = line.split(" ", limit = 2)

View file

@ -100,9 +100,10 @@ def main() -> None:
arguments = parser.parse_args()
tokens = json.loads((ROOT / "tokens.json").read_text())
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
kotlin, css = render_tokens(tokens)
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
inputs = ["tokens.json", "shell-tokens.json", "generate_tokens.py"]
inputs = ["tokens.json", "shell-tokens.json", "home-tokens.json", "generate_tokens.py"]
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))

View file

@ -0,0 +1,322 @@
{
"home": {
"kickerGap": {
"$type": "dimension",
"px": 12,
"dp": 6.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"kickerBottom": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"titleBottom": {
"$type": "dimension",
"px": 16,
"dp": 8.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"factsHeight": {
"$type": "dimension",
"px": 38,
"dp": 19.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"factsBottom": {
"$type": "dimension",
"px": 14,
"dp": 7.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"factsGap": {
"$type": "dimension",
"px": 22,
"dp": 11.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"overviewBottom": {
"$type": "dimension",
"px": 30,
"dp": 15.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardPadding": {
"$type": "dimension",
"px": 22,
"dp": 11.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardOverlayTop": {
"$type": "dimension",
"px": 54,
"dp": 27.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardOverlayBottom": {
"$type": "dimension",
"px": 18,
"dp": 9.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"sectionTop": {
"$type": "dimension",
"px": 40,
"dp": 20.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"sectionAvatar": {
"$type": "dimension",
"px": 56,
"dp": 28.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"sectionGap": {
"$type": "dimension",
"px": 22,
"dp": 11.0,
"source": "locked index.html:180"
},
"stateTextBottom": {
"$type": "dimension",
"px": 24,
"dp": 12.0,
"source": "locked index.html:198"
},
"stateTop": {
"$type": "dimension",
"px": 20,
"dp": 10.0,
"source": "locked index.html:194"
},
"statePaddingX": {
"$type": "dimension",
"px": 40,
"dp": 20.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"statePaddingY": {
"$type": "dimension",
"px": 34,
"dp": 17.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"stateGap": {
"$type": "dimension",
"px": 8,
"dp": 4.0,
"source": "locked index.html:195"
},
"stateIcon": {
"$type": "dimension",
"px": 36,
"dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"captionWidth": {
"$type": "dimension",
"px": 640,
"dp": 320.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"plateTop": {
"$type": "dimension",
"px": 14,
"dp": 7.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"plateSide": {
"$type": "dimension",
"px": 16,
"dp": 8.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"plateFade": {
"$type": "dimension",
"px": 46,
"dp": 23.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"ratingGap": {
"$type": "dimension",
"px": 20,
"dp": 10.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"ratingMarkGap": {
"$type": "dimension",
"px": 8,
"dp": 4.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSkeletonWidth": {
"$type": "dimension",
"px": 640,
"dp": 320.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSkeletonHeight": {
"$type": "dimension",
"px": 84,
"dp": 42.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSkeletonFactsWidth": {
"$type": "dimension",
"px": 460,
"dp": 230.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSkeletonFactsHeight": {
"$type": "dimension",
"px": 34,
"dp": 17.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSkeletonButtonWidth": {
"$type": "dimension",
"px": 260,
"dp": 130.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSkeletonGap": {
"$type": "dimension",
"px": 22,
"dp": 11.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSkeletonButtonGap": {
"$type": "dimension",
"px": 40,
"dp": 20.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"artGlyph": {
"$type": "dimension",
"px": 44,
"dp": 22.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardSheetLeft": {
"$type": "dimension",
"px": 22,
"dp": 11.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardSheetTop": {
"$type": "dimension",
"px": 18,
"dp": 9.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardSheetInset": {
"$type": "dimension",
"px": 36,
"dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSheetRight": {
"$type": "dimension",
"px": 180,
"dp": 90.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroSheetTop": {
"$type": "dimension",
"px": 150,
"dp": 75.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardBlur": {
"$type": "dimension",
"px": 26,
"dp": 13.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"heroBlur": {
"$type": "dimension",
"px": 60,
"dp": 30.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"progressInset": {
"$type": "dimension",
"px": 18,
"dp": 9.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"progressBottom": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"endTop": {
"$type": "dimension",
"px": 40,
"dp": 20.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"frameBottom": {
"$type": "dimension",
"px": 80,
"dp": 40.0,
"source": "locked index.html:1042"
},
"seeAllGap": {
"$type": "dimension",
"px": 8,
"dp": 4.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"seeAllIcon": {
"$type": "dimension",
"px": 48,
"dp": 24.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"toastPadding": {
"$type": "dimension",
"px": 28,
"dp": 14.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"toastTop": {
"$type": "dimension",
"px": 150,
"dp": 75.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"toastWidth": {
"$type": "dimension",
"px": 760,
"dp": 380.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"cardWashInset": {
"$type": "dimension",
"px": 30,
"dp": 15.0,
"source": "locked index.html:129"
},
"heroWashInset": {
"$type": "dimension",
"px": 80,
"dp": 40.0,
"source": "locked index.html:96"
},
"noteTop": {
"$type": "dimension",
"px": 14,
"dp": 7.0,
"source": "locked index.html:191"
},
"noteGap": {
"$type": "dimension",
"px": 16,
"dp": 8.0,
"source": "locked index.html:191"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Some files were not shown because too many files have changed in this diff Show more