Build household requests under Settings
|
|
@ -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)
|
||||
|
|
|
|||
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()
|
||||
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,40 @@
|
|||
{
|
||||
"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" },
|
||||
"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:152" },
|
||||
"stageGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:152" },
|
||||
"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" },
|
||||
"separatorAlpha": { "$type": "number", "$value": 0.4, "source": "spec 4.4 caption separator rule" },
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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,43 @@
|
|||
// 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.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))
|
||||
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// 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.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
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", 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
|
||||
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 = item.poster, contentDescription = null,
|
||||
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)).background(C.surface3),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row {
|
||||
Text("for ${item.recipient(viewer)}", style = ComingInTypography.caption, color = secondary)
|
||||
Text(" · ", style = ComingInTypography.caption, color = secondary.copy(alpha = secondary.alpha * T.separatorAlpha))
|
||||
Text("by ${item.actor(viewer)}", style = ComingInTypography.caption, color = secondary)
|
||||
}
|
||||
FlowRow(Modifier.padding(top = T.stageMargin), 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, 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(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
|
||||
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph))
|
||||
Text("${READINESS_NAMES[index]} · $word", 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")
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
|||
"seerr" -> R.drawable.wolfskin_sparkle
|
||||
"advanced" -> R.drawable.wolfskin_sort
|
||||
"alert" -> R.drawable.wolfskin_alert
|
||||
"clock" -> R.drawable.wolfskin_clock
|
||||
"check" -> R.drawable.wolfskin_check
|
||||
"download" -> R.drawable.wolfskin_download
|
||||
else -> R.drawable.wolfskin_right
|
||||
}
|
||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -77,7 +77,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
|||
}
|
||||
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. */
|
||||
|
|
@ -48,6 +50,8 @@ 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)
|
||||
private var rootRoute by mutableStateOf("home")
|
||||
|
|
@ -63,6 +67,10 @@ class ShellState(
|
|||
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. */
|
||||
|
|
@ -86,7 +94,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)
|
||||
|
|
@ -135,6 +148,8 @@ class ShellState(
|
|||
navFocused = false
|
||||
navIndex = 0
|
||||
rootRoute = "home"
|
||||
requestReturnRoute = null
|
||||
requestsVisible = false
|
||||
views[person.key to route] = RememberedView()
|
||||
onAction(ShellAction.Person(selected))
|
||||
}
|
||||
|
|
@ -172,6 +187,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
|
||||
|
|
@ -180,15 +196,41 @@ 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 (route == "settings") {
|
||||
route = pickerReturnRoute
|
||||
onAction(ShellAction.Pop)
|
||||
|
|
|
|||
|
|
@ -82,20 +82,9 @@ 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,42 @@ 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 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 = 4.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
|
||||
const val separatorAlpha = 0.4f
|
||||
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
|
||||
|
|
|
|||
|
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 |
3
wolfskin-design/src/main/res/drawable/wolfskin_check.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="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>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_clock.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M20.5,12a8.5,8.5 0,1 1,-17,0a8.5,8.5 0,1 1,17,0M12,7.5V12l3,2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="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>
|
||||
|
|
@ -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,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("request-title/${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("request-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("request-title/${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 })
|
||||
}
|
||||
}
|
||||
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,37 @@
|
|||
--motion-scroll: 180ms;
|
||||
--motion-ratingDwell: 350ms;
|
||||
--motion-toast: 3200ms;
|
||||
--requests-sheetPaddingY: 80.0px;
|
||||
--requests-sheetPaddingX: 64.0px;
|
||||
--requests-sheetGap: 18.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: 8.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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
|
||||
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
|
||||
1037c46c9d804f9349e927257de13ba32cbd1c05e208ec0bc1d345f4b465f1eb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
c133085ef60612df9753ef3681446d9ccb2eb29b1ee1955b86254714c67525e3 tokens.css
|
||||
5c738477023a783ec59b439af121998e84bc2be0c3a0c04d39ccafef5b8dcf71 shell-tokens.json
|
||||
e12df5b5356875c64e5ddd1cbdde15649e3bda802c20c1342933688897a2e6fb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
40198a88e356c1f8c35b5099de4926bdf107143affe219a09d1a98d2abd02b2c tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||