Integrate reviewed Requests and Discover surfaces
Some checks failed
review/design-claude Changes requested: 13 blocking design/behaviour findings (see PR comment)
|
|
@ -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", "title-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "discover-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)
|
||||
|
|
@ -59,6 +59,7 @@ dependencies {
|
|||
implementation(libs.coil.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.robolectric)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
|
|||
279
wolfskin-design/discover-tokens.json
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
{
|
||||
"discover": {
|
||||
"sheetPaddingX": {
|
||||
"$type": "dimension",
|
||||
"px": 64,
|
||||
"dp": 32,
|
||||
"source": "locked index.html:227"
|
||||
},
|
||||
"sheetPaddingY": {
|
||||
"$type": "dimension",
|
||||
"px": 80,
|
||||
"dp": 40,
|
||||
"source": "locked index.html:227"
|
||||
},
|
||||
"sheetGap": {
|
||||
"$type": "dimension",
|
||||
"px": 18,
|
||||
"dp": 9,
|
||||
"source": "locked index.html:227"
|
||||
},
|
||||
"sheetSubGap": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7,
|
||||
"source": "locked index.html:229"
|
||||
},
|
||||
"sheetRowPadding": {
|
||||
"$type": "dimension",
|
||||
"px": 26,
|
||||
"dp": 13,
|
||||
"source": "locked index.html:231"
|
||||
},
|
||||
"sheetAvatar": {
|
||||
"$type": "dimension",
|
||||
"px": 60,
|
||||
"dp": 30,
|
||||
"source": "locked index.html:232"
|
||||
},
|
||||
"sheetRadio": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20,
|
||||
"source": "locked index.html:234"
|
||||
},
|
||||
"sheetRadioDot": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8,
|
||||
"source": "locked index.html:236"
|
||||
},
|
||||
"sheetPersonGap": {
|
||||
"$type": "dimension",
|
||||
"px": 24,
|
||||
"dp": 12,
|
||||
"source": "locked index.html:231"
|
||||
},
|
||||
"sheetButtonTop": {
|
||||
"$type": "dimension",
|
||||
"px": 12,
|
||||
"dp": 6,
|
||||
"source": "locked index.html:241"
|
||||
},
|
||||
"chipTop": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8,
|
||||
"source": "locked index.html:184"
|
||||
},
|
||||
"chipHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 68,
|
||||
"dp": 34,
|
||||
"source": "locked index.html:185"
|
||||
},
|
||||
"chipPadding": {
|
||||
"$type": "dimension",
|
||||
"px": 28,
|
||||
"dp": 14,
|
||||
"source": "locked index.html:185"
|
||||
},
|
||||
"chipGap": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8,
|
||||
"source": "locked index.html:184"
|
||||
},
|
||||
"heroTitleGap": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8,
|
||||
"source": "locked index.html:103"
|
||||
},
|
||||
"heroKickerGap": {
|
||||
"$type": "dimension",
|
||||
"px": 10,
|
||||
"dp": 5,
|
||||
"source": "locked index.html:101"
|
||||
},
|
||||
"heroMetaGap": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7,
|
||||
"source": "locked index.html:104"
|
||||
},
|
||||
"heroOverviewGap": {
|
||||
"$type": "dimension",
|
||||
"px": 30,
|
||||
"dp": 15,
|
||||
"source": "locked index.html:105"
|
||||
},
|
||||
"heroFactsGap": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11,
|
||||
"source": "locked index.html:104"
|
||||
},
|
||||
"heroFactsHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 38,
|
||||
"dp": 19,
|
||||
"source": "locked index.html:104"
|
||||
},
|
||||
"heroSheetRight": {
|
||||
"$type": "dimension",
|
||||
"px": 180,
|
||||
"dp": 90,
|
||||
"source": "locked index.html:97"
|
||||
},
|
||||
"heroSheetTop": {
|
||||
"$type": "dimension",
|
||||
"px": 140,
|
||||
"dp": 70,
|
||||
"source": "locked index.html:98"
|
||||
},
|
||||
"heroSheetWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 360,
|
||||
"dp": 180,
|
||||
"source": "locked index.html:98"
|
||||
},
|
||||
"heroSheetHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 540,
|
||||
"dp": 270,
|
||||
"source": "locked index.html:98"
|
||||
},
|
||||
"dotSize": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7,
|
||||
"source": "locked index.html:114"
|
||||
},
|
||||
"dotActive": {
|
||||
"$type": "dimension",
|
||||
"px": 44,
|
||||
"dp": 22,
|
||||
"source": "locked index.html:115"
|
||||
},
|
||||
"dotGap": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7,
|
||||
"source": "locked index.html:113"
|
||||
},
|
||||
"dotMargin": {
|
||||
"$type": "dimension",
|
||||
"px": 18,
|
||||
"dp": 9,
|
||||
"source": "locked index.html:113"
|
||||
},
|
||||
"dotChevron": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20,
|
||||
"source": "locked index.html:116"
|
||||
},
|
||||
"stateTop": {
|
||||
"$type": "dimension",
|
||||
"px": 20,
|
||||
"dp": 10,
|
||||
"source": "locked index.html:194"
|
||||
},
|
||||
"statePaddingX": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20,
|
||||
"source": "locked index.html:194"
|
||||
},
|
||||
"statePaddingY": {
|
||||
"$type": "dimension",
|
||||
"px": 34,
|
||||
"dp": 17,
|
||||
"source": "locked index.html:194"
|
||||
},
|
||||
"stateTextGap": {
|
||||
"$type": "dimension",
|
||||
"px": 24,
|
||||
"dp": 12,
|
||||
"source": "locked index.html:198"
|
||||
},
|
||||
"noteTop": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7,
|
||||
"source": "locked index.html:191"
|
||||
},
|
||||
"headerTop": {
|
||||
"$type": "dimension",
|
||||
"px": 150,
|
||||
"dp": 75,
|
||||
"source": "locked index.html:199"
|
||||
},
|
||||
"headerGap": {
|
||||
"$type": "dimension",
|
||||
"px": 24,
|
||||
"dp": 12,
|
||||
"source": "locked index.html:202"
|
||||
},
|
||||
"entryPaddingX": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11,
|
||||
"source": "locked index.html:149"
|
||||
},
|
||||
"entryPaddingY": {
|
||||
"$type": "dimension",
|
||||
"px": 20,
|
||||
"dp": 10,
|
||||
"source": "locked index.html:149"
|
||||
},
|
||||
"entryText": {
|
||||
"$type": "typography",
|
||||
"px": 30,
|
||||
"sp": 15,
|
||||
"weight": 800,
|
||||
"letterSpacingEm": -0.01,
|
||||
"$value": {
|
||||
"fontSize": "15sp",
|
||||
"fontWeight": 800
|
||||
},
|
||||
"source": "locked index.html:149"
|
||||
},
|
||||
"sheetRadioRing": {
|
||||
"$type": "dimension",
|
||||
"px": 3,
|
||||
"dp": 1.5,
|
||||
"source": "locked index.html:234"
|
||||
},
|
||||
"skeletonNameWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 260,
|
||||
"dp": 130,
|
||||
"source": "locked index.html:1851"
|
||||
},
|
||||
"skeletonNameHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 34,
|
||||
"dp": 17,
|
||||
"source": "locked index.html:1851"
|
||||
},
|
||||
"sheetSubTop": {"$type": "dimension", "px": -8, "dp": -4, "source": "locked index.html:229"},
|
||||
"sheetRatingTop": {"$type": "dimension", "px": -10, "dp": -5, "source": "locked index.html:168"},
|
||||
"sheetRatingBottom": {"$type": "dimension", "px": 6, "dp": 3, "source": "locked index.html:168"},
|
||||
"sheetLabelTop": {"$type": "dimension", "px": 8, "dp": 4, "source": "locked index.html:230"},
|
||||
"noteIcon": {"$type": "dimension", "px": 30, "dp": 15, "source": "locked index.html:192"},
|
||||
"noteGap": {"$type": "dimension", "px": 16, "dp": 8, "source": "locked index.html:191"},
|
||||
"frameBottomMin": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:1042"},
|
||||
"anchorFitTop": {"$type": "dimension", "px": 700, "dp": 350, "source": "locked index.html:1062"},
|
||||
"anchorBottomClearance": {"$type": "dimension", "px": 40, "dp": 20, "source": "locked index.html:1062"},
|
||||
"reasonOpacity": {"$type": "number", "$value": 0.8, "source": "locked index.html:240"},
|
||||
"stageGap": {"$type": "dimension", "px": 10, "dp": 5, "source": "locked index.html:220"},
|
||||
"entryBorderOpacity": {"$type": "number", "$value": 0.08, "source": "locked index.html:149"},
|
||||
"ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"},
|
||||
"ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"},
|
||||
"ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"},
|
||||
"ambientSaturation": {"$type": "number", "$value": 1.2, "source": "locked index.html:96"},
|
||||
"ambientBrightness": {"$type": "number", "$value": 0.55, "source": "locked index.html:96"}
|
||||
}
|
||||
}
|
||||
31
wolfskin-design/generate_request_marks.py
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Rasterize locked prototype rating marks at the Coming-in row's native pixel size."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import cairosvg
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SOURCE = ROOT / "request-marks"
|
||||
OUTPUT = ROOT / "src/main/res/drawable-nodpi"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Keep the original SVGs and record hashes alongside generated television-size PNGs."""
|
||||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||
tokens = json.loads((ROOT / "tokens.json").read_text())
|
||||
supplement = json.loads((ROOT / "shell-tokens.json").read_text())
|
||||
height = int(tokens["type"]["label"]["px"])
|
||||
wordmark_ratio = supplement["requests"]["ratingsWordmarkRatio"]["$value"]
|
||||
manifest = []
|
||||
for source in sorted(SOURCE.glob("*.svg")):
|
||||
target = OUTPUT / f"coming_in_rating_{source.stem}.png"
|
||||
width = round(height * wordmark_ratio) if source.stem in ("imdb", "tmdb") else height
|
||||
cairosvg.svg2png(url=str(source), write_to=str(target), output_width=width, output_height=height)
|
||||
for path in (source, target):
|
||||
manifest.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(ROOT)}")
|
||||
(ROOT / "request-marks.sha256").write_text("\n".join(manifest) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -114,9 +114,10 @@ def main() -> None:
|
|||
tokens = json.loads((ROOT / "tokens.json").read_text())
|
||||
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
|
||||
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
|
||||
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
|
||||
kotlin, css = render_tokens(tokens)
|
||||
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
||||
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "generate_tokens.py"]
|
||||
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "discover-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()))
|
||||
|
|
|
|||
12
wolfskin-design/request-marks.sha256
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
01aa8a3c3e9db48f0e1c6af25056940986784315bf66f48a8120a7317bf6d75a request-marks/fresh.svg
|
||||
743b6139dc12019d0940568869065722593f44ce416a7e9c7b8d9ef92942bb3f src/main/res/drawable-nodpi/coming_in_rating_fresh.png
|
||||
27f93b15a075b015e2448eca7949f7a3da03c37fbf26cecf6df3bdab45fda993 request-marks/imdb.svg
|
||||
a738afd57519b1b4a38192927b48bd45755ce75e4673c6b12b9b3e0c2e388372 src/main/res/drawable-nodpi/coming_in_rating_imdb.png
|
||||
510d0f9fb77d72190ebf03208f04215a4e86f71e165488518ac75aa49cf4a79d request-marks/rotten.svg
|
||||
0aade711ba57b6b201c7395ece97108df928b6076d8120404cdf4155a4242632 src/main/res/drawable-nodpi/coming_in_rating_rotten.png
|
||||
b8dcf4d27eba2446380980c96d82b1f64aa6859fa2ea1cdab867f849f9fec4fd request-marks/spilled.svg
|
||||
fa4f50258009cd1d16f0d0f5a3c216dc88a57409b1bccc379512ae914cc9d714 src/main/res/drawable-nodpi/coming_in_rating_spilled.png
|
||||
d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b request-marks/tmdb.svg
|
||||
8e1cb507f876f324bb5c53bf4e60775712f1fdddbffe94fd249e6e74099e045e src/main/res/drawable-nodpi/coming_in_rating_tmdb.png
|
||||
2ad511c7a101a7f2f8810434911df8c13cc126fa691272242037237172600bc3 request-marks/upright.svg
|
||||
6ff8a05501bc1c15b96c0130271c41baaa2ddb74f64916f58b9e7f918931d789 src/main/res/drawable-nodpi/coming_in_rating_upright.png
|
||||
20
wolfskin-design/request-marks/fresh.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata id="metadata3396">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:title/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g id="layer1" fill="#f93208">
|
||||
<path id="path3412" d="m20.154 40.829c-28.149 27.622-13.657 61.011-5.734 71.931 35.254 41.954 92.792 25.339 111.89-5.9071 4.7608-8.2027 22.554-53.467-23.976-78.009z"/>
|
||||
<path id="path3471" d="m39.613 39.265 4.7778-8.8607 28.406-5.0384 11.119 9.2082z"/>
|
||||
</g>
|
||||
<g id="layer2">
|
||||
<path id="path3437" d="m39.436 8.5696 8.9682-5.2826 6.7569 15.479c3.7925-6.3226 13.79-16.316 24.939-4.6684-4.7281 1.2636-7.5161 3.8553-7.7397 8.4768 15.145-4.1697 31.343 3.2127 33.539 9.0911-10.951-4.314-27.695 10.377-41.771 2.334 0.009 15.045-12.617 16.636-19.902 17.076 2.077-4.996 5.591-9.994 1.474-14.987-7.618 8.171-13.874 10.668-33.17 4.668 4.876-1.679 14.843-11.39 24.448-11.425-6.775-2.467-12.29-2.087-17.814-1.475 2.917-3.961 12.149-15.197 28.625-8.476z" fill="#02902e"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
wolfskin-design/request-marks/imdb.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
wolfskin-design/request-marks/rotten.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="145" height="140"><path fill="#0fc755" d="M47.4 35.342c-13.607-7.935-12.32-25.203 2.097-31.88 26.124-6.531 29.117 13.78 22.652 30.412-6.542 24.11 18.095 23.662 19.925 10.067 3.605-18.412 19.394-26.695 31.67-16.359 12.598 12.135 7.074 36.581-17.827 34.187-16.03-1.545-19.552 19.585.839 21.183 32.228 1.915 42.49 22.167 31.04 35.865-15.993 15.15-37.691-4.439-45.512-19.505-6.8-9.307-17.321.11-13.423 6.502 12.983 19.465 2.923 31.229-10.906 30.62-13.37-.85-20.96-9.06-13.214-29.15 3.897-12.481-8.595-15.386-16.57-5.45-11.707 19.61-28.865 13.68-33.976 4.19-3.243-7.621-2.921-25.846 24.119-23.696 16.688 4.137 11.776-12.561-.63-13.633-9.245-.443-30.501-7.304-22.86-24.54 7.34-11.056 24.958-11.768 33.348 6.293 3.037 4.232 8.361 11.042 18.037 5.033 3.51-5.197 1.21-13.9-8.809-20.135z"/></svg>
|
||||
|
After Width: | Height: | Size: 833 B |
46
wolfskin-design/request-marks/spilled.svg
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata id="metadata3886">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:title/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g id="g3998" transform="matrix(.0042962 .96345 -.96345 .0042962 135.21 .16894)"></g>
|
||||
<g id="layer4">
|
||||
<path id="path3462" d="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z" fill="#07a23b"/>
|
||||
<g fill="#fff">
|
||||
<path id="path3505" d="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"/>
|
||||
<path id="path3551" d="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"/>
|
||||
<path id="path3553" d="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"/>
|
||||
<path id="path3555" d="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="layer5">
|
||||
<path id="path4031" d="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z" fill="#03621e"/>
|
||||
</g>
|
||||
<g id="layer6" fill="#fdd600">
|
||||
<path id="path4136" d="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"/>
|
||||
<path id="path4174" d="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/>
|
||||
<path id="path4176" d="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"/>
|
||||
<path id="path4178" d="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"/>
|
||||
<path id="path4180" d="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"/>
|
||||
<path id="path4182" d="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/>
|
||||
<path id="path4184" d="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"/>
|
||||
<path id="path4186" d="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"/>
|
||||
<path id="path4188" d="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"/>
|
||||
<path id="path4190" d="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"/>
|
||||
<path id="path4192" d="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/>
|
||||
<path id="path4194" d="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/>
|
||||
<path id="path4196" d="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"/>
|
||||
<path id="path4198" d="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"/>
|
||||
<path id="path4200" d="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"/>
|
||||
</g>
|
||||
<g id="layer7">
|
||||
<path id="path4203" d="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z" fill="#09a339"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
1
wolfskin-design/request-marks/tmdb.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
wolfskin-design/request-marks/upright.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="106.25" height="140"><path fill="#fa3106" d="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"/><g fill="#fff"><path d="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"/></g><g fill="#ffd600"><circle cx="13.213" cy="31.252" r="6.816"/><circle cx="22.022" cy="27.687" r="6.607"/><circle cx="30.359" cy="19.769" r="5.925"/><circle cx="34.973" cy="15.155" r="6.03"/><circle cx="45.093" cy="17.095" r="4.929"/><circle cx="51.123" cy="9.597" r="6.24"/><circle cx="61.19" cy="9.387" r="6.554"/><circle cx="67.954" cy="13.635" r="4.929"/><circle cx="76.081" cy="17.672" r="5.925"/><circle cx="78.913" cy="22.706" r="4.352"/><circle cx="83.475" cy="26.324" r="5.243"/><circle cx="88.194" cy="34.398" r="5.768"/><path d="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,4 +1,48 @@
|
|||
{
|
||||
"requests": {
|
||||
"sheetPaddingY": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" },
|
||||
"sheetPaddingX": { "$type": "dimension", "px": 64, "dp": 32, "source": "index.html:227" },
|
||||
"sheetGap": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:227" },
|
||||
"sheetTitleLineHeight": { "$type": "dimension", "px": 56, "dp": 28, "source": "index.html:228; locked st-083/st-086: content begins at 154px after 80px padding and 18px gap" },
|
||||
"sheetTitleBaseline": { "$type": "dimension", "px": 44, "dp": 22, "source": "locked st-083: modal ink-bottom 123px, native baseline boundary 124px minus 80px padding" },
|
||||
"titleLineHeight": { "$type": "dimension", "px": 39, "dp": 19.5, "source": "index.html:250; 30px font with 1.3 line box" },
|
||||
"titleBaseline": { "$type": "dimension", "px": 31, "dp": 15.5, "source": "locked st-082/st-086: ink-bottom 45px, 15px text-block inset, baseline boundary 1px below ink" },
|
||||
"factsLineHeight": { "$type": "dimension", "px": 31.2, "dp": 15.6, "source": "index.html:251; 24px font with 1.3 line box" },
|
||||
"factsBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 77px, 15px block inset, 39px title box and 1px baseline boundary" },
|
||||
"stageLineHeight": { "$type": "dimension", "px": 39.6, "dp": 19.8, "source": "index.html:251-252; inline-flex stage within the 28px/1.3 parent strut" },
|
||||
"stageLabelLineHeight": { "$type": "dimension", "px": 33.8, "dp": 16.9, "source": "index.html:220; 26px font with 1.3 leading" },
|
||||
"stageBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 112px, 15px block inset, 39px title, 31.2px facts, 4px margin and 1px baseline boundary" },
|
||||
"emptyMargin": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:1905" },
|
||||
"rowPaddingY": { "$type": "dimension", "px": 14, "dp": 7, "source": "index.html:247" },
|
||||
"rowPaddingX": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:247" },
|
||||
"rowGap": { "$type": "dimension", "px": 24, "dp": 12, "source": "index.html:247" },
|
||||
"listGap": { "$type": "dimension", "px": 6, "dp": 3, "source": "index.html:255" },
|
||||
"stageMargin": { "$type": "dimension", "px": 4, "dp": 2, "source": "index.html:252" },
|
||||
"stageGlyph": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:221" },
|
||||
"stageGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:220" },
|
||||
"readinessGapX": { "$type": "dimension", "px": 22, "dp": 11, "source": "index.html:312" },
|
||||
"readinessGapY": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:312" },
|
||||
"readinessMargin": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:312" },
|
||||
"readinessGlyph": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:314" },
|
||||
"chevron": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:317" },
|
||||
"chevronAlpha": { "$type": "number", "$value": 0.6, "source": "index.html:317" },
|
||||
"receiptPaddingY": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:243" },
|
||||
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
|
||||
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
|
||||
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
|
||||
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
|
||||
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
|
||||
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },
|
||||
"skeletonSubtitleWidth": { "$type": "dimension", "px": 240, "dp": 120, "source": "index.html:1902" },
|
||||
"skeletonSubtitleHeight": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:1902" },
|
||||
"skeletonGap": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:1902" },
|
||||
"ratingsGap": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:156" },
|
||||
"ratingsEntryGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:157" },
|
||||
"shadowBlur": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" },
|
||||
"shadowOffset": { "$type": "dimension", "px": -30, "dp": -15, "source": "index.html:227" },
|
||||
"shadowColor": { "$type": "color", "$value": "#00000080", "source": "index.html:227" },
|
||||
"ratingsWordmarkRatio": { "$type": "number", "$value": 1.8, "source": "index.html:160" }
|
||||
},
|
||||
"shell": {
|
||||
"navLabelFade": {
|
||||
"$type": "duration",
|
||||
|
|
@ -327,7 +371,6 @@
|
|||
"captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"},
|
||||
"captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"},
|
||||
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
|
||||
"badgeBorder": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"},
|
||||
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
|
||||
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},
|
||||
"plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"},
|
||||
|
|
@ -356,6 +399,11 @@
|
|||
"tailIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:300"},
|
||||
"tailPadding": {"$type":"dimension","px":20,"dp":10,"source":"index.html:299"},
|
||||
"tailGap": {"$type":"dimension","px":10,"dp":5,"source":"index.html:299"},
|
||||
"badgeRing": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"},
|
||||
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
|
||||
"seeAllGap": {"$type":"dimension","px":8,"dp":4,"source":"index.html:146"},
|
||||
"tailDescription": {"$type":"typography","px":24,"sp":12,"weight":500,"$value":{"fontWeight":500},"source":"index.html:301"},
|
||||
"headerSubtitle": {"$type":"typography","px":30,"sp":15,"weight":400,"$value":{"fontWeight":400},"source":"index.html:201"},
|
||||
"imageReads": {"$type":"number","$value":4,"source":"spec 4.9, tokens.json image.concurrentReads"},
|
||||
"unknownCriticOpacity": {"$type":"number","$value":0.55,"source":"spec 4.7, index.html:161"},
|
||||
"fallbackPadding": {"$type":"dimension","px":22,"dp":11,"source":"index.html:130"},
|
||||
|
|
@ -366,5 +414,19 @@
|
|||
"fallbackSaturationBottom": {"$type":"number","$value":0.26,"source":"index.html:130"},
|
||||
"fallbackLightnessBottom": {"$type":"number","$value":0.1,"source":"index.html:130"},
|
||||
"fallbackIconOpacity": {"$type":"number","$value":0.45,"source":"index.html:132"}
|
||||
},
|
||||
"lift": {
|
||||
"cardBlur": {"$type":"dimension","px":50,"dp":25,"source":"index.html:134; spec 4.5"},
|
||||
"cardY": {"$type":"dimension","px":18,"dp":9,"source":"index.html:134"},
|
||||
"cardOpacity": {"$type":"number","$value":0.65,"source":"index.html:134"},
|
||||
"buttonBlur": {"$type":"dimension","px":40,"dp":20,"source":"index.html:110; spec 4.5"},
|
||||
"buttonY": {"$type":"dimension","px":12,"dp":6,"source":"spec 4.5"},
|
||||
"buttonOpacity": {"$type":"number","$value":0.5,"source":"spec 4.5"},
|
||||
"heroBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:97"},
|
||||
"heroY": {"$type":"dimension","px":30,"dp":15,"source":"index.html:97"},
|
||||
"heroOpacity": {"$type":"number","$value":0.6,"source":"index.html:97"},
|
||||
"sheetBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:227"},
|
||||
"sheetX": {"$type":"dimension","px":-30,"dp":-15,"source":"index.html:227"},
|
||||
"sheetOpacity": {"$type":"number","$value":0.5,"source":"index.html:227"}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G
|
||||
|
||||
/** Shelves and continuous grids share the same recoverable tail instead of a page selector. */
|
||||
@Composable
|
||||
fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
||||
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
|
||||
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted,
|
||||
style = G.tailDescription, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Debug assets and native screenshots consume the same projection of the frozen prototype. */
|
||||
fun readComingInFixture(json: String, poster: (String) -> Any?): List<ComingInRequest> {
|
||||
val items = JSONObject(json).getJSONArray("items")
|
||||
return List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
val scores = item.getJSONArray("ratings")
|
||||
ComingInRequest(
|
||||
id = item.getString("id"), mediaType = item.getString("mediaType"), tmdbId = item.getLong("tmdbId"),
|
||||
title = item.getString("title"), requesterKey = item.getString("requesterKey"), requesterName = item.getString("requesterName"),
|
||||
actorKey = item.getString("actorKey"), actorName = item.getString("actorName"), stage = ComingInStage.valueOf(item.getString("stage")),
|
||||
poster = poster(item.getString("poster")),
|
||||
ratings = List(scores.length()) { scoreIndex ->
|
||||
val score = scores.getJSONObject(scoreIndex)
|
||||
ComingInRating(score.getString("mark"), score.getDouble("score"), score.getBoolean("percentage"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** A CSS line box has its own height and baseline, independent of native leading rounding. */
|
||||
internal fun Modifier.comingInLineBox(height: Dp, baseline: Dp): Modifier = layout { measurable, constraints ->
|
||||
val text = measurable.measure(constraints.copy(minHeight = 0))
|
||||
val baselineOffset = baseline.roundToPx() - text[FirstBaseline]
|
||||
layout(text.width, height.roundToPx()) {
|
||||
text.placeRelative(0, baselineOffset)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.res.painterResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
||||
|
||||
/** Percentages remain whole; ten-point scores use the television's locale. */
|
||||
fun formatComingInRating(rating: ComingInRating, locale: Locale = Locale.getDefault()): String =
|
||||
if (rating.percentage) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
|
||||
|
||||
/** This row always displays the full supplier set rather than the card plate's shortened prefix. */
|
||||
@Composable
|
||||
fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsGap)) {
|
||||
ratings.forEach { rating ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) {
|
||||
val mark = when (rating.mark) {
|
||||
"fresh" -> R.drawable.coming_in_rating_fresh
|
||||
"rotten" -> R.drawable.coming_in_rating_rotten
|
||||
"upright" -> R.drawable.coming_in_rating_upright
|
||||
"spilled" -> R.drawable.coming_in_rating_spilled
|
||||
"imdb" -> R.drawable.coming_in_rating_imdb
|
||||
else -> R.drawable.coming_in_rating_tmdb
|
||||
}
|
||||
val wide = rating.mark == "imdb" || rating.mark == "tmdb"
|
||||
Image(painterResource(mark), contentDescription = rating.mark,
|
||||
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f)
|
||||
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3),
|
||||
contentScale = ContentScale.Fit)
|
||||
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ratingMarkShape = RoundedCornerShape(WolfskinTokens.Radius.thumb)
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
||||
|
||||
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
|
||||
@Composable
|
||||
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val state = shell.requests
|
||||
val data = state.snapshot
|
||||
LaunchedEffect(state.focusedId, data.signedIn, data.status) {
|
||||
state.awaitFocusedRatings()
|
||||
}
|
||||
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
|
||||
Column(modifier.fillMaxHeight().width(L.sheetWidth)
|
||||
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
|
||||
.drawWithCache {
|
||||
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = C.surface.toArgb()
|
||||
setShadowLayer(T.shadowBlur.toPx(), T.shadowOffset.toPx(), 0f, T.shadowColor.toArgb())
|
||||
}
|
||||
onDrawBehind {
|
||||
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
|
||||
.testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) {
|
||||
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
|
||||
when {
|
||||
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
|
||||
}
|
||||
SheetButton("Close") { shell.closeRequests() }
|
||||
}
|
||||
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
|
||||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(horizontal = T.receiptPaddingX, vertical = T.receiptPaddingY),
|
||||
verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowPaddingY)) {
|
||||
Glyph("alert", C.warn, Modifier.size(T.receiptGlyph))
|
||||
Text("Requests unavailable", style = ComingInTypography.stateTitle, color = C.warn)
|
||||
}
|
||||
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
|
||||
}
|
||||
SheetButton("Try again", primary = true) { shell.retryRequests() }
|
||||
}
|
||||
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
|
||||
else -> {
|
||||
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
|
||||
val start = state.windowStart
|
||||
val visibleRows = remember(data, start, state.windowSize) {
|
||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
||||
}
|
||||
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
itemsIndexed(visibleRows,
|
||||
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
|
||||
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComingInSkeleton() {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||
Box(Modifier.size(L.thumbWidth, L.thumbHeight).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.thumb)))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(T.skeletonGap)) {
|
||||
Box(Modifier.size(T.skeletonTitleWidth, T.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
|
||||
Box(Modifier.size(T.skeletonSubtitleWidth, T.skeletonSubtitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thumbnail slots do not resize when the text or the readiness block grows. */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
|
||||
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
val secondary = if (focused) C.onFocus else C.muted
|
||||
val context = LocalContext.current
|
||||
val request = remember(item.poster, context) { LibraryImages.request(context, item.poster, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt()) }
|
||||
val posterReady = remember(request) { mutableStateOf(false) }
|
||||
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.pointerInput(item.id) {
|
||||
awaitPointerEventScope {
|
||||
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
|
||||
}
|
||||
}.clickable(onClick = onClick)
|
||||
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
|
||||
AsyncImage(model = request, imageLoader = LibraryImages.loader(context), contentDescription = null,
|
||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
|
||||
.background(if (posterReady.value) Color.Transparent else C.surface3),
|
||||
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
||||
style = ComingInTypography.caption, color = secondary)
|
||||
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||
Glyph(item.stage.glyph, secondary, Modifier.size(T.stageGlyph))
|
||||
Text(item.stage.label, Modifier.comingInLineBox(T.stageLabelLineHeight, T.stageBaseline), style = ComingInTypography.label, color = secondary)
|
||||
}
|
||||
if (showRatings) ComingInRatings(item.ratings, foreground)
|
||||
}
|
||||
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
||||
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
||||
item.stage.readiness.forEachIndexed { index, word ->
|
||||
Row {
|
||||
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
|
||||
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
|
||||
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
|
||||
}
|
||||
}
|
||||
|
||||
private val READINESS_NAMES = listOf("Request", "Download", "Library", "Playback")
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
/** Household acquisition stages, ordered independently from their display wording. */
|
||||
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
|
||||
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
|
||||
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
|
||||
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
|
||||
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
|
||||
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
|
||||
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
|
||||
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
|
||||
}
|
||||
|
||||
/** Ratings have already passed the supplier's observed-score and vote checks. */
|
||||
data class ComingInRating(val mark: String, val score: Double, val percentage: Boolean = false)
|
||||
|
||||
/** Requests carry authenticated actor evidence, never the current viewer as a substitute. */
|
||||
data class ComingInRequest(
|
||||
val id: String,
|
||||
val mediaType: String,
|
||||
val tmdbId: Long,
|
||||
val title: String,
|
||||
val requesterKey: String,
|
||||
val requesterName: String,
|
||||
val actorKey: String?,
|
||||
val actorName: String?,
|
||||
val stage: ComingInStage,
|
||||
val poster: Any? = null,
|
||||
val ratings: List<ComingInRating> = emptyList(),
|
||||
) {
|
||||
val ratingKey = "$mediaType:$tmdbId"
|
||||
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
|
||||
fun actor(viewer: ShellPerson): String = when {
|
||||
actorKey == "shared" -> "Shared"
|
||||
actorKey != null && actorKey == viewer.key -> "you"
|
||||
else -> actorName ?: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
enum class ComingInStatus { Ready, Loading, Unavailable }
|
||||
|
||||
/** The provider retains saved rows when refresh fails; an unavailable empty result is not empty success. */
|
||||
data class ComingInSnapshot(
|
||||
val items: List<ComingInRequest> = emptyList(),
|
||||
val status: ComingInStatus = ComingInStatus.Unavailable,
|
||||
val partial: Boolean = false,
|
||||
val signedIn: Boolean = false,
|
||||
) {
|
||||
val ordered = items.sortedBy { it.stage.rank }
|
||||
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
|
||||
}
|
||||
|
||||
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
|
||||
class ComingInState {
|
||||
var snapshot by mutableStateOf(ComingInSnapshot())
|
||||
private set
|
||||
var focusedId by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var expanded by mutableStateOf(false)
|
||||
private set
|
||||
var ratingsVisible by mutableStateOf(false)
|
||||
private set
|
||||
private val cachedRatings = mutableSetOf<String>()
|
||||
val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0)
|
||||
val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index)
|
||||
val windowSize: Int get() = if (expanded) 4 else 5
|
||||
val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0))
|
||||
|
||||
fun update(value: ComingInSnapshot) {
|
||||
snapshot = value
|
||||
if (!value.signedIn) cachedRatings.clear()
|
||||
if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id)
|
||||
ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings
|
||||
}
|
||||
|
||||
fun focus(id: String?) {
|
||||
focusedId = id
|
||||
expanded = false
|
||||
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
|
||||
}
|
||||
|
||||
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
|
||||
suspend fun awaitFocusedRatings() {
|
||||
val item = focused ?: return
|
||||
if (!snapshot.signedIn || snapshot.status != ComingInStatus.Ready || item.ratings.isEmpty() || ratingsVisible) return
|
||||
delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
if (focusedId != item.id || !snapshot.signedIn || snapshot.status != ComingInStatus.Ready) return
|
||||
cachedRatings.add(item.ratingKey)
|
||||
ratingsVisible = true
|
||||
}
|
||||
|
||||
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */
|
||||
fun key(key: ShellKey): Boolean {
|
||||
when (key) {
|
||||
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
||||
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
|
||||
ShellKey.Right -> if (focused != null) expanded = true
|
||||
ShellKey.Left -> expanded = false
|
||||
ShellKey.Ok -> return focused != null
|
||||
else -> Unit
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
/** CSS line boxes retain their leading even on single lines; trimming changes sheet geometry. */
|
||||
internal object ComingInTypography {
|
||||
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||
val title = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox)
|
||||
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox)
|
||||
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox)
|
||||
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox)
|
||||
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox)
|
||||
val stateTitle = WolfskinTokens.Type.stateTitle.copy(lineHeightStyle = lineBox, fontWeight = FontWeight.Bold)
|
||||
val readiness = caption.copy(fontWeight = FontWeight.Bold)
|
||||
val rating = WolfskinTokens.Type.label.copy(fontFeatureSettings = "tnum")
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Native captures provide decoded local art; neither posters nor heroes perform image HTTP reads. */
|
||||
val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null }
|
||||
|
||||
/** Decode requests use the actual art slot, with RGB565 only for upright posters. */
|
||||
@Composable
|
||||
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false) {
|
||||
if (path == null) return
|
||||
val bitmap = LocalDiscoverArt.current?.invoke(path)
|
||||
val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) }
|
||||
val filter = remember(dim) { if (dim) ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||
setToSaturation(D.ambientSaturation)
|
||||
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
||||
}) else null }
|
||||
val context = LocalContext.current
|
||||
val request = remember(context, path, poster) {
|
||||
ImageRequest.Builder(context).data(path).size(if (poster) 256 else 1280, if (poster) 384 else 720).allowRgb565(poster).build()
|
||||
}
|
||||
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||
else AsyncImage(
|
||||
model = request,
|
||||
imageLoader = LibraryImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
|
||||
)
|
||||
}
|
||||
|
||||
/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */
|
||||
@Composable
|
||||
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
|
||||
if (title.posterOnly) {
|
||||
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
|
||||
.wrapContentSize(Alignment.TopStart, unbounded = true)
|
||||
.size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2)
|
||||
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true)
|
||||
DiscoverArtwork(title.poster, Modifier.offset(
|
||||
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
|
||||
if (detail) L.navBand else D.heroSheetTop,
|
||||
).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight)
|
||||
.dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow(
|
||||
radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY)))
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
|
||||
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().drawWithCache {
|
||||
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)
|
||||
onDrawBehind { drawRect(scrim) }
|
||||
})
|
||||
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Missing art is deterministic and quiet; it does not imply a provider read succeeded. */
|
||||
fun discoverFallback(title: String): Brush {
|
||||
val hue = title.fold(0) { hash, char -> (hash * 31 + char.code) % 360 }.toFloat()
|
||||
return Brush.linearGradient(listOf(Color.hsl(hue, .22f, .22f), Color.hsl(hue, .26f, .10f)))
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
/** Discover metadata projects into the same poster and grid contract as Libraries. */
|
||||
internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle {
|
||||
val receipts = state.requests[key].orEmpty()
|
||||
val held = state.shell.person.key in heldBy
|
||||
val availability = when {
|
||||
held -> "In your library"
|
||||
receipts.isNotEmpty() -> {
|
||||
val recipients = receipts.joinToString(", ") { request ->
|
||||
if (request.requester == state.shell.person.key) "you"
|
||||
else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty()
|
||||
}
|
||||
"Requested for $recipients · ${receipts.first().stage}"
|
||||
}
|
||||
heldBy.isNotEmpty() -> "In ${heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }}’s library"
|
||||
else -> ""
|
||||
}
|
||||
return LibraryTitle(id = key, title = title, type = type, year = year,
|
||||
runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster,
|
||||
ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
|
||||
badge = when {
|
||||
held -> "held"
|
||||
receipts.isEmpty() -> null
|
||||
receipts.all { it.stage == "Failed" } -> "failed"
|
||||
else -> "requested"
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Projects supplied prototype data; this class never loads a bundled fixture or contacts a server. */
|
||||
class DiscoverFixture(json: String, private val artPath: (String) -> String = { it }) {
|
||||
private val profiles = JSONObject(json).getJSONArray("profiles").objects()
|
||||
private val holdings = profiles.associate { profile ->
|
||||
profile.getString("key") to listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
|
||||
.map { identity(it) }.toSet()
|
||||
}
|
||||
private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title)
|
||||
val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll")
|
||||
.mapIndexed { index, name -> DiscoverService(index.toLong(), name, listOf(355f, 200f, 230f, 265f, 0f, 20f, 140f, 25f, 30f)[index]) }
|
||||
|
||||
fun snapshot(person: String): DiscoverSnapshot {
|
||||
val profile = profiles.first { it.getString("key") == person }
|
||||
val picks = profile.getJSONArray("discover").objects().map(::title)
|
||||
val played = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
|
||||
.filter { it.optBoolean("played") }
|
||||
val groups = linkedMapOf<String, Pair<String, MutableList<DiscoverTitle>>>()
|
||||
picks.sortedBy { if (it.type == "movie") 0 else 1 }.forEach { pick ->
|
||||
played.map { it to normalizedGenres(it.getJSONArray("genres").strings()).intersect(normalizedGenres(pick.genres)).size }
|
||||
.filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) ->
|
||||
groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick)
|
||||
}
|
||||
}
|
||||
val lanes = services.mapIndexed { index, service ->
|
||||
val source = rotate(pool, index * 7).sortedByDescending { it.year }
|
||||
DiscoverLane("new-$index", "New on ${service.name}", source.take(12), fullTitles = source)
|
||||
} + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size, fullTitles = pool)) +
|
||||
listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").map { genre ->
|
||||
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
|
||||
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
|
||||
}
|
||||
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
||||
}
|
||||
|
||||
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
|
||||
val base = rotate(pool.filter { it.type == type }, service.id.toInt() * 5)
|
||||
val noun = if (type == "movie") "movies" else "series"
|
||||
val genres = base.flatMap { it.genres }.groupingBy { it }.eachCount().entries.sortedByDescending { it.value }.take(3)
|
||||
return listOf(
|
||||
DiscoverLane("sv-pop", "Popular $noun", base.drop(1).take(20)),
|
||||
DiscoverLane("sv-new", "New $noun", base.sortedByDescending { it.year }.take(20)),
|
||||
DiscoverLane("sv-top", "Top rated $noun", rotate(base, 11).take(20)),
|
||||
) + genres.map { genre -> DiscoverLane("sv-g-${genre.key}", "${genre.key} $noun", base.filter { genre.key in it.genres }.take(20)) }
|
||||
}
|
||||
|
||||
fun find(name: String): DiscoverTitle = (pool + profiles.flatMap { p -> listOf("movies", "shows", "anime").flatMap { p.getJSONArray(it).objects() }.map(::title) }).first { it.title == name }
|
||||
|
||||
private fun title(item: JSONObject): DiscoverTitle = DiscoverTitle(
|
||||
tmdb = item.optLong("tmdbId", item.getString("id").hashCode().toLong().let { if (it < 0) -it else it }),
|
||||
type = item.getString("type"), title = item.getString("title"), year = item.nullableInt("year"),
|
||||
genres = item.getJSONArray("genres").strings(), overview = item.optString("overview"),
|
||||
poster = item.nullableString("poster")?.let(artPath), backdrop = item.nullableString("backdrop")?.let(artPath),
|
||||
posterOnly = item.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop",
|
||||
runtimeSeconds = item.nullableInt("runtimeSeconds"), seasonCount = item.nullableInt("seasonCount"),
|
||||
ratings = ratings(item.optJSONObject("ratings")), heldBy = holdings.filterValues { identity(item) in it }.keys,
|
||||
)
|
||||
|
||||
private fun ratings(json: JSONObject?): List<DiscoverRating> = buildList {
|
||||
if (json == null) return@buildList
|
||||
for ((field, source, label) in listOf(Triple("rtCritics", "rtCritics", "rtCriticsLabel"), Triple("rtAudience", "rtAudience", "rtAudienceLabel"), Triple("imdb", "imdb", ""))) {
|
||||
if (!json.isNull(field)) add(DiscoverRating(source, json.getDouble(field), json.nullableString(label)))
|
||||
}
|
||||
if (isEmpty() && !json.isNull("tmdb") && json.optInt("tmdbVotes") > 0) add(DiscoverRating("tmdb", json.getDouble("tmdb")))
|
||||
}
|
||||
|
||||
private fun identity(json: JSONObject): String = "${json.getString("type")}:${json.optLong("tmdbId", json.getString("id").hashCode().toLong().let { if (it < 0) -it else it })}"
|
||||
private fun <T> rotate(items: List<T>, offset: Int): List<T> = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size)
|
||||
private fun normalizedGenres(genres: List<String>): Set<String> = genres.flatMap {
|
||||
when (it) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(it) }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }
|
||||
private fun JSONArray.strings(): List<String> = List(length()) { getString(it) }
|
||||
private fun JSONObject.nullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
|
||||
private fun JSONObject.nullableInt(key: String): Int? = if (isNull(key)) null else getInt(key)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlin.math.ceil
|
||||
|
||||
/** Dropped seed groups must not suppress titles from the later catalogue lanes. */
|
||||
internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<DiscoverTitle>, filter: String, hidden: Map<String, DiscoverTitle>): List<DiscoverLane> {
|
||||
fun matches(item: DiscoverTitle) = filter == "all" || item.type == filter
|
||||
val seen = heroes.mapTo(mutableSetOf()) { it.key }
|
||||
val result = mutableListOf<DiscoverLane>()
|
||||
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
|
||||
if (snapshot.status == DiscoverStatus.Ready) {
|
||||
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
|
||||
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
|
||||
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
|
||||
val count = all.count { it.key !in seen }
|
||||
val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt()))
|
||||
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
|
||||
}
|
||||
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
|
||||
groups.forEachIndexed { index, seed ->
|
||||
val items = take(seed.items, size)
|
||||
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
||||
}
|
||||
}
|
||||
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
|
||||
result.forEach { lane -> lane.titles.forEach { displayed.add(it.key) } }
|
||||
if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList())
|
||||
snapshot.lanes.forEach { lane ->
|
||||
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
|
||||
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
|
||||
if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) {
|
||||
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size,
|
||||
tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None)
|
||||
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }
|
||||
}
|
||||
}
|
||||
val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) }
|
||||
if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true)
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
/** Catalogue identity is stable across lanes, filters, requests and dismissals. */
|
||||
data class DiscoverTitle(
|
||||
val tmdb: Long,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val year: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val overview: String = "",
|
||||
val poster: String? = null,
|
||||
val backdrop: String? = null,
|
||||
val posterOnly: Boolean = false,
|
||||
val runtimeSeconds: Int? = null,
|
||||
val seasonCount: Int? = null,
|
||||
val seasons: List<Int>? = null,
|
||||
val ratings: List<DiscoverRating> = emptyList(),
|
||||
val heldBy: Set<String> = emptySet(),
|
||||
val score: Double = 0.0,
|
||||
) {
|
||||
val key: String get() = "$type:$tmdb"
|
||||
val typeWord: String get() = if (type == "movie") "Movie" else "Series"
|
||||
}
|
||||
|
||||
/** Observed source marks keep labels separate from score values. */
|
||||
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
|
||||
|
||||
/** A contribution group preserves feed order rather than assigning a visible rank. */
|
||||
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
|
||||
|
||||
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
|
||||
data class DiscoverService(val id: Long, val name: String, val hue: Float)
|
||||
|
||||
enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable }
|
||||
enum class DiscoverTail { None, SeeAll, Retry, ShowMore }
|
||||
|
||||
/** Provider data is immutable; screen focus never mutates source ordering. */
|
||||
data class DiscoverSnapshot(
|
||||
val picks: List<DiscoverTitle> = emptyList(),
|
||||
val seeds: List<DiscoverSeed> = emptyList(),
|
||||
val services: List<DiscoverService> = emptyList(),
|
||||
val lanes: List<DiscoverLane> = emptyList(),
|
||||
val hidden: List<DiscoverTitle> = emptyList(),
|
||||
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
|
||||
val savedAt: String? = null,
|
||||
val reusesFixtureLanes: Boolean = false,
|
||||
)
|
||||
|
||||
/** Lane identity survives lazy recycling and provider refreshes. */
|
||||
data class DiscoverLane(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val titles: List<DiscoverTitle>,
|
||||
val total: Int = titles.size,
|
||||
val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None,
|
||||
val pick: Boolean = false,
|
||||
val hidden: Boolean = false,
|
||||
val fullTitles: List<DiscoverTitle> = titles,
|
||||
)
|
||||
|
||||
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
|
||||
data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String, val fromRecommendation: Boolean = true)
|
||||
|
||||
/** Request outcomes distinguish uncertainty from an acknowledged receipt. */
|
||||
data class DiscoverRequestResult(
|
||||
val phase: RequestPhase,
|
||||
val actor: String? = null,
|
||||
val stage: String = "Waiting",
|
||||
val requester: String? = null,
|
||||
)
|
||||
|
||||
enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused }
|
||||
|
||||
/** Production adapters use the generated Conductarr client; fixture adapters cannot send HTTP. */
|
||||
interface DiscoverOperations {
|
||||
suspend fun request(intent: DiscoverRequest): DiscoverRequestResult
|
||||
suspend fun check(intent: DiscoverRequest): DiscoverRequestResult
|
||||
suspend fun hide(profile: String, title: DiscoverTitle): Boolean
|
||||
suspend fun restore(profile: String, title: DiscoverTitle): Boolean
|
||||
suspend fun refresh(): DiscoverSnapshot
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */
|
||||
@Composable
|
||||
fun DiscoverOverlay(state: DiscoverState) {
|
||||
val overlay = state.overlay
|
||||
if (overlay != null && !state.shell.pickerVisible) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) {
|
||||
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
|
||||
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
|
||||
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
|
||||
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||
when (overlay) {
|
||||
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
|
||||
is DiscoverOverlayState.Hidden -> {
|
||||
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
is DiscoverOverlayState.Restore -> {
|
||||
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
|
||||
Text("Hidden from ${state.shell.person.name}’s picks.",
|
||||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
|
||||
color = C.muted, style = DiscoverType.body)
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.toast?.let { text ->
|
||||
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - WolfskinTokens.Library.captionWidth, L.navBand).width(WolfskinTokens.Library.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
|
||||
Text(text, color = C.fg, style = DiscoverType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
||||
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle)
|
||||
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
|
||||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
|
||||
placeable.placeRelative(0, D.sheetRatingTop.roundToPx())
|
||||
}
|
||||
})
|
||||
when (sheet.phase) {
|
||||
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
|
||||
RequestPhase.LoadingRecipients -> {
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||
repeat(3) {
|
||||
Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(D.sheetAvatar).background(C.surface, CircleShape))
|
||||
Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(top = D.sheetButtonTop)) {
|
||||
DiscoverButton("Close", true, primary = false) { state.overlay = null }
|
||||
}
|
||||
}
|
||||
else -> RequestOutcome(state, sheet)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||
sheet.people.forEachIndexed { index, person ->
|
||||
val focused = sheet.focus == index
|
||||
val disabled = person.key in sheet.title.heldBy
|
||||
Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
.clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
|
||||
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
|
||||
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle)
|
||||
if (disabled) Text("In library", color = (if (focused) C.onFocus else C.fg).copy(alpha = D.reasonOpacity), style = DiscoverType.reason)
|
||||
else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape)
|
||||
.border(if (sheet.selected != index || focused) D.sheetRadioRing else WolfskinTokens.Shell.zero, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) {
|
||||
if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption)
|
||||
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption)
|
||||
Box(Modifier.padding(top = D.sheetButtonTop)) {
|
||||
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
|
||||
val phase = sheet.phase
|
||||
val name = sheet.selectedPerson.name
|
||||
val actor = sheet.result?.actor ?: state.shell.person.name
|
||||
val text = when (phase) {
|
||||
RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name} · by $actor"
|
||||
RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed."
|
||||
RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice."
|
||||
RequestPhase.Refused -> "$name can’t receive requests from this TV."
|
||||
else -> "Conductarr didn’t say who this TV may request for. Nothing was sent."
|
||||
}
|
||||
val heading = when (phase) {
|
||||
RequestPhase.Receipt -> if (sheet.held) "Adding to $name’s library" else "Requested"
|
||||
RequestPhase.NotConfirmed -> "Not confirmed"
|
||||
RequestPhase.Pending -> "Request unresolved"
|
||||
RequestPhase.Refused -> "Refused"
|
||||
else -> "Can’t read who can receive requests"
|
||||
}
|
||||
ReceiptPanel(heading, text, if (phase == RequestPhase.Receipt) C.fg else if (phase == RequestPhase.Refused) C.fail else C.warn,
|
||||
if (phase == RequestPhase.Receipt) sheet.result?.stage ?: "Waiting" else null)
|
||||
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption)
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
|
||||
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
|
||||
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null }
|
||||
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui.graphics.Color = C.fg, stage: String? = null) {
|
||||
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon))
|
||||
Text(title, color = color, style = DiscoverType.stateTitle)
|
||||
}
|
||||
Text(text, color = C.muted, style = DiscoverType.body)
|
||||
if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(D.stageGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val stageColor = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost" -> C.accent; else -> C.muted }
|
||||
Glyph("clock", stageColor, Modifier.size(L.buttonIcon))
|
||||
Text(stage, color = stageColor, style = DiscoverType.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
|
||||
/** Localized score formatting never infers a tomato label from its percentage. */
|
||||
fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String =
|
||||
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
|
||||
|
||||
/** Heroes, title destinations and sheets show every observed score without a dwell or truncation. */
|
||||
@Composable
|
||||
fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier) {
|
||||
if (ratings.isEmpty()) return
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } }
|
||||
val style = DiscoverType.ratings
|
||||
val markSize = with(LocalDensity.current) { style.fontSize.toDp() }
|
||||
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEachIndexed { index, rating ->
|
||||
val resource = when (rating.source) {
|
||||
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
|
||||
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
|
||||
"imdb" -> R.drawable.ic_rating_imdb
|
||||
else -> R.drawable.ic_rating_tmdb
|
||||
}
|
||||
val muted = rating.source == "rtCritics" && rating.label != "Rotten" && rating.label != "Fresh" && rating.label != "Certified Fresh"
|
||||
val wordmark = rating.source == "imdb" || rating.source == "tmdb"
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markSize * .2857143f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (wordmark) 1.8f else 1f),
|
||||
alpha = if (muted) .55f else 1f, colorFilter = if (muted) desaturated else null)
|
||||
Text(labels[index], color = C.fg, style = style, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.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.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Discover uses the shell's real scroller, Back ladder and per-person focus memory. */
|
||||
@Composable
|
||||
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||
val shell = state.shell
|
||||
val focus = remember(state) { object : ShellContentFocus by state {} }
|
||||
DisposableEffect(focus) {
|
||||
shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
|
||||
}
|
||||
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
||||
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
|
||||
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
|
||||
state.plateKey = null
|
||||
if (state.contentFocused && focusedTitle != null) {
|
||||
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
|
||||
}
|
||||
}
|
||||
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
|
||||
state.collection?.let {
|
||||
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
||||
return
|
||||
}
|
||||
if (state.snapshot.status == DiscoverStatus.Loading) {
|
||||
val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } }
|
||||
LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
||||
return
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
|
||||
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) {
|
||||
buildList {
|
||||
add(if (state.hasHero) L.row_phero else L.row_lhead)
|
||||
add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero)
|
||||
if (!state.canRequest) add(L.row_state) else {
|
||||
if (note) add(L.row_note)
|
||||
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.focusedRow, shell.navFocused, heights) {
|
||||
if (!shell.navFocused) {
|
||||
val row = state.focusedRow
|
||||
val physical = row + if (row >= state.firstShelf && note) 1 else 0
|
||||
var top = WolfskinTokens.Shell.zero
|
||||
for (index in 0 until physical.coerceAtMost(heights.size)) top += heights[index]
|
||||
val fits = top < D.anchorFitTop && top + heights[physical.coerceAtMost(heights.lastIndex)] <= WolfskinTokens.Shell.stageHeight - D.anchorBottomClearance
|
||||
val target = if (row <= 1 || fits) 0f else with(density) { (top - L.pinnedLine).toPx() }
|
||||
var current = WolfskinTokens.Shell.zero
|
||||
for (index in 0 until list.firstVisibleItemIndex.coerceAtMost(heights.size)) current += heights[index]
|
||||
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
|
||||
}
|
||||
}
|
||||
Box(modifier.fillMaxSize().testTag("discover-screen")) {
|
||||
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) }
|
||||
LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) {
|
||||
item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") {
|
||||
if (state.hasHero && state.service == null) DiscoverHero(state) else LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
|
||||
}
|
||||
item(key = "discover-chips", contentType = "chips") {
|
||||
if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state)
|
||||
}
|
||||
if (state.snapshot.status in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable)) item(key = "discover-state", contentType = "state") { DiscoverStatePanel(state) }
|
||||
else {
|
||||
if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) item(key = "discover-note", contentType = "note") {
|
||||
Box(Modifier.height(L.row_note).fillMaxWidth()) {
|
||||
Row(Modifier.offset(L.safeSide, D.noteTop), horizontalArrangement = Arrangement.spacedBy(D.noteGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val color = if (state.snapshot.status == DiscoverStatus.NoSeeds) C.muted else C.warn
|
||||
Glyph(if (state.snapshot.status == DiscoverStatus.NoSeeds) "eye" else "alert", color, Modifier.size(D.noteIcon))
|
||||
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = DiscoverType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverHero(state: DiscoverState) {
|
||||
val title = state.hero ?: return
|
||||
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
|
||||
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
|
||||
}
|
||||
Spacer(Modifier.height(D.heroKickerGap))
|
||||
Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(D.heroTitleGap))
|
||||
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
|
||||
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) DiscoverRatings(title.ratings)
|
||||
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
|
||||
}
|
||||
Spacer(Modifier.height(D.heroMetaGap))
|
||||
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(D.heroOverviewGap))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
|
||||
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
||||
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) }
|
||||
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverChips(state: DiscoverState) {
|
||||
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
(if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) ->
|
||||
val focused = state.contentFocused && state.focusedRow == 1 && state.column == index
|
||||
Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
|
||||
.border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
|
||||
.clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
||||
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverStatePanel(state: DiscoverState) {
|
||||
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
|
||||
Box(Modifier.fillMaxWidth().height(L.row_state)) {
|
||||
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).wrapContentHeight(Alignment.Top, unbounded = true).width(L.stateWidth)
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = DiscoverType.stateTitle)
|
||||
}
|
||||
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = DiscoverType.body)
|
||||
DiscoverButton("Try again", state.contentFocused, primary = signIn) { state.refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */
|
||||
@Composable
|
||||
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
||||
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||
Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Every shelf is lazy and keyed; the focused caption is outside the scaled art slot. */
|
||||
@Composable
|
||||
fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||
val services = lane.id == "svc"
|
||||
val height = if (services) K.entryH else K.posterH
|
||||
val cards = rememberLazyListState()
|
||||
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
||||
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||
val active = state.contentFocused && state.focusedRow == row
|
||||
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
|
||||
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(column, count) {
|
||||
var target = first
|
||||
if (column > target + slots - 2) target = column - (slots - 2)
|
||||
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
|
||||
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
|
||||
state.shell.view.cards["first:${lane.id}"] = target
|
||||
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset
|
||||
if (initialized && offset != null) {
|
||||
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
} else cards.scrollToItem(target)
|
||||
initialized = true
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
|
||||
LazyRow(state = cards, userScrollEnabled = false,
|
||||
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
||||
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
||||
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
||||
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
||||
DiscoverPoster(state, item, active && index == column, Modifier.clickable {
|
||||
state.focus(row, index)
|
||||
if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item)
|
||||
})
|
||||
}
|
||||
if (lane.tail != DiscoverTail.None) item(key = "tail:${lane.id}", contentType = if (lane.tail == DiscoverTail.SeeAll) "seeAll" else "tail") {
|
||||
DiscoverTailCard(lane, active && column == lane.titles.size) { state.focus(row, lane.titles.size); state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!services && active) {
|
||||
val item = lane.titles.getOrNull(column)
|
||||
Caption(
|
||||
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
||||
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
|
||||
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
|
||||
accentFirstFact = lane.hidden,
|
||||
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the focused poster reads the ratings plate; cached observations paint immediately. */
|
||||
@Composable
|
||||
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) {
|
||||
val receipts = state.requests[title.key]
|
||||
val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) }
|
||||
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
|
||||
modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) {
|
||||
if (lane.tail == DiscoverTail.SeeAll) LibrarySeeAllCard(lane.total, focused, onClick)
|
||||
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
|
||||
}
|
||||
|
||||
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {
|
||||
val card = title.libraryCard(state)
|
||||
return (if (hidden) "Hidden from your picks · " else "") + card.facts(true)
|
||||
}
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/** One person owns a Discover view; the shell retains its root list and card memory. */
|
||||
class DiscoverState(
|
||||
val shell: ShellState,
|
||||
initial: DiscoverSnapshot,
|
||||
private val operations: DiscoverOperations,
|
||||
private val scope: CoroutineScope,
|
||||
private val onPush: (String) -> Unit = {},
|
||||
private val onPop: () -> Unit = {},
|
||||
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
|
||||
) : ShellContentFocus {
|
||||
var snapshot by mutableStateOf(initial)
|
||||
private set
|
||||
private var serviceFilter by mutableStateOf("movie")
|
||||
var filter: String
|
||||
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
|
||||
set(value) {
|
||||
if (service != null) serviceFilter = value
|
||||
else shell.rememberedView("discover").cards["filter"] = DISCOVER_FILTERS.indexOf(value)
|
||||
}
|
||||
var slide: Int
|
||||
get() = (shell.rememberedView("discover").cards["slide"] ?: 0).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
|
||||
set(value) { shell.rememberedView("discover").cards["slide"] = value }
|
||||
var title by mutableStateOf<DiscoverTitle?>(null)
|
||||
var service by mutableStateOf<DiscoverService?>(null)
|
||||
var collection by mutableStateOf<LibraryGridState?>(null)
|
||||
var titleButton by mutableStateOf(0)
|
||||
private var activeOverlay by mutableStateOf<DiscoverOverlayState?>(null)
|
||||
var overlay: DiscoverOverlayState?
|
||||
get() = activeOverlay
|
||||
set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null }
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
val observedRatings = mutableSetOf<String>()
|
||||
var plateKey by mutableStateOf<String?>(null)
|
||||
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
|
||||
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
|
||||
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
|
||||
private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle>
|
||||
private var revision by mutableStateOf(0)
|
||||
private var cachedRevision = -1
|
||||
private var cachedFilter = ""
|
||||
private var cachedService: DiscoverService? = null
|
||||
private var cachedLanes = emptyList<DiscoverLane>()
|
||||
private var cachedHeroesRevision = -1
|
||||
private var cachedHeroesFilter = ""
|
||||
private var cachedHeroes = emptyList<DiscoverTitle>()
|
||||
private val dismissalMutex = Mutex()
|
||||
private val dismissalVersions = mutableMapOf<String, Int>()
|
||||
private var rootFilter = "all"
|
||||
|
||||
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
|
||||
val canRequest: Boolean get() = when (snapshot.status) {
|
||||
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
|
||||
else -> false
|
||||
}
|
||||
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
|
||||
val heroes: List<DiscoverTitle> get() {
|
||||
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
|
||||
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
|
||||
cachedHeroes = source.asSequence().filter { it.key !in hidden && matches(it) }
|
||||
.sortedWith(compareByDescending<DiscoverTitle> { it.score }.thenBy { if (it.type == "movie") 0 else 1 })
|
||||
.take(if (snapshot.status == DiscoverStatus.Ready) 5 else 1).toList()
|
||||
cachedHeroesRevision = revision; cachedHeroesFilter = filter
|
||||
}
|
||||
return cachedHeroes
|
||||
}
|
||||
val hero: DiscoverTitle? get() = heroes.getOrNull(slide)
|
||||
val lanes: List<DiscoverLane> get() {
|
||||
if (cachedRevision != revision || cachedFilter != filter || cachedService != service) {
|
||||
cachedLanes = buildLanes(); cachedRevision = revision; cachedFilter = filter; cachedService = service
|
||||
}
|
||||
return cachedLanes
|
||||
}
|
||||
val firstShelf: Int get() = 2
|
||||
val rowCount: Int get() = firstShelf + lanes.size
|
||||
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
|
||||
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
||||
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
|
||||
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
|
||||
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null
|
||||
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
||||
override val cardKey: String? get() = when {
|
||||
overlay != null || title != null -> null
|
||||
collection != null -> collection?.cardKey
|
||||
isCard -> "$rowKey:$column"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun focus(row: Int, column: Int = 0) {
|
||||
shell.navFocused = false; shell.view.row = row
|
||||
shell.view.cards[rowKey] = column.coerceIn(0, maxColumn())
|
||||
}
|
||||
|
||||
private fun moveRow(direction: Int) {
|
||||
val target = focusedRow + direction
|
||||
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
|
||||
if (target >= rowCount) return
|
||||
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
|
||||
val nearest = if (focusedRow >= firstShelf && target >= firstShelf)
|
||||
column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero()
|
||||
else 0
|
||||
focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun openTitle(item: DiscoverTitle) {
|
||||
title = item; titleButton = 0
|
||||
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
|
||||
onPush("discover-title/${item.key}")
|
||||
}
|
||||
fun openCollection(lane: DiscoverLane) {
|
||||
var titles = lane.fullTitles.associateBy { it.key }
|
||||
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
|
||||
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
|
||||
}
|
||||
lateinit var grid: LibraryGridState
|
||||
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
|
||||
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
|
||||
scope.launch {
|
||||
snapshot = operations.refresh(); revision++
|
||||
if (collection !== grid) return@launch
|
||||
val fresh = lanes.firstOrNull { it.id == lane.id }
|
||||
if (canRequest && fresh != null) {
|
||||
titles = fresh.fullTitles.associateBy { it.key }
|
||||
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
|
||||
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
|
||||
}
|
||||
grid.tail = when (fresh.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
|
||||
updateCollectionCards()
|
||||
} else grid.tail = GridTail.Retry
|
||||
}
|
||||
}).apply {
|
||||
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
|
||||
}
|
||||
collection = grid
|
||||
onPush("discover-see-all/${lane.id}")
|
||||
}
|
||||
|
||||
private fun updateCollectionCards(changed: DiscoverTitle? = null) {
|
||||
val grid = collection ?: return
|
||||
val focused = title?.key ?: grid.focusedTitle?.id
|
||||
if (changed != null && changed.key in collectionCards) collectionCards[changed.key] = changed.libraryCard(this)
|
||||
grid.source = collectionCards.values.filter { it.id !in hidden }
|
||||
val restored = grid.items.indexOfFirst { it.id == focused }
|
||||
grid.index = if (restored >= 0) restored else grid.index.coerceIn(0,
|
||||
if (grid.tail == GridTail.None) grid.items.lastIndex.coerceAtLeast(0) else grid.tailIndex)
|
||||
}
|
||||
fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden
|
||||
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
|
||||
|
||||
fun openService(item: DiscoverService) {
|
||||
rootFilter = filter; service = item; filter = "movie"; revision++
|
||||
onPush("discover-service/${item.id}")
|
||||
focus(firstShelf)
|
||||
}
|
||||
|
||||
fun enterRoute(route: String) {
|
||||
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
|
||||
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
|
||||
}
|
||||
|
||||
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
|
||||
override fun key(key: ShellKey): Boolean {
|
||||
if (overlay != null) { overlayKey(key); return true }
|
||||
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
|
||||
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
|
||||
if (title == null) collection?.let {
|
||||
if (key == ShellKey.Back) { collection = null; onPop(); return true }
|
||||
return it.key(key)
|
||||
}
|
||||
if (key == ShellKey.Back) {
|
||||
if (title != null) { title = null; onPop() }
|
||||
else if (service != null) { service = null; filter = rootFilter; revision++; onPop() }
|
||||
else shell.focusNav(shell.currentTab)
|
||||
return true
|
||||
}
|
||||
if (title != null) { titleKey(key); return true }
|
||||
if (!canRequest) {
|
||||
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
|
||||
return true
|
||||
}
|
||||
when (key) {
|
||||
ShellKey.Up -> moveRow(-1)
|
||||
ShellKey.Down -> moveRow(1)
|
||||
ShellKey.Left, ShellKey.Right -> {
|
||||
val direction = if (key == ShellKey.Left) -1 else 1
|
||||
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
|
||||
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
|
||||
}
|
||||
ShellKey.Ok -> when {
|
||||
isCard -> activateCard()
|
||||
focusedRow == 0 -> hero?.let(::openTitle)
|
||||
focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun maxColumn(): Int = when (focusedRow) {
|
||||
0 -> heroes.lastIndex
|
||||
1 -> if (service == null) 2 else 1
|
||||
else -> if (focusedLane?.id == "svc") snapshot.services.lastIndex else focusedLane?.let { it.titles.size - 1 + if (it.tail != DiscoverTail.None) 1 else 0 } ?: 0
|
||||
}.coerceAtLeast(0)
|
||||
|
||||
private fun activateCard() {
|
||||
val lane = focusedLane ?: return
|
||||
if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return }
|
||||
val item = lane.titles.getOrNull(column)
|
||||
if (item == null) {
|
||||
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh()
|
||||
else openCollection(lane)
|
||||
} else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
|
||||
else openTitle(item)
|
||||
}
|
||||
|
||||
private fun titleKey(key: ShellKey) {
|
||||
if (!canRequest) {
|
||||
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
|
||||
return
|
||||
}
|
||||
when (key) {
|
||||
ShellKey.Up -> shell.focusNav(shell.currentTab)
|
||||
ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0)
|
||||
ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1)
|
||||
ShellKey.Ok -> when (titleButton) {
|
||||
0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false)
|
||||
1 -> openRequest(true)
|
||||
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
|
||||
if (!canRequest) { refresh(); return }
|
||||
val choice = RequestSheetState(item, shell.person, shell.people, other, phase)
|
||||
val previous = requestSheets[item.key to choice.selectedPerson.key]
|
||||
overlay = DiscoverOverlayState.Request(
|
||||
if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice,
|
||||
)
|
||||
}
|
||||
|
||||
private fun overlayKey(key: ShellKey) {
|
||||
when (val sheet = overlay) {
|
||||
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
|
||||
scope.launch {
|
||||
snapshot = operations.refresh(); revision++
|
||||
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
|
||||
sheet.state.focus = sheet.state.buttonIndex
|
||||
}
|
||||
}
|
||||
is DiscoverOverlayState.Hidden -> when (key) {
|
||||
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
||||
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
||||
ShellKey.Back -> overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() }
|
||||
}
|
||||
is DiscoverOverlayState.Restore -> when (key) {
|
||||
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
||||
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
||||
ShellKey.Back -> overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(sheet: RequestSheetState, check: Boolean) {
|
||||
if (!canRequest || (!check && !sheet.canSubmit)) return
|
||||
if (!check) {
|
||||
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
|
||||
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {
|
||||
overlay = DiscoverOverlayState.Request(requireNotNull(previous))
|
||||
return
|
||||
}
|
||||
}
|
||||
val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, sheet.viewer.key, isPick(sheet.title))
|
||||
requestSheets[sheet.title.key to sheet.selectedPerson.key] = sheet
|
||||
sheet.phase = RequestPhase.Sending
|
||||
scope.launch {
|
||||
val result = (if (check) operations.check(intent) else operations.request(intent)).copy(requester = intent.requester)
|
||||
sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex
|
||||
if (result.phase == RequestPhase.Receipt) {
|
||||
requests[sheet.title.key] = buildList {
|
||||
requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) }
|
||||
add(result)
|
||||
}
|
||||
updateCollectionCards(sheet.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hide(item: DiscoverTitle) {
|
||||
if (!canRequest) { refresh(); return }
|
||||
hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item)
|
||||
updateCollectionCards()
|
||||
val profile = shell.person.key
|
||||
val version = (dismissalVersions[item.key] ?: 0) + 1
|
||||
dismissalVersions[item.key] = version
|
||||
scope.launch {
|
||||
val success = dismissalMutex.withLock { operations.hide(profile, item) }
|
||||
if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." }
|
||||
}
|
||||
}
|
||||
|
||||
fun restore(item: DiscoverTitle) {
|
||||
if (!canRequest) { refresh(); return }
|
||||
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
|
||||
updateCollectionCards()
|
||||
val profile = shell.person.key
|
||||
val version = (dismissalVersions[item.key] ?: 0) + 1
|
||||
dismissalVersions[item.key] = version
|
||||
scope.launch {
|
||||
val success = dismissalMutex.withLock { operations.restore(profile, item) }
|
||||
if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } }
|
||||
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
|
||||
|
||||
private fun buildLanes(): List<DiscoverLane> = service?.let {
|
||||
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
|
||||
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
|
||||
}
|
||||
|
||||
private fun Int?.orZero(): Int = this ?: 0
|
||||
|
||||
private val DISCOVER_FILTERS = listOf("all", "movie", "tv")
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Explicit destination stub exposes only the Discover-owned request and pick actions. */
|
||||
@Composable
|
||||
fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||
val title = state.title ?: return
|
||||
val focused = state.contentFocused
|
||||
Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) {
|
||||
DiscoverBackdrop(title, detail = true)
|
||||
Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) {
|
||||
Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2)
|
||||
Spacer(Modifier.height(D.heroTitleGap))
|
||||
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Spacer(Modifier.height(D.heroMetaGap))
|
||||
if (state.isSignedIn) DiscoverRatings(title.ratings)
|
||||
Spacer(Modifier.height(D.heroMetaGap))
|
||||
Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3)
|
||||
Spacer(Modifier.height(D.heroOverviewGap))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else {
|
||||
val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key }
|
||||
DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") {
|
||||
state.titleButton = 0; state.key(ShellKey.Ok)
|
||||
}
|
||||
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
|
||||
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
|
||||
/** CSS line boxes survive native first/last-line trimming only within Discover's text stacks. */
|
||||
internal object DiscoverType {
|
||||
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||
private val platform = PlatformTextStyle(includeFontPadding = false)
|
||||
val hero = WolfskinTokens.Type.displayHero.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val button = WolfskinTokens.Type.button.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val sheetTitle = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val reason = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.Medium, lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val rowHeading = WolfskinTokens.Type.rowHeading.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", lineHeightStyle = lineBox, platformStyle = platform)
|
||||
}
|
||||
|
||||
/** Paragraph overflow must not expand the CSS block height. */
|
||||
internal fun Modifier.discoverLineBox(style: TextStyle): Modifier = layout { measurable, constraints ->
|
||||
val paragraph = measurable.measure(constraints)
|
||||
val lineHeight = style.lineHeight.toPx()
|
||||
val lines = ((paragraph[LastBaseline] - paragraph[FirstBaseline]) / lineHeight).roundToInt() + 1
|
||||
val height = (lines * lineHeight).roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)
|
||||
layout(paragraph.width, height) {
|
||||
paragraph.placeRelative(0, (height - paragraph.height) / 2)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@ 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.runtime.remember
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
|
|
@ -25,16 +29,31 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
|||
return graphicsLayer { scaleX = animated; scaleY = animated }
|
||||
}
|
||||
|
||||
/** Content lifts are distinct from the white person/gear rings. */
|
||||
enum class FocusLift { None, Card, Button }
|
||||
|
||||
/** 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,
|
||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
|
||||
val ringAlpha 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 shadow = if (lift == FocusLift.None) this else {
|
||||
val shape = remember(radius) { RoundedCornerShape(radius) }
|
||||
val density = LocalDensity.current
|
||||
val blur = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardBlur else WolfskinTokens.Lift.buttonBlur).toPx() }
|
||||
val shift = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardY else WolfskinTokens.Lift.buttonY).toPx() }
|
||||
dropShadow(shape) {
|
||||
this.radius = blur
|
||||
offset = Offset(0f, shift)
|
||||
this.color = Color.Black
|
||||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||
}
|
||||
}
|
||||
return shadow.drawBehind {
|
||||
if (ringAlpha > 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(color.copy(alpha = color.alpha * ringAlpha), 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,20 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
|
||||
/** Every frame follows focus with the same bounded scroll tween. */
|
||||
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
|
||||
if (distance == 0f) return
|
||||
list.scroll {
|
||||
var previous = 0f
|
||||
animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll,
|
||||
easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
|
||||
scrollBy(value - previous)
|
||||
previous = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,13 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
"plus" -> R.drawable.wolfskin_plus
|
||||
"check" -> R.drawable.wolfskin_check
|
||||
"clock" -> R.drawable.wolfskin_clock
|
||||
"hide" -> R.drawable.wolfskin_hide
|
||||
"undo" -> R.drawable.wolfskin_undo
|
||||
"left" -> R.drawable.wolfskin_left
|
||||
"download" -> R.drawable.wolfskin_download
|
||||
"film" -> R.drawable.wolfskin_film
|
||||
"eye" -> R.drawable.wolfskin_eye
|
||||
"down" -> R.drawable.wolfskin_down
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ class LibraryGridState(
|
|||
val onRecover: () -> Unit = {},
|
||||
val onDiscover: () -> Unit = {},
|
||||
val onReadAhead: (Int) -> Unit = {},
|
||||
val observedRatings: MutableSet<String> = mutableSetOf(),
|
||||
) : ShellContentFocus {
|
||||
var source by mutableStateOf(items)
|
||||
var status by mutableStateOf(LibraryStatus.Ready)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ object LibraryImages {
|
|||
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
|
||||
.crossfade(false).build().also { loader = it }
|
||||
|
||||
fun request(context: Context, path: String, width: Int = WolfskinTokens.Image.posterRequestWidth,
|
||||
fun request(context: Context, data: Any?, width: Int = WolfskinTokens.Image.posterRequestWidth,
|
||||
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
ImageRequest.Builder(context).data(data).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ fun LibraryPoster(
|
|||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
|
||||
.testTag("poster-${title.id}")) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
||||
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
|
||||
|
|
@ -53,12 +53,15 @@ fun LibraryPoster(
|
|||
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
|
||||
}
|
||||
}
|
||||
if (catalogue && title.badge != null) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
|
||||
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape)
|
||||
.then(if (title.badge != "held") Modifier.border(L.badgeBorder, if (title.badge == "failed") C.fail else C.accent, CircleShape) else Modifier),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Glyph(if (title.badge == "held") "check" else if (title.badge == "failed") "alert" else "clock",
|
||||
if (title.badge == "held") C.onFocus else if (title.badge == "failed") C.fail else C.accent, Modifier.size(L.badgeIcon))
|
||||
if (catalogue && title.badge != null) {
|
||||
val held = title.badge == "held"
|
||||
val color = if (title.badge == "failed") C.fail else C.accent
|
||||
Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
|
||||
.size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape)
|
||||
.then(if (held) Modifier else Modifier.border(L.badgeRing, color, CircleShape)), contentAlignment = Alignment.Center) {
|
||||
Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock",
|
||||
if (held) C.onFocus else color, Modifier.size(L.badgeIcon))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
|
@ -40,6 +39,7 @@ fun LibraryScreen(
|
|||
cachedRatings: Set<String> = emptySet(),
|
||||
ratingsReachable: Boolean = true,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
registerFocus: Boolean = true,
|
||||
) {
|
||||
val view = shell.view
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -47,10 +47,11 @@ fun LibraryScreen(
|
|||
val active = !shell.navFocused && !shell.pickerVisible
|
||||
val items = grid.items
|
||||
val focused = grid.focusedTitle
|
||||
val count = if (grid.catalogue) items.size else grid.source.size
|
||||
var plateKey by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(shell, grid) {
|
||||
shell.contentFocus = grid
|
||||
onDispose { if (shell.contentFocus === grid) shell.contentFocus = null }
|
||||
DisposableEffect(shell, grid, registerFocus) {
|
||||
if (registerFocus) shell.contentFocus = grid
|
||||
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
|
||||
}
|
||||
LaunchedEffect(grid) {
|
||||
var revision = grid.pageRevision
|
||||
|
|
@ -69,8 +70,9 @@ fun LibraryScreen(
|
|||
}
|
||||
LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) {
|
||||
plateKey = null
|
||||
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings)) {
|
||||
if (focused.id !in cachedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
|
||||
if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id)
|
||||
plateKey = focused.id
|
||||
}
|
||||
}
|
||||
|
|
@ -102,7 +104,11 @@ fun LibraryScreen(
|
|||
}
|
||||
}
|
||||
}, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine - rowHeight)) {
|
||||
item(key = "header", contentType = "header") { LibraryHeader(grid, shell.person) }
|
||||
item(key = "header", contentType = "header") {
|
||||
LibraryHeader(shell.person, grid.title,
|
||||
if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}",
|
||||
grid.savedAt)
|
||||
}
|
||||
if (headerCount == 2) item(key = "toolbar", contentType = "chips") { LibraryToolbar(grid, active) }
|
||||
when (grid.status) {
|
||||
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
|
||||
|
|
@ -155,15 +161,15 @@ fun LibraryScreen(
|
|||
}
|
||||
}
|
||||
|
||||
/** Library, catalogue and service headers share one baseline and person treatment. */
|
||||
@Composable
|
||||
private fun LibraryHeader(grid: LibraryGridState, person: ShellPerson) {
|
||||
val count = if (grid.catalogue) grid.items.size else grid.source.size
|
||||
fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", savedAt: String? = null) {
|
||||
Box(Modifier.fillMaxWidth().height(L.row_lhead)) {
|
||||
Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
||||
Text(grid.title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
Text(if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", modifier = Modifier.alignByBaseline(), color = C.muted, style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal))
|
||||
grid.savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
|
||||
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
|
||||
savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -222,15 +228,7 @@ private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) {
|
|||
private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
|
||||
val retry = grid.tail == GridTail.Retry
|
||||
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
|
||||
Column(Modifier.offset(L.safeSide).size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||
.clickable { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }.padding(G.tailPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
||||
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
|
||||
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted, style = WolfskinTokens.Type.caption, textAlign = TextAlign.Center)
|
||||
}
|
||||
CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
|
||||
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
|
||||
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
|
||||
}
|
||||
|
|
@ -260,15 +258,5 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
|
|||
if (first < headerCount) WolfskinTokens.Shell.zero else
|
||||
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
|
||||
} + view.listState.firstVisibleItemScrollOffset
|
||||
val distance = target - current
|
||||
if (distance == 0f) return
|
||||
view.listState.scroll {
|
||||
var previous = 0f
|
||||
androidx.compose.animation.core.animate(0f, distance,
|
||||
animationSpec = androidx.compose.animation.core.tween(WolfskinTokens.Motion.scroll,
|
||||
easing = androidx.compose.animation.core.CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
|
||||
scrollBy(value - previous)
|
||||
previous = value
|
||||
}
|
||||
}
|
||||
animateFrameScroll(view.listState, target - current)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifie
|
|||
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)
|
||||
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
|
||||
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
|
||||
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
|
||||
Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/** Overlay focus is independent of the screen below and never reaches the navigation bar. */
|
||||
sealed interface DiscoverOverlayState {
|
||||
class Request(val state: RequestSheetState) : DiscoverOverlayState
|
||||
class Hidden(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) }
|
||||
class Restore(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) }
|
||||
}
|
||||
|
||||
/** Request selection cannot supply an actor and cannot submit an unknown-season series. */
|
||||
class RequestSheetState(
|
||||
val title: DiscoverTitle,
|
||||
val viewer: ShellPerson,
|
||||
people: List<ShellPerson>,
|
||||
other: Boolean,
|
||||
initialPhase: RequestPhase = RequestPhase.Choose,
|
||||
) {
|
||||
val people = listOf(viewer) + people.filter { it.key != viewer.key }
|
||||
val held = viewer.key in title.heldBy
|
||||
val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty()
|
||||
val buttonIndex = this.people.size
|
||||
var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0)
|
||||
var phase by mutableStateOf(initialPhase)
|
||||
var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected)
|
||||
var result by mutableStateOf<DiscoverRequestResult?>(null)
|
||||
val selectedPerson: ShellPerson get() = people[selected]
|
||||
val canSubmit: Boolean get() = phase == RequestPhase.Choose && !blocked && selectedPerson.key !in title.heldBy
|
||||
val buttonLabel: String get() = when {
|
||||
held -> "Add to ${selectedPerson.name}’s library"
|
||||
selectedPerson.key == viewer.key -> "Request"
|
||||
else -> "Request for ${selectedPerson.name}"
|
||||
}
|
||||
|
||||
fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) {
|
||||
if (key == ShellKey.Back) { close(); return }
|
||||
when (phase) {
|
||||
RequestPhase.Choose -> {
|
||||
val order = people.indices.filter { people[it].key !in title.heldBy } + buttonIndex
|
||||
val index = order.indexOf(focus)
|
||||
when (key) {
|
||||
ShellKey.Up -> focus = order[(index - 1).coerceAtLeast(0)]
|
||||
ShellKey.Down -> focus = order[(index + 1).coerceAtMost(order.lastIndex)]
|
||||
ShellKey.Ok -> if (focus == buttonIndex) { if (blocked) close() else if (canSubmit) send(this, false) }
|
||||
else { selected = focus; focus = buttonIndex }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
RequestPhase.Sending -> Unit
|
||||
RequestPhase.LoadingRecipients -> if (key == ShellKey.Ok) close()
|
||||
RequestPhase.Receipt, RequestPhase.Refused -> if (key == ShellKey.Ok) close()
|
||||
RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable -> {
|
||||
when (key) {
|
||||
ShellKey.Left, ShellKey.Up -> focus = buttonIndex
|
||||
ShellKey.Right, ShellKey.Down -> focus = buttonIndex + 1
|
||||
ShellKey.Ok -> if (focus != buttonIndex) close() else if (phase == RequestPhase.RecipientsUnavailable) retryRecipients() else send(this, true)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ val SETTINGS_GROUPS = listOf(
|
|||
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. */
|
||||
/** The household request count shares the sheet's snapshot and excludes Ready. */
|
||||
@Composable
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
|
||||
val list = state.view.listState
|
||||
|
|
@ -70,14 +70,14 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
|||
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)
|
||||
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
||||
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,
|
||||
SettingsRow(group, !state.navFocused && !state.requestsVisible && 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)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ sealed interface ShellAction {
|
|||
data class HandOff(val group: String) : ShellAction
|
||||
data object Pop : ShellAction
|
||||
data object RetryPeople : ShellAction
|
||||
data object RetryRequests : ShellAction
|
||||
data class RequestTitle(val request: ComingInRequest) : ShellAction
|
||||
}
|
||||
|
||||
/** D-pad inputs are shared by Android events and deterministic JVM replays. */
|
||||
|
|
@ -55,8 +57,11 @@ class ShellState(
|
|||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||
var recoveryIndex by mutableStateOf(0)
|
||||
var requestsVisible by mutableStateOf(false)
|
||||
val requests = ComingInState()
|
||||
private var requestReturnRoute: String? = null
|
||||
var handOff by mutableStateOf<String?>(null)
|
||||
var settingsRow by mutableStateOf(0)
|
||||
var contentOverlayVisible by mutableStateOf(false)
|
||||
private var rootRoute by mutableStateOf("home")
|
||||
private var pickerReturnRoute = "home"
|
||||
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
|
||||
|
|
@ -66,11 +71,19 @@ class ShellState(
|
|||
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
|
||||
|
||||
/** Screen options share the same per-person memory as their root's focus and scroller. */
|
||||
fun rememberedView(destination: String): RememberedView =
|
||||
views.getOrPut(person.key to destination) { 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
|
||||
if (requestReturnRoute == destination) {
|
||||
requestsVisible = true
|
||||
requestReturnRoute = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Pointer navigation uses the same transitions as D-pad selection. */
|
||||
|
|
@ -78,7 +91,12 @@ class ShellState(
|
|||
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? = if (!pickerVisible && !navFocused && !requestsVisible) contentFocus?.cardKey else null): Boolean {
|
||||
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible && !contentOverlayVisible) contentFocus?.cardKey else null): Boolean {
|
||||
if (key == ShellKey.Ok && repeat > 0) return true
|
||||
if (!pickerVisible && !requestsVisible && contentOverlayVisible) {
|
||||
if (down) contentFocus?.key(key)
|
||||
return true
|
||||
}
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
if (card != null) {
|
||||
|
|
@ -94,7 +112,12 @@ class ShellState(
|
|||
armedCard = null
|
||||
}
|
||||
if (requestsVisible) {
|
||||
if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false
|
||||
when {
|
||||
key == ShellKey.Back -> closeRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
|
||||
requests.key(key) -> activateRequest()
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key)
|
||||
|
|
@ -143,6 +166,8 @@ class ShellState(
|
|||
navFocused = false
|
||||
navIndex = 0
|
||||
rootRoute = "home"
|
||||
requestReturnRoute = null
|
||||
requestsVisible = false
|
||||
views[person.key to route] = RememberedView()
|
||||
onAction(ShellAction.Person(selected))
|
||||
}
|
||||
|
|
@ -181,6 +206,7 @@ class ShellState(
|
|||
gearFocused = false
|
||||
} else {
|
||||
val target = tabs[navIndex]
|
||||
requestReturnRoute = null
|
||||
if (target == route) views[person.key to target] = RememberedView()
|
||||
route = target
|
||||
rootRoute = target
|
||||
|
|
@ -189,7 +215,7 @@ class ShellState(
|
|||
}
|
||||
} else if (route == "settings") {
|
||||
val group = SETTINGS_GROUPS[settingsRow].key
|
||||
if (group == "requests") requestsVisible = true else {
|
||||
if (group == "requests") openRequests() else {
|
||||
handOff = group
|
||||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
|
|
@ -197,8 +223,34 @@ class ShellState(
|
|||
else contentFocus?.key(ShellKey.Ok)
|
||||
}
|
||||
|
||||
/** Requested buttons may name an existing request without changing its authenticated actor. */
|
||||
fun openRequests(requestId: String? = null) {
|
||||
requests.focus(requestId ?: requests.snapshot.ordered.firstOrNull()?.id)
|
||||
requestsVisible = true
|
||||
navFocused = false
|
||||
}
|
||||
|
||||
fun closeRequests() { requestsVisible = false }
|
||||
fun retryRequests() { onAction(ShellAction.RetryRequests) }
|
||||
|
||||
fun activateRequest() {
|
||||
val request = requests.focused ?: return
|
||||
requestReturnRoute = route
|
||||
requestsVisible = false
|
||||
onAction(ShellAction.RequestTitle(request))
|
||||
}
|
||||
|
||||
/** Back never discards a root view, including the card below a focused nav. */
|
||||
fun back() {
|
||||
if (requestsVisible) { closeRequests(); return }
|
||||
val returnRoute = requestReturnRoute
|
||||
if (returnRoute != null && route != returnRoute) {
|
||||
route = returnRoute
|
||||
requestsVisible = true
|
||||
requestReturnRoute = null
|
||||
onAction(ShellAction.Pop)
|
||||
return
|
||||
}
|
||||
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
|
||||
if (route == "settings") {
|
||||
route = pickerReturnRoute
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ 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
|
||||
|
|
@ -59,6 +57,7 @@ fun ShellSurface(
|
|||
handlesContentKeys: Boolean = true,
|
||||
settingsOpenRequests: Int? = null,
|
||||
savedAt: String? = null,
|
||||
overlay: @Composable () -> Unit = {},
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
|
|
@ -86,20 +85,10 @@ fun ShellSurface(
|
|||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2, savedAt = savedAt)
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.requestsVisible) ComingInSheet(state)
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ private fun RecoveryPicker(state: ShellState) {
|
|||
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)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.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)
|
||||
|
|
|
|||
|
|
@ -246,6 +246,50 @@ object WolfskinTokens {
|
|||
const val requestsPollSeconds = 30.0f
|
||||
const val comingInVisibleRows = 5.0f
|
||||
}
|
||||
object Requests {
|
||||
val sheetPaddingY = 40.0.dp
|
||||
val sheetPaddingX = 32.0.dp
|
||||
val sheetGap = 9.0.dp
|
||||
val sheetTitleLineHeight = 28.0.dp
|
||||
val sheetTitleBaseline = 22.0.dp
|
||||
val titleLineHeight = 19.5.dp
|
||||
val titleBaseline = 15.5.dp
|
||||
val factsLineHeight = 15.6.dp
|
||||
val factsBaseline = 12.0.dp
|
||||
val stageLineHeight = 19.8.dp
|
||||
val stageLabelLineHeight = 16.9.dp
|
||||
val stageBaseline = 12.0.dp
|
||||
val emptyMargin = 10.0.dp
|
||||
val rowPaddingY = 7.0.dp
|
||||
val rowPaddingX = 9.0.dp
|
||||
val rowGap = 12.0.dp
|
||||
val listGap = 3.0.dp
|
||||
val stageMargin = 2.0.dp
|
||||
val stageGlyph = 14.0.dp
|
||||
val stageGap = 5.0.dp
|
||||
val readinessGapX = 11.0.dp
|
||||
val readinessGapY = 5.0.dp
|
||||
val readinessMargin = 4.0.dp
|
||||
val readinessGlyph = 13.0.dp
|
||||
val chevron = 15.0.dp
|
||||
const val chevronAlpha = 0.6f
|
||||
val receiptPaddingY = 14.0.dp
|
||||
val receiptPaddingX = 15.0.dp
|
||||
val receiptGap = 5.0.dp
|
||||
val receiptGlyph = 18.0.dp
|
||||
val buttonMargin = 6.0.dp
|
||||
val skeletonTitleWidth = 180.0.dp
|
||||
val skeletonTitleHeight = 17.0.dp
|
||||
val skeletonSubtitleWidth = 120.0.dp
|
||||
val skeletonSubtitleHeight = 13.0.dp
|
||||
val skeletonGap = 6.0.dp
|
||||
val ratingsGap = 10.0.dp
|
||||
val ratingsEntryGap = 4.0.dp
|
||||
val shadowBlur = 40.0.dp
|
||||
val shadowOffset = -15.0.dp
|
||||
val shadowColor = ComposeColor(0x80000000)
|
||||
const val ratingsWordmarkRatio = 1.8f
|
||||
}
|
||||
object Shell {
|
||||
const val navLabelFade = 200
|
||||
val zero = 0.0.dp
|
||||
|
|
@ -312,7 +356,6 @@ object WolfskinTokens {
|
|||
const val captionSeparatorOpacity = 0.4f
|
||||
val captionFactsGap = 9.0.dp
|
||||
val badgeIcon = 14.0.dp
|
||||
val badgeBorder = 1.5.dp
|
||||
val plateTop = 7.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateBottom = 23.0.dp
|
||||
|
|
@ -341,6 +384,10 @@ object WolfskinTokens {
|
|||
val tailIcon = 22.0.dp
|
||||
val tailPadding = 10.0.dp
|
||||
val tailGap = 5.0.dp
|
||||
val badgeRing = 1.5.dp
|
||||
val seeAllGap = 4.0.dp
|
||||
val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
val headerSubtitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(400), lineHeight = 19.5.sp, letterSpacing = 0.0.em)
|
||||
const val imageReads = 4.0f
|
||||
const val unknownCriticOpacity = 0.55f
|
||||
val fallbackPadding = 11.0.dp
|
||||
|
|
@ -352,6 +399,20 @@ object WolfskinTokens {
|
|||
const val fallbackLightnessBottom = 0.1f
|
||||
const val fallbackIconOpacity = 0.45f
|
||||
}
|
||||
object Lift {
|
||||
val cardBlur = 25.0.dp
|
||||
val cardY = 9.0.dp
|
||||
const val cardOpacity = 0.65f
|
||||
val buttonBlur = 20.0.dp
|
||||
val buttonY = 6.0.dp
|
||||
const val buttonOpacity = 0.5f
|
||||
val heroBlur = 40.0.dp
|
||||
val heroY = 15.0.dp
|
||||
const val heroOpacity = 0.6f
|
||||
val sheetBlur = 40.0.dp
|
||||
val sheetX = -15.0.dp
|
||||
const val sheetOpacity = 0.5f
|
||||
}
|
||||
object Title {
|
||||
val lineGap = 11.0.dp
|
||||
val lineLift = -2.0.dp
|
||||
|
|
@ -475,4 +536,65 @@ object WolfskinTokens {
|
|||
val border = 1.0.dp
|
||||
const val borderOpacity = 0.5f
|
||||
}
|
||||
object Discover {
|
||||
val sheetPaddingX = 32.0.dp
|
||||
val sheetPaddingY = 40.0.dp
|
||||
val sheetGap = 9.0.dp
|
||||
val sheetSubGap = 7.0.dp
|
||||
val sheetRowPadding = 13.0.dp
|
||||
val sheetAvatar = 30.0.dp
|
||||
val sheetRadio = 20.0.dp
|
||||
val sheetRadioDot = 8.0.dp
|
||||
val sheetPersonGap = 12.0.dp
|
||||
val sheetButtonTop = 6.0.dp
|
||||
val chipTop = 8.0.dp
|
||||
val chipHeight = 34.0.dp
|
||||
val chipPadding = 14.0.dp
|
||||
val chipGap = 8.0.dp
|
||||
val heroTitleGap = 8.0.dp
|
||||
val heroKickerGap = 5.0.dp
|
||||
val heroMetaGap = 7.0.dp
|
||||
val heroOverviewGap = 15.0.dp
|
||||
val heroFactsGap = 11.0.dp
|
||||
val heroFactsHeight = 19.0.dp
|
||||
val heroSheetRight = 90.0.dp
|
||||
val heroSheetTop = 70.0.dp
|
||||
val heroSheetWidth = 180.0.dp
|
||||
val heroSheetHeight = 270.0.dp
|
||||
val dotSize = 7.0.dp
|
||||
val dotActive = 22.0.dp
|
||||
val dotGap = 7.0.dp
|
||||
val dotMargin = 9.0.dp
|
||||
val dotChevron = 20.0.dp
|
||||
val stateTop = 10.0.dp
|
||||
val statePaddingX = 20.0.dp
|
||||
val statePaddingY = 17.0.dp
|
||||
val stateTextGap = 12.0.dp
|
||||
val noteTop = 7.0.dp
|
||||
val headerTop = 75.0.dp
|
||||
val headerGap = 12.0.dp
|
||||
val entryPaddingX = 11.0.dp
|
||||
val entryPaddingY = 10.0.dp
|
||||
val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em)
|
||||
val sheetRadioRing = 1.5.dp
|
||||
val skeletonNameWidth = 130.0.dp
|
||||
val skeletonNameHeight = 17.0.dp
|
||||
val sheetSubTop = -4.0.dp
|
||||
val sheetRatingTop = -5.0.dp
|
||||
val sheetRatingBottom = 3.0.dp
|
||||
val sheetLabelTop = 4.0.dp
|
||||
val noteIcon = 15.0.dp
|
||||
val noteGap = 8.0.dp
|
||||
val frameBottomMin = 40.0.dp
|
||||
val anchorFitTop = 350.0.dp
|
||||
val anchorBottomClearance = 20.0.dp
|
||||
const val reasonOpacity = 0.8f
|
||||
val stageGap = 5.0.dp
|
||||
const val entryBorderOpacity = 0.08f
|
||||
val ambientInset = 40.0.dp
|
||||
val ambientBlur = 30.0.dp
|
||||
const val ambientScale = 1.1f
|
||||
const val ambientSaturation = 1.2f
|
||||
const val ambientBrightness = 0.55f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 804 B |
|
After Width: | Height: | Size: 813 B |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1,023 B |
4
wolfskin-design/src/main/res/drawable/wolfskin_check.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5 12.5l4.5 4.5L19 7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
5
wolfskin-design/src/main/res/drawable/wolfskin_clock.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M 3.5,12.0 a 8.5,8.5 0 1,0 17.0,0 a 8.5,8.5 0 1,0 -17.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
|
||||
<path android:pathData="M12 7.5V12l3 2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
|
|
@ -1 +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="M6,9 L12,15 L18,9" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
|
||||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="m6 9 6 6 6-6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -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,4v11m-5,-5l5,5l5,-5M5,20h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_hide.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7c1.6 0 3-.4 4.3-1" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_left.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="m15 6-6 6 6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_plus.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12 5v14M5 12h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
5
wolfskin-design/src/main/res/drawable/wolfskin_undo.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M9 7 4.5 11.5 9 16" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M5 11.5h9a5.5 5.5 0 0 1 0 11h-2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ComingInRatingsGateTest {
|
||||
private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting,
|
||||
ratings = listOf(ComingInRating("imdb", 6.0)))
|
||||
private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2)))
|
||||
|
||||
@Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest {
|
||||
val state = ComingInState()
|
||||
state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true))
|
||||
val abandoned = launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(100)
|
||||
assertFalse(state.ratingsVisible)
|
||||
abandoned.cancel()
|
||||
state.focus(second.id)
|
||||
val next = launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(349)
|
||||
runCurrent()
|
||||
assertFalse(state.ratingsVisible)
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
next.join()
|
||||
state.focus(first.id)
|
||||
assertFalse(state.ratingsVisible)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(350)
|
||||
runCurrent()
|
||||
assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible)
|
||||
state.focus(second.id)
|
||||
assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible)
|
||||
}
|
||||
|
||||
@Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest {
|
||||
val state = ComingInState()
|
||||
val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true)
|
||||
state.update(snapshot)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
state.update(snapshot.copy(signedIn = false))
|
||||
assertFalse(state.ratingsVisible)
|
||||
val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6)))
|
||||
state.update(snapshot.copy(items = listOf(refreshed)))
|
||||
assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
val rating = state.focused!!.ratings.single()
|
||||
assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL")))
|
||||
assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH))
|
||||
assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL")))
|
||||
}
|
||||
|
||||
@Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest {
|
||||
val state = ComingInState()
|
||||
val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared")
|
||||
val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true)
|
||||
state.update(online)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
state.update(online.copy(status = ComingInStatus.Unavailable))
|
||||
state.focus(second.id)
|
||||
state.awaitFocusedRatings()
|
||||
assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible)
|
||||
state.focus(sharedCopy.id)
|
||||
assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Captures execute the shipped Compose surface with offline fixture artwork and actual shell focus. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val fixture = discoverTestFixture()
|
||||
|
||||
@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, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) {
|
||||
val titleState = name.substringAfter("st-").toInt() in setOf(58, 59, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 87, 110)
|
||||
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(if (titleState) "home" else "discover") }
|
||||
val snapshot = snapshotTransform(fixture.snapshot(person))
|
||||
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate),
|
||||
onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service)
|
||||
configure(state)
|
||||
val painters = mutableMapOf<String, ImageBitmap>()
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
CompositionLocalProvider(LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
recordTypography(directory, name)
|
||||
}
|
||||
|
||||
private fun recordTypography(directory: File, name: String) {
|
||||
val rows = JSONArray()
|
||||
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
|
||||
layouts.forEach { layout ->
|
||||
val text = layout.layoutInput.text.text
|
||||
val lines = JSONArray()
|
||||
repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) }
|
||||
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines)
|
||||
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
|
||||
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
|
||||
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
|
||||
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
|
||||
}
|
||||
}
|
||||
File(directory, "$name-type-metrics.json").writeText(rows.toString(2))
|
||||
}
|
||||
|
||||
@Test fun st029_discover() = capture("st-029")
|
||||
@Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) }
|
||||
@Test fun st031_hidden() = capture("st-031") { hidden(it) }
|
||||
@Test fun st032_restore() {
|
||||
capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) }
|
||||
val title = rule.onAllNodesWithText(fixture.snapshot("justin").picks[7].title, useUnmergedTree = true)
|
||||
.fetchSemanticsNodes().maxBy { it.boundsInRoot.left }.boundsInRoot
|
||||
val sub = rule.onNodeWithText("Hidden from Justin’s picks.", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val gap = with(rule.density) { (WolfskinTokens.Discover.sheetGap + WolfskinTokens.Discover.sheetSubTop).toPx() }
|
||||
assertTrue("Restore subtitle must retain the negative top margin", kotlin.math.abs(sub.top - title.bottom - gap) <= 1f)
|
||||
}
|
||||
@Test fun st033_failedBadge() = capture("st-033") { state ->
|
||||
val lane = state.lanes.first { it.id == "pk-movie" }
|
||||
state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin"))
|
||||
state.focus(state.lanes.indexOf(lane) + 2, 1)
|
||||
}
|
||||
@Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot ->
|
||||
snapshot.copy(lanes = snapshot.lanes.map { if (it.id == "trending") it.copy(tail = DiscoverTail.Retry) else it })
|
||||
}) { state -> val lane = state.lanes.first { it.id == "trending" }; state.focus(state.lanes.indexOf(lane) + 2, lane.titles.size) }
|
||||
@Test fun st035_duplicateTail() = capture("st-035") { state ->
|
||||
val titles = state.snapshot.lanes.first { it.id == "trending" }.fullTitles.take(36)
|
||||
state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore))
|
||||
state.collection!!.index = state.collection!!.tailIndex
|
||||
}
|
||||
@Test fun st036_needsSignIn() {
|
||||
capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) })
|
||||
val button = rule.onNodeWithText("Try again").fetchSemanticsNode().boundsInRoot
|
||||
val minimum = with(rule.density) { WolfskinTokens.Layout.buttonHeight.toPx() }
|
||||
assertTrue("The sign-in button must not shrink to fit the scroll row", button.height >= minimum)
|
||||
}
|
||||
@Test fun st037_series() {
|
||||
capture("st-037", person = "mindel") { it.filter = "tv" }
|
||||
val title = rule.onNodeWithText("56 Days", useUnmergedTree = true).fetchSemanticsNode()
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
title.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
|
||||
val layout = layouts.single()
|
||||
val cssHeight = with(rule.density) { DiscoverType.hero.lineHeight.toPx() } * layout.lineCount
|
||||
assertTrue("Natural font height must not inflate the hero CSS line box", kotlin.math.abs(title.boundsInRoot.height - cssHeight) <= 1f)
|
||||
}
|
||||
@Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) })
|
||||
@Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) }
|
||||
@Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) }
|
||||
@Test fun st041_unreachable() = capture("st-041", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.Unavailable) })
|
||||
@Test fun st058_hiddenReceipt() = capture("st-058") { state -> state.openTitle(state.snapshot.picks.first()); state.titleButton = 2; state.hide(state.title!!) }
|
||||
@Test fun st059_hiddenTitle() = capture("st-059") { state -> state.hide(state.snapshot.picks.first()); state.overlay = null; state.openTitle(state.snapshot.picks.first()); state.titleButton = 2 }
|
||||
@Test fun st071_requestSelf() = capture("st-071") { request(it) }
|
||||
@Test fun st072_requestOther() = capture("st-072") { request(it, other = true) }
|
||||
@Test fun st073_addHeld() = capture("st-073") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
|
||||
@Test fun st074_receipt() = capture("st-074") { request(it, other = true, phase = RequestPhase.Receipt) }
|
||||
@Test fun st075_notConfirmed() = capture("st-075") { request(it, phase = RequestPhase.NotConfirmed) }
|
||||
@Test fun st076_pending() = capture("st-076") { request(it, phase = RequestPhase.Pending) }
|
||||
@Test fun st077_refused() = capture("st-077", person = "mindel") { request(it, other = true, phase = RequestPhase.Refused) }
|
||||
@Test fun st078_recipientsLoading() = capture("st-078") { request(it, other = true, phase = RequestPhase.LoadingRecipients) }
|
||||
@Test fun st079_recipientsUnavailable() = capture("st-079") { request(it, other = true, phase = RequestPhase.RecipientsUnavailable) }
|
||||
@Test fun st080_seasonsUnknown() = capture("st-080") { state -> request(state, state.snapshot.picks.first { it.type == "tv" }) }
|
||||
@Test fun st087_sharedReceipt() = capture("st-087", person = "shared") { request(it, phase = RequestPhase.Receipt) }
|
||||
@Test fun st105_pickPlate() = capture("st-105") { state -> val lane = state.lanes.first { it.id == "pk-movie" }; state.focus(state.lanes.indexOf(lane) + 2, 1) }
|
||||
@Test fun st110_heldRatings() = capture("st-110") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
|
||||
|
||||
private fun hidden(state: DiscoverState) {
|
||||
state.hide(state.snapshot.picks[7]); state.hide(state.snapshot.picks[20]); state.overlay = null
|
||||
val lane = state.lanes.first { it.hidden }
|
||||
state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == state.snapshot.picks[7].key })
|
||||
}
|
||||
|
||||
private fun request(state: DiscoverState, title: DiscoverTitle = state.snapshot.picks.first(), other: Boolean = false, phase: RequestPhase = RequestPhase.Choose) {
|
||||
state.openTitle(title); state.openRequest(other, phase = phase)
|
||||
if (phase == RequestPhase.Receipt) {
|
||||
val sheet = (state.overlay as DiscoverOverlayState.Request).state
|
||||
val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key)
|
||||
sheet.result = receipt; state.requests[title.key] = listOf(receipt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun discoverTestFixture(): DiscoverFixture {
|
||||
val json = DiscoverCaptureTest::class.java.getResourceAsStream("/wolfskin-discover.json")!!.bufferedReader().use { it.readText() }
|
||||
val assets = File("../app/src/debug/assets/wolfskin-discover").canonicalFile
|
||||
return DiscoverFixture(json) { File(assets, it).absolutePath }
|
||||
}
|
||||
|
||||
/** In-process effects are inspectable without exposing any live client or endpoint. */
|
||||
internal class RecordingDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
|
||||
val sent = mutableListOf<DiscoverRequest>()
|
||||
var checked = 0
|
||||
var next = RequestPhase.Receipt
|
||||
var mutationSucceeds = true
|
||||
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { sent += intent; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
|
||||
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { checked++; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
|
||||
override suspend fun hide(profile: String, title: DiscoverTitle) = mutationSucceeds
|
||||
override suspend fun restore(profile: String, title: DiscoverTitle) = mutationSucceeds
|
||||
override suspend fun refresh() = snapshot
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
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.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.json.JSONObject
|
||||
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
|
||||
|
||||
/** Acceptance data drives real Android events through the shell's production focus adapter. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class DiscoverKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
private val fixture = discoverTestFixture()
|
||||
private val shell = ShellState(fixturePeople(), "justin")
|
||||
private val snapshot = fixture.snapshot("justin")
|
||||
private val operations = RecordingDiscoverOperations(snapshot, "justin")
|
||||
private val state = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination,
|
||||
{ shell.enterDestination("discover") }, fixture::service)
|
||||
|
||||
private fun launch() {
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
||||
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
||||
|
||||
private fun replay(goal: String) {
|
||||
val paths = JSONObject(javaClass.getResourceAsStream("/discover-keys.json")!!.bufferedReader().use { it.readText() }).getJSONArray("pressPaths")
|
||||
val path = (0 until paths.length()).map { paths.getJSONObject(it) }.single { it.getString("goal") == goal }
|
||||
val keys = path.getJSONArray("keys")
|
||||
assertEquals(path.getInt("presses"), keys.length())
|
||||
launch()
|
||||
repeat(keys.length()) { index -> press(when (keys.getString(index)) {
|
||||
"OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back
|
||||
}) }
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test fun openDiscoverInFourPresses() {
|
||||
replay("Open Discover")
|
||||
assertEquals("discover", shell.route)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals("The Amazing Spider-Man", state.hero?.title)
|
||||
}
|
||||
|
||||
@Test fun requestForYourselfInSevenPresses() {
|
||||
replay("Request a pick for yourself")
|
||||
val sheet = (state.overlay as DiscoverOverlayState.Request).state
|
||||
assertEquals(RequestPhase.Receipt, sheet.phase)
|
||||
assertEquals("justin", operations.sent.single().requester)
|
||||
assertEquals("Justin", sheet.result?.actor)
|
||||
assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).single().stage)
|
||||
}
|
||||
|
||||
@Test fun requestForMindelInNinePresses() {
|
||||
replay("Request a pick for Mindel")
|
||||
val sheet = (state.overlay as DiscoverOverlayState.Request).state
|
||||
assertEquals(RequestPhase.Receipt, sheet.phase)
|
||||
assertEquals("mindel", operations.sent.single().requester)
|
||||
assertEquals("Justin", sheet.result?.actor)
|
||||
}
|
||||
|
||||
@Test fun notInterestedInEightPressesAndUndoRestoresEveryIndex() {
|
||||
val original = state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }
|
||||
replay("Round 3 · Not interested on a pick (Discover hero)")
|
||||
val hidden = state.overlay as DiscoverOverlayState.Hidden
|
||||
assertTrue(state.isHidden(hidden.title))
|
||||
assertTrue(state.heroes.none { it.key == hidden.title.key })
|
||||
assertTrue(state.lanes.filterNot { it.hidden }.none { lane -> lane.titles.any { it.key == hidden.title.key } })
|
||||
press(Key.DirectionCenter)
|
||||
assertNull(state.overlay)
|
||||
assertEquals("The Amazing Spider-Man", state.hero?.title)
|
||||
assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } })
|
||||
}
|
||||
|
||||
@Test fun heldOkOpensSheetButNeverSubmits() {
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionCenter)
|
||||
rule.runOnIdle {
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
repeat(8) { index -> shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, index + 1))) }
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
}
|
||||
assertEquals(RequestPhase.Choose, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
assertTrue(operations.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun uncertainOutcomeChecksWithoutResending() {
|
||||
operations.next = RequestPhase.NotConfirmed
|
||||
replay("Request a pick for yourself")
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
operations.next = RequestPhase.Receipt
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(1, operations.checked)
|
||||
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
}
|
||||
|
||||
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
|
||||
operations.next = RequestPhase.NotConfirmed
|
||||
replay("Request a pick for yourself")
|
||||
press(Key.Back, Key.DirectionCenter)
|
||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
operations.next = RequestPhase.Receipt
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(1, operations.checked)
|
||||
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
}
|
||||
|
||||
@Test fun requestingForAnotherPersonKeepsYourOwnRequestedAction() {
|
||||
replay("Request a pick for yourself")
|
||||
press(Key.DirectionCenter)
|
||||
press(Key.DirectionRight, Key.DirectionCenter)
|
||||
press(Key.DirectionCenter, Key.DirectionCenter)
|
||||
assertEquals(setOf("justin", "mindel"), state.requests.getValue(state.title!!.key).map { it.requester }.toSet())
|
||||
press(Key.DirectionCenter)
|
||||
press(Key.DirectionLeft, Key.DirectionCenter)
|
||||
assertTrue(shell.requestsVisible)
|
||||
assertEquals(2, operations.sent.size)
|
||||
}
|
||||
|
||||
@Test fun choosingAnUncertainRecipientAgainNeverResubmits() {
|
||||
operations.next = RequestPhase.NotConfirmed
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionCenter)
|
||||
press(Key.DirectionRight, Key.DirectionCenter)
|
||||
press(Key.DirectionDown, Key.DirectionCenter)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("shared", operations.sent.single().requester)
|
||||
press(Key.Back, Key.DirectionCenter)
|
||||
press(Key.DirectionDown, Key.DirectionCenter)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
operations.next = RequestPhase.Receipt
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(1, operations.checked)
|
||||
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
}
|
||||
|
||||
@Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() {
|
||||
replay("Open Discover")
|
||||
rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) }
|
||||
press(Key.DirectionCenter)
|
||||
assertTrue(operations.sent.isEmpty())
|
||||
assertNull(state.overlay)
|
||||
assertNotNull(state.title)
|
||||
assertFalse(shell.navFocused)
|
||||
}
|
||||
|
||||
@Test fun cardOkWaitsForReleaseAndFocusMoveCancelsActivation() {
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionDown, Key.DirectionDown)
|
||||
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
|
||||
assertNull(state.title)
|
||||
press(Key.DirectionRight)
|
||||
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
|
||||
assertNull(state.title)
|
||||
press(Key.DirectionCenter)
|
||||
assertNotNull(state.title)
|
||||
}
|
||||
@Test fun downClampsToOccupiedCardsAndThenTheTail() {
|
||||
shell.pickerVisible = false
|
||||
val items = snapshot.picks.take(8)
|
||||
state.openCollection(DiscoverLane("short", "Picks", items, tail = DiscoverTail.ShowMore))
|
||||
launch()
|
||||
repeat(5) { press(Key.DirectionRight) }
|
||||
assertEquals(5, state.collection!!.index % state.collection!!.columns)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(1, state.collection!!.index / state.collection!!.columns)
|
||||
assertEquals(1, state.collection!!.index % state.collection!!.columns)
|
||||
assertEquals(items.last().key, state.collection!!.items[state.collection!!.index].id)
|
||||
press(Key.DirectionDown, Key.DirectionRight)
|
||||
assertEquals(2, state.collection!!.index / state.collection!!.columns)
|
||||
assertEquals(0, state.collection!!.index % state.collection!!.columns)
|
||||
assertEquals(GridTail.ShowMore, state.collection!!.tail)
|
||||
}
|
||||
|
||||
@Test fun collectionRetryReadsNewTitlesAndOpensTheFirstAppendedTitle() {
|
||||
shell.pickerVisible = false
|
||||
val lane = state.lanes.first { it.id == "trending" }
|
||||
state.openCollection(lane.copy(titles = lane.fullTitles.take(6), fullTitles = lane.fullTitles.take(6), tail = DiscoverTail.Retry))
|
||||
val grid = state.collection!!
|
||||
grid.index = grid.tailIndex
|
||||
launch()
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(GridTail.None, grid.tail)
|
||||
assertEquals(lane.fullTitles.map { it.key }, grid.items.map { it.id })
|
||||
assertEquals(lane.fullTitles[6].key, grid.focusedTitle?.id)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(lane.fullTitles[6], state.title)
|
||||
}
|
||||
|
||||
@Test fun collectionDismissalAndUndoKeepTheOriginalOrderAndFocus() {
|
||||
shell.pickerVisible = false
|
||||
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
||||
val grid = state.collection!!
|
||||
val original = grid.items.map { it.id }
|
||||
launch()
|
||||
press(Key.DirectionCenter)
|
||||
val opener = state.title!!
|
||||
press(Key.DirectionRight, Key.DirectionRight)
|
||||
press(Key.DirectionCenter)
|
||||
assertFalse(grid.items.any { it.id == opener.key })
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(original, grid.items.map { it.id })
|
||||
assertEquals(opener.key, grid.focusedTitle?.id)
|
||||
}
|
||||
|
||||
@Test fun collectionBackRestoresTheExactOrigin() {
|
||||
shell.pickerVisible = false; shell.enterDestination("discover")
|
||||
state.focus(3, 2)
|
||||
val origin = shell.view
|
||||
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
||||
launch()
|
||||
press(Key.DirectionDown, Key.Back)
|
||||
assertNull(state.collection)
|
||||
assertSame(origin, shell.view)
|
||||
assertEquals(3, shell.view.row)
|
||||
assertEquals(2, shell.view.cards[state.rowKey])
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
|
||||
fun dutchPlateUsesLocaleAndStaysOnOneLine() {
|
||||
shell.pickerVisible = false
|
||||
val title = snapshot.picks.first().copy(ratings = listOf(DiscoverRating("tmdb", 7.8)))
|
||||
state.openCollection(DiscoverLane("locale", "Picks", listOf(title)))
|
||||
launch()
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.onNodeWithText("7,8").assertExists()
|
||||
assertEquals("7.8", formatDiscoverRating(title.ratings.single(), java.util.Locale.ENGLISH))
|
||||
assertEquals("91%", formatDiscoverRating(DiscoverRating("rtCritics", 91.7), java.util.Locale.forLanguageTag("nl-NL")))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** Feed projection must not lose titles when a contribution row is too short to display. */
|
||||
class DiscoverLaneProjectionTest {
|
||||
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
|
||||
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
|
||||
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
|
||||
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
|
||||
assertEquals(titles.take(3), rows.first().titles)
|
||||
assertEquals(titles.drop(3), rows.last().titles)
|
||||
}
|
||||
|
||||
@Test fun catalogueDeduplicationKeepsMediaTypesDistinctAndHiddenPicksLast() {
|
||||
val movie = DiscoverTitle(1, "movie", "Film")
|
||||
val series = DiscoverTitle(1, "tv", "Series")
|
||||
val hidden = DiscoverTitle(2, "movie", "Hidden")
|
||||
val later = DiscoverTitle(3, "movie", "Later")
|
||||
val snapshot = DiscoverSnapshot(hidden = listOf(hidden), status = DiscoverStatus.Ready, lanes = listOf(
|
||||
DiscoverLane("first", "First", listOf(movie, series, hidden)),
|
||||
DiscoverLane("second", "Second", listOf(movie, series, later))))
|
||||
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", mapOf(hidden.key to hidden))
|
||||
assertEquals(listOf(listOf(movie, series), listOf(later), listOf(hidden)), rows.map { it.titles })
|
||||
assertEquals("hidden", rows.last().id)
|
||||
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
|
||||
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
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 com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Before
|
||||
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
|
||||
|
||||
/** Every approved Requests state uses the actual shell overlay at native television density. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class RequestsCaptureTest {
|
||||
@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, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) {
|
||||
val state = ShellState(fixturePeople(), person)
|
||||
state.pickerVisible = false
|
||||
state.enterDestination("settings")
|
||||
state.requests.update(snapshot)
|
||||
state.openRequests()
|
||||
if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) }
|
||||
rule.setContent {
|
||||
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
|
||||
}
|
||||
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 st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
|
||||
@Test fun st082_stages() = capture("st-082", requestsFixture())
|
||||
@Test fun st083_unavailable() = capture("st-083", ComingInSnapshot())
|
||||
@Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading))
|
||||
@Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true))
|
||||
@Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true)
|
||||
@Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared")
|
||||
@Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true))
|
||||
}
|
||||
|
||||
/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */
|
||||
fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot {
|
||||
val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() }
|
||||
val items = readComingInFixture(source) { name ->
|
||||
RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() }
|
||||
}
|
||||
val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) }
|
||||
return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true)
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
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
|
||||
|
||||
/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class RequestsKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private fun mount(state: ShellState) {
|
||||
rule.setContent { WolfskinTheme { ShellSurface(state) } }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) {
|
||||
listOfNotNull(first, second, third, fourth).forEach { key ->
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState =
|
||||
ShellState(fixturePeople(), "justin", actions).apply {
|
||||
pickerVisible = false
|
||||
enterDestination("settings")
|
||||
requests.update(snapshot)
|
||||
openRequests()
|
||||
}
|
||||
|
||||
@Test fun approvedNinePressPathUsesRealShellFocus() {
|
||||
val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
|
||||
val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft,
|
||||
"RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown)
|
||||
val state = ShellState(fixturePeople(), "justin")
|
||||
state.requests.update(requestsFixture())
|
||||
mount(state)
|
||||
val path = paths.getJSONObject(0)
|
||||
val presses = path.getJSONArray("keys")
|
||||
assertEquals(9, path.getInt("presses"))
|
||||
assertEquals(path.getInt("presses"), presses.length())
|
||||
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
|
||||
rule.onNodeWithTag("coming-in").assertExists()
|
||||
rule.onNodeWithText("Requests · 5 open").assertExists()
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals("x5", state.requests.focusedId)
|
||||
assertFalse(state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun readinessWindowAndTitleRoundTripPreserveRequest() {
|
||||
lateinit var state: ShellState
|
||||
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") }
|
||||
mount(state)
|
||||
send(Key.DirectionDown, Key.DirectionRight)
|
||||
rule.onNodeWithText("Download · Downloading").assertExists()
|
||||
assertEquals(4, state.requests.windowSize)
|
||||
assertEquals("x1", state.requests.focusedId)
|
||||
send(Key.DirectionLeft)
|
||||
rule.onNodeWithText("Download · Downloading").assertDoesNotExist()
|
||||
send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown)
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
assertEquals(1, state.requests.windowStart)
|
||||
send(Key.DirectionDown)
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
send(Key.DirectionCenter)
|
||||
assertFalse(state.requestsVisible)
|
||||
assertTrue(state.route.startsWith("title/"))
|
||||
send(Key.Back)
|
||||
rule.onNodeWithTag("coming-row-x6").assertExists()
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
assertTrue(state.requestsVisible)
|
||||
send(Key.Back)
|
||||
assertFalse(state.requestsVisible)
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals(0, state.settingsRow)
|
||||
}
|
||||
|
||||
@Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() {
|
||||
var retries = 0
|
||||
val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ }
|
||||
mount(state)
|
||||
send(Key.DirectionUp, Key.DirectionLeft)
|
||||
assertFalse(state.navFocused)
|
||||
assertTrue(state.requestsVisible)
|
||||
send(Key.DirectionCenter)
|
||||
assertFalse(state.requestsVisible)
|
||||
rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() }
|
||||
send(Key.DirectionCenter)
|
||||
assertEquals(1, retries)
|
||||
assertTrue(state.requestsVisible)
|
||||
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
||||
send(Key.DirectionDown, Key.DirectionCenter)
|
||||
rule.onNodeWithText("Nothing coming in.").assertExists()
|
||||
assertTrue(state.requestsVisible)
|
||||
}
|
||||
|
||||
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
|
||||
val original = Locale.getDefault()
|
||||
try {
|
||||
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
|
||||
val state = sheet(requestsFixture(ratings = true))
|
||||
kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() }
|
||||
mount(state)
|
||||
rule.onNodeWithText("7,6").assertExists()
|
||||
rule.onNodeWithText("87%").assertExists()
|
||||
rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) }
|
||||
rule.onNodeWithText("7,6").assertDoesNotExist()
|
||||
rule.onNodeWithText("87%").assertDoesNotExist()
|
||||
} finally { Locale.setDefault(original) }
|
||||
}
|
||||
|
||||
@Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() {
|
||||
lateinit var state: ShellState
|
||||
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") }
|
||||
mount(state)
|
||||
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
|
||||
assertEquals("home", state.route)
|
||||
send(Key.Back)
|
||||
assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible)
|
||||
assertTrue(state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() {
|
||||
val snapshot = requestsFixture()
|
||||
val state = sheet(snapshot)
|
||||
mount(state)
|
||||
send(Key.DirectionDown)
|
||||
rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) }
|
||||
assertEquals("x1", state.requests.focusedId)
|
||||
rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists()
|
||||
val shared = requestsFixture(shared = true).items.single()
|
||||
val viewer = fixturePeople().single { it.key == "shared" }
|
||||
assertEquals("Shared", shared.actor(viewer))
|
||||
assertEquals("Justin", shared.recipient(viewer))
|
||||
assertEquals(5, snapshot.openCount)
|
||||
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
|
||||
}
|
||||
}
|
||||
92
wolfskin-design/src/test/resources/discover-keys.json
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"pressPaths": [
|
||||
{
|
||||
"goal": "Open Discover",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK"
|
||||
],
|
||||
"presses": 4,
|
||||
"note": "",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
},
|
||||
{
|
||||
"goal": "Request a pick for yourself",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"OK",
|
||||
"OK"
|
||||
],
|
||||
"presses": 7,
|
||||
"note": "",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
},
|
||||
{
|
||||
"goal": "Request a pick for Mindel",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"OK"
|
||||
],
|
||||
"presses": 9,
|
||||
"note": "",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
},
|
||||
{
|
||||
"goal": "Round 3 · Not interested on a pick (Discover hero)",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK"
|
||||
],
|
||||
"presses": 8,
|
||||
"note": "",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
},
|
||||
{
|
||||
"goal": "Round 4 · Discover pick nobody holds → its Trailer (sample hand-off)",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK"
|
||||
],
|
||||
"presses": 9,
|
||||
"note": "4 on the title page (3 Rights to Trailer, OK); The Amazing Spider-Man",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
|
||||
"requires": "Conductarr trailer field (spec 3.2, fifth addition); the press path runs once it lands"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
wolfskin-design/src/test/resources/requests-press-paths.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[
|
||||
{
|
||||
"goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"LEFT",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"DOWN",
|
||||
"OK",
|
||||
"OK"
|
||||
],
|
||||
"presses": 9,
|
||||
"note": "8 from the hero",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
}
|
||||
]
|
||||
217
wolfskin-design/src/test/resources/wolfskin-requests.json
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
{
|
||||
"source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order",
|
||||
"items": [
|
||||
{
|
||||
"id": "x1",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 11821,
|
||||
"title": "The Abbott and Costello Show",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Downloading",
|
||||
"poster": "tv-the-abbott-and-costello-show-1952-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "tmdb",
|
||||
"score": 7.2,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x2",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 13483,
|
||||
"title": "Awake",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "AlmostReady",
|
||||
"poster": "movie-awake-2007-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 23,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 55,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6.5,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x3",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 70785,
|
||||
"title": "Anne with an E",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Searching",
|
||||
"poster": "tv-anne-with-an-e-2017-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 63,
|
||||
"percentage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x4",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 99861,
|
||||
"title": "Avengers: Age of Ultron",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-avengers-age-of-ultron-2015-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 75,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 82,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.3,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x5",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 1033148,
|
||||
"title": "Die My Love",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Failed",
|
||||
"poster": "movie-die-my-love-2025-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 74,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 45,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 416153,
|
||||
"title": "Arctic Dogs",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Ready",
|
||||
"poster": "movie-arctic-dogs-2019-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 13,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 60,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 4.9,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x7",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 76203,
|
||||
"title": "12 Years a Slave",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "shared",
|
||||
"actorName": "Shared",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-12-years-a-slave-2013-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 95,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 8.1,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x8",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 14069,
|
||||
"title": "The Girl Who Leapt Through Time",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 87,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -127,6 +127,46 @@
|
|||
--motion-scroll: 180ms;
|
||||
--motion-ratingDwell: 350ms;
|
||||
--motion-toast: 3200ms;
|
||||
--requests-sheetPaddingY: 80.0px;
|
||||
--requests-sheetPaddingX: 64.0px;
|
||||
--requests-sheetGap: 18.0px;
|
||||
--requests-sheetTitleLineHeight: 56.0px;
|
||||
--requests-sheetTitleBaseline: 44.0px;
|
||||
--requests-titleLineHeight: 39.0px;
|
||||
--requests-titleBaseline: 31.0px;
|
||||
--requests-factsLineHeight: 31.2px;
|
||||
--requests-factsBaseline: 24.0px;
|
||||
--requests-stageLineHeight: 39.6px;
|
||||
--requests-stageLabelLineHeight: 33.8px;
|
||||
--requests-stageBaseline: 24.0px;
|
||||
--requests-emptyMargin: 20.0px;
|
||||
--requests-rowPaddingY: 14.0px;
|
||||
--requests-rowPaddingX: 18.0px;
|
||||
--requests-rowGap: 24.0px;
|
||||
--requests-listGap: 6.0px;
|
||||
--requests-stageMargin: 4.0px;
|
||||
--requests-stageGlyph: 28.0px;
|
||||
--requests-stageGap: 10.0px;
|
||||
--requests-readinessGapX: 22.0px;
|
||||
--requests-readinessGapY: 10.0px;
|
||||
--requests-readinessMargin: 8.0px;
|
||||
--requests-readinessGlyph: 26.0px;
|
||||
--requests-chevron: 30.0px;
|
||||
--requests-receiptPaddingY: 28.0px;
|
||||
--requests-receiptPaddingX: 30.0px;
|
||||
--requests-receiptGap: 10.0px;
|
||||
--requests-receiptGlyph: 36.0px;
|
||||
--requests-buttonMargin: 12.0px;
|
||||
--requests-skeletonTitleWidth: 360.0px;
|
||||
--requests-skeletonTitleHeight: 34.0px;
|
||||
--requests-skeletonSubtitleWidth: 240.0px;
|
||||
--requests-skeletonSubtitleHeight: 26.0px;
|
||||
--requests-skeletonGap: 12.0px;
|
||||
--requests-ratingsGap: 20.0px;
|
||||
--requests-ratingsEntryGap: 8.0px;
|
||||
--requests-shadowBlur: 80.0px;
|
||||
--requests-shadowOffset: -30.0px;
|
||||
--requests-shadowColor: #00000080;
|
||||
--shell-navLabelFade: 200ms;
|
||||
--shell-zero: 0.0px;
|
||||
--shell-stageWidth: 1920.0px;
|
||||
|
|
@ -184,7 +224,6 @@
|
|||
--library-captionWidth: 640.0px;
|
||||
--library-captionFactsGap: 18.0px;
|
||||
--library-badgeIcon: 28.0px;
|
||||
--library-badgeBorder: 3.0px;
|
||||
--library-plateTop: 14.0px;
|
||||
--library-plateSide: 16.0px;
|
||||
--library-plateBottom: 46.0px;
|
||||
|
|
@ -205,8 +244,20 @@
|
|||
--library-tailIcon: 44.0px;
|
||||
--library-tailPadding: 20.0px;
|
||||
--library-tailGap: 10.0px;
|
||||
--library-badgeRing: 3.0px;
|
||||
--library-seeAllGap: 8.0px;
|
||||
--library-tailDescription: 24.0px;
|
||||
--library-headerSubtitle: 30.0px;
|
||||
--library-fallbackPadding: 22.0px;
|
||||
--library-fallbackIcon: 44.0px;
|
||||
--lift-cardBlur: 50.0px;
|
||||
--lift-cardY: 18.0px;
|
||||
--lift-buttonBlur: 40.0px;
|
||||
--lift-buttonY: 12.0px;
|
||||
--lift-heroBlur: 80.0px;
|
||||
--lift-heroY: 30.0px;
|
||||
--lift-sheetBlur: 80.0px;
|
||||
--lift-sheetX: -30.0px;
|
||||
--title-lineGap: 22.0px;
|
||||
--title-lineLift: -4.0px;
|
||||
--title-overviewGap: 34.0px;
|
||||
|
|
@ -316,4 +367,58 @@
|
|||
--savedIndicator-gap: 10.0px;
|
||||
--savedIndicator-icon: 28.0px;
|
||||
--savedIndicator-border: 2.0px;
|
||||
--discover-sheetPaddingX: 64.0px;
|
||||
--discover-sheetPaddingY: 80.0px;
|
||||
--discover-sheetGap: 18.0px;
|
||||
--discover-sheetSubGap: 14.0px;
|
||||
--discover-sheetRowPadding: 26.0px;
|
||||
--discover-sheetAvatar: 60.0px;
|
||||
--discover-sheetRadio: 40.0px;
|
||||
--discover-sheetRadioDot: 16.0px;
|
||||
--discover-sheetPersonGap: 24.0px;
|
||||
--discover-sheetButtonTop: 12.0px;
|
||||
--discover-chipTop: 16.0px;
|
||||
--discover-chipHeight: 68.0px;
|
||||
--discover-chipPadding: 28.0px;
|
||||
--discover-chipGap: 16.0px;
|
||||
--discover-heroTitleGap: 16.0px;
|
||||
--discover-heroKickerGap: 10.0px;
|
||||
--discover-heroMetaGap: 14.0px;
|
||||
--discover-heroOverviewGap: 30.0px;
|
||||
--discover-heroFactsGap: 22.0px;
|
||||
--discover-heroFactsHeight: 38.0px;
|
||||
--discover-heroSheetRight: 180.0px;
|
||||
--discover-heroSheetTop: 140.0px;
|
||||
--discover-heroSheetWidth: 360.0px;
|
||||
--discover-heroSheetHeight: 540.0px;
|
||||
--discover-dotSize: 14.0px;
|
||||
--discover-dotActive: 44.0px;
|
||||
--discover-dotGap: 14.0px;
|
||||
--discover-dotMargin: 18.0px;
|
||||
--discover-dotChevron: 40.0px;
|
||||
--discover-stateTop: 20.0px;
|
||||
--discover-statePaddingX: 40.0px;
|
||||
--discover-statePaddingY: 34.0px;
|
||||
--discover-stateTextGap: 24.0px;
|
||||
--discover-noteTop: 14.0px;
|
||||
--discover-headerTop: 150.0px;
|
||||
--discover-headerGap: 24.0px;
|
||||
--discover-entryPaddingX: 22.0px;
|
||||
--discover-entryPaddingY: 20.0px;
|
||||
--discover-entryText: 30.0px;
|
||||
--discover-sheetRadioRing: 3.0px;
|
||||
--discover-skeletonNameWidth: 260.0px;
|
||||
--discover-skeletonNameHeight: 34.0px;
|
||||
--discover-sheetSubTop: -8.0px;
|
||||
--discover-sheetRatingTop: -10.0px;
|
||||
--discover-sheetRatingBottom: 6.0px;
|
||||
--discover-sheetLabelTop: 8.0px;
|
||||
--discover-noteIcon: 30.0px;
|
||||
--discover-noteGap: 16.0px;
|
||||
--discover-frameBottomMin: 80.0px;
|
||||
--discover-anchorFitTop: 700.0px;
|
||||
--discover-anchorBottomClearance: 40.0px;
|
||||
--discover-stageGap: 10.0px;
|
||||
--discover-ambientInset: 80.0px;
|
||||
--discover-ambientBlur: 60.0px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py
|
||||
b36140ec4745837b9e912deb6eba5d9ce94871a18512d7fdaddb37b7dd79ce25 shell-tokens.json
|
||||
c56109dc854d03d74baf0384e09451461ad50ca266b1cc4d598b310475927d11 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
|
||||
407ec3f8cd78f4a59d037879ef066921fa0cf1c4ee81c14c8c2913d815c02305 generate_tokens.py
|
||||
276e8972052059732d19f2f19af6f10b4220b2f87237890a8f306f93f674a7a7 shell-tokens.json
|
||||
4c429a7d5aac5c62f558d7091776b2fe94e3ad9ddc40df74798962543728574e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
bc82f477611669fbc6dd0d3e6559b1e05b0ad280531a883102803ffb0ebcdecb title-tokens.json
|
||||
ae9ca44fc6e1d3bd4ecce133ba56f2d1bce267f1754b30028a6cc0124539a6f7 tokens.css
|
||||
a13b3dc369e8d5b1ca1d6de4f49daa8eafbeb822bf4984290f66e7c0e692d911 tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||