fix android trivia experience
No files matched your search
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#F6E9E1"/>
|
||||
<g transform="translate(55 85) scale(.66)">
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="17" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/>
|
||||
<rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="19"/>
|
||||
<path stroke="#27635B" stroke-width="19" d="M245 245h23"/>
|
||||
<circle cx="185" cy="250" r="14" fill="#27635B"/><circle cx="328" cy="250" r="14" fill="#27635B"/>
|
||||
<path fill="none" stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
</g>
|
||||
<g transform="translate(64 41) scale(.82)" stroke="#27635B" stroke-width="10" stroke-linejoin="round">
|
||||
<path fill="#F1DCC7" d="M300 105l32-14 28 13 30-13 28 14v91l-28-14-30 13-28-13-32 14z"/>
|
||||
<path fill="#B7CFC8" d="M332 91l28 13v91l-28-13z"/>
|
||||
<path fill="#E9AF8B" d="M390 91l28 14v91l-28-14z"/>
|
||||
<path fill="none" d="M332 91v91m28-78v91m30-104v91"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert cheering after a correct answer</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<path fill="none" stroke="#3E2447" stroke-width="11" stroke-linecap="round" d="m158 252 21 17 31-34m140 0-31 34-21-17M200 324q57 62 113 0"/>
|
||||
<path fill="#147568" d="m425 61 15 34 37 4-28 25 8 36-32-19-32 19 8-36-28-25 37-4z"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><path stroke="#27635B" stroke-width="15" d="M245 246h23"/>
|
||||
<path fill="none" stroke="#27635B" stroke-width="11" stroke-linecap="round" d="m158 252 21 17 31-34m140 0-31 34-21-17M200 324q57 62 113 0"/>
|
||||
<path fill="#27635B" d="m425 61 15 34 37 4-28 25 8 36-32-19-32 19 8-36-28-25 37-4z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 844 B After Width: | Height: | Size: 844 B |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert delighted by a surprising fact</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="15" fill="#3E2447"/><circle cx="328" cy="251" r="15" fill="#3E2447"/><circle cx="257" cy="342" r="25" fill="#FFFBF2" stroke="#3E2447" stroke-width="10"/>
|
||||
<path fill="#E3AD35" stroke="#3E2447" stroke-width="8" d="M410 45c55 0 74 68 31 96l-2 25h-58l-2-25c-43-28-24-96 31-96z"/><path stroke="#3E2447" stroke-width="9" d="M382 181h57"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><path stroke="#27635B" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="15" fill="#27635B"/><circle cx="328" cy="251" r="15" fill="#27635B"/><circle cx="257" cy="342" r="25" fill="#F6E9E1" stroke="#27635B" stroke-width="10"/>
|
||||
<path fill="#E9AF8B" stroke="#27635B" stroke-width="8" d="M410 45c55 0 74 68 31 96l-2 25h-58l-2-25c-43-28-24-96 31-96z"/><path stroke="#27635B" stroke-width="9" d="M382 181h57"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 978 B |
@@ -1,11 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert smiling behind oversized glasses</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<path fill="none" stroke="#C45348" stroke-width="8" stroke-linecap="round" d="M153 150c-19 22-20 49-7 69m58-119c-15 31-10 54 7 72m67-66c21 22 21 49 8 73m64-27c-17 21-18 43-7 64M123 260c26 8 39 28 36 54m194-74c-27 13-36 35-29 60"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/>
|
||||
<rect x="268" y="205" width="119" height="92" rx="28" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/>
|
||||
<path stroke="#3E2447" stroke-width="15" stroke-linecap="round" d="M245 245h23"/>
|
||||
<circle cx="185" cy="250" r="12" fill="#3E2447"/><circle cx="328" cy="250" r="12" fill="#3E2447"/>
|
||||
<path fill="none" stroke="#3E2447" stroke-width="12" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
<circle cx="418" cy="120" r="27" fill="#17647E"/><path stroke="#17647E" stroke-width="11" stroke-linecap="round" d="M418 72V42m35 44 23-22"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M105 270C47 237 68 151 135 146c3-72 96-92 137-39 55-45 142-2 130 70 63 30 42 120-15 135-18 85-123 103-173 51-62 32-135-28-109-93z"/>
|
||||
<path fill="none" stroke="#E1814E" stroke-width="8" stroke-linecap="round" d="M153 150c-19 22-20 49-7 69m58-119c-15 31-10 54 7 72m67-66c21 22 21 49 8 73m64-27c-17 21-18 43-7 64M123 260c26 8 39 28 36 54m194-74c-27 13-36 35-29 60"/>
|
||||
<rect x="126" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
|
||||
<rect x="268" y="205" width="119" height="92" rx="28" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/>
|
||||
<path stroke="#27635B" stroke-width="15" stroke-linecap="round" d="M245 245h23"/>
|
||||
<circle cx="185" cy="250" r="12" fill="#27635B"/><circle cx="328" cy="250" r="12" fill="#27635B"/>
|
||||
<path fill="none" stroke="#27635B" stroke-width="12" stroke-linecap="round" d="M204 325q52 47 105 0"/>
|
||||
<circle cx="418" cy="120" r="27" fill="#778BA5"/><path stroke="#778BA5" stroke-width="11" stroke-linecap="round" d="M418 72V42m35 44 23-22"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Bert offering encouragement after a wrong answer</title>
|
||||
<path fill="#E96E5B" stroke="#3E2447" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#FFFBF2" stroke="#3E2447" stroke-width="15"/><path stroke="#3E2447" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="11" fill="#3E2447"/><circle cx="328" cy="251" r="11" fill="#3E2447"/><path fill="none" stroke="#3E2447" stroke-width="12" stroke-linecap="round" d="M205 350q52-44 104 0"/>
|
||||
<path fill="none" stroke="#17647E" stroke-width="10" stroke-linecap="round" d="M413 80c34 31 7 61-7 81-14-20-41-50-7-81l7-8z"/>
|
||||
<path fill="#F7BFBE" stroke="#27635B" stroke-width="12" d="M104 278C48 238 71 153 137 149c7-67 90-87 134-41 59-44 139 0 130 72 60 34 35 119-20 132-17 86-126 99-172 46-64 29-132-29-105-80z"/>
|
||||
<rect x="126" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><rect x="268" y="207" width="119" height="90" rx="27" fill="#F6E9E1" stroke="#27635B" stroke-width="15"/><path stroke="#27635B" stroke-width="15" d="M245 246h23"/>
|
||||
<circle cx="185" cy="251" r="11" fill="#27635B"/><circle cx="328" cy="251" r="11" fill="#27635B"/><path fill="none" stroke="#27635B" stroke-width="12" stroke-linecap="round" d="M205 350q52-44 104 0"/>
|
||||
<path fill="none" stroke="#778BA5" stroke-width="10" stroke-linecap="round" d="M413 80c34 31 7 61-7 81-14-20-41-50-7-81l7-8z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 958 B |
@@ -1 +1,152 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert correct accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"success star","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-12]},{"t":22,"s":[12]},{"t":45,"s":[-12]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[70,70,100]},{"t":22,"s":[110,110,100]},{"t":45,"s":[70,70,100]}]}},"shapes":[{"ty":"sr","sy":1,"pt":{"a":0,"k":5},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"or":{"a":0,"k":100},"os":{"a":0,"k":0},"ir":{"a":0,"k":45},"is":{"a":0,"k":0}},{"ty":"fl","c":{"a":0,"k":[0.08,0.46,0.41,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert correct accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "success star",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-12
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"s": [
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
-12
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
70,
|
||||
70,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"s": [
|
||||
110,
|
||||
110,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
70,
|
||||
70,
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sr",
|
||||
"sy": 1,
|
||||
"pt": {
|
||||
"a": 0,
|
||||
"k": 5
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"or": {
|
||||
"a": 0,
|
||||
"k": 42
|
||||
},
|
||||
"os": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"ir": {
|
||||
"a": 0,
|
||||
"k": 18
|
||||
},
|
||||
"is": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.152941,
|
||||
0.388235,
|
||||
0.356863,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,115 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert fact accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"fact bubble","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[256,270,0]},{"t":30,"s":[256,242,0]},{"t":60,"s":[256,270,0]}]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[190,150]}},{"ty":"fl","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert fact accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "fact bubble",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
420,
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
420,
|
||||
82,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
420,
|
||||
100,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
72,
|
||||
54
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.466667,
|
||||
0.545098,
|
||||
0.647059,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,113 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert hint accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"hint glow","ks":{"o":{"a":1,"k":[{"t":0,"s":[45]},{"t":30,"s":[100]},{"t":60,"s":[45]}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[180,180]}},{"ty":"fl","c":{"a":0,"k":[0.89,0.68,0.21,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert hint accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "hint glow",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
64,
|
||||
64
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.913725,
|
||||
0.686275,
|
||||
0.545098,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,157 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":90,"w":512,"h":512,"nm":"bert idle","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"spark","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-8]},{"t":45,"s":[8]},{"t":90,"s":[-8]}]},"p":{"a":0,"k":[410,105,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[85,85,100]},{"t":45,"s":[110,110,100]},{"t":90,"s":[85,85,100]}]}},"ao":0,"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[48,48]},"nm":"spark circle"},{"ty":"fl","c":{"a":0,"k":[0.09,0.45,0.41,1]},"o":{"a":0,"k":100},"r":1,"nm":"teal fill"}],"ip":0,"op":90,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"bert","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":0,"s":[256,266,0]},{"t":45,"s":[256,258,0]},{"t":90,"s":[256,266,0]}]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[310,270]},"nm":"brain silhouette"},{"ty":"fl","c":{"a":0,"k":[0.91,0.43,0.36,1]},"o":{"a":0,"k":100},"r":1,"nm":"coral fill"},{"ty":"st","c":{"a":0,"k":[0.24,0.14,0.28,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":12},"lc":2,"lj":2,"nm":"plum stroke"}],"ip":0,"op":90,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 90,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert idle accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "curiosity spark",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 90,
|
||||
"s": [
|
||||
45
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 90,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
412,
|
||||
102,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
75,
|
||||
75,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
105,
|
||||
105,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 90,
|
||||
"s": [
|
||||
75,
|
||||
75,
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
34,
|
||||
34
|
||||
]
|
||||
},
|
||||
"nm": "spark dot"
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.152941,
|
||||
0.388235,
|
||||
0.356863,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1,
|
||||
"nm": "teal fill"
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 90,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,124 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert incorrect accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"kind wobble","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-4]},{"t":15,"s":[4]},{"t":30,"s":[-4]},{"t":45,"s":[0]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[170,170]}},{"ty":"st","c":{"a":0,"k":[0.61,0.24,0.2,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":18},"lc":2,"lj":2}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert incorrect accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "kind wobble",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-4
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"s": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
-4
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
42,
|
||||
42
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.882353,
|
||||
0.505882,
|
||||
0.305882,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 18
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,112 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert overtime accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"clock","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0]},{"t":60,"s":[360]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[190,190]}},{"ty":"st","c":{"a":0,"k":[0.89,0.68,0.21,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert overtime accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "clock",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
360
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
70,
|
||||
70
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.913725,
|
||||
0.686275,
|
||||
0.545098,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 20
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,131 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":45,"w":512,"h":512,"nm":"bert results accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"result seal","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[-8]},{"t":22,"s":[8]},{"t":45,"s":[-8]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"sr","sy":1,"pt":{"a":0,"k":12},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"or":{"a":0,"k":110},"os":{"a":0,"k":0},"ir":{"a":0,"k":90},"is":{"a":0,"k":0}},{"ty":"fl","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"r":1}],"ip":0,"op":45,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert results accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "result seal",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"s": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 45,
|
||||
"s": [
|
||||
-8
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sr",
|
||||
"sy": 1,
|
||||
"pt": {
|
||||
"a": 0,
|
||||
"k": 12
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"or": {
|
||||
"a": 0,
|
||||
"k": 42
|
||||
},
|
||||
"os": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
},
|
||||
"ir": {
|
||||
"a": 0,
|
||||
"k": 32
|
||||
},
|
||||
"is": {
|
||||
"a": 0,
|
||||
"k": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.466667,
|
||||
0.545098,
|
||||
0.647059,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": 1
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 45,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,158 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":30,"w":512,"h":512,"nm":"bert passport stamp accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"stamp","ks":{"o":{"a":1,"k":[{"t":0,"s":[0]},{"t":10,"s":[100]},{"t":30,"s":[100]}]},"r":{"a":1,"k":[{"t":0,"s":[-18]},{"t":10,"s":[3]},{"t":30,"s":[0]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[150,150,100]},{"t":10,"s":[95,95,100]},{"t":30,"s":[100,100,100]}]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[220,220]}},{"ty":"st","c":{"a":0,"k":[0.09,0.39,0.49,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":20},"lc":2,"lj":2}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert passport stamp accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "stamp",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
-18
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"s": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
150,
|
||||
150,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"s": [
|
||||
95,
|
||||
95,
|
||||
100
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 30,
|
||||
"s": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
72,
|
||||
72
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.466667,
|
||||
0.545098,
|
||||
0.647059,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 20
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1 +1,112 @@
|
||||
{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"bert streak accent","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"streak ring","ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0]},{"t":60,"s":[360]}]},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[210,210]}},{"ty":"st","c":{"a":0,"k":[0.91,0.43,0.36,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":22},"lc":2,"lj":2}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]}
|
||||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 512,
|
||||
"h": 512,
|
||||
"nm": "bert streak accent",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "streak ring",
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"t": 0,
|
||||
"s": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"t": 60,
|
||||
"s": [
|
||||
360
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
420,
|
||||
95,
|
||||
0
|
||||
]
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
]
|
||||
}
|
||||
},
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
70,
|
||||
70
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [
|
||||
0.968627,
|
||||
0.74902,
|
||||
0.745098,
|
||||
1
|
||||
]
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 22
|
||||
},
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import app.bertsatlas.domain.model.HintType
|
||||
import app.bertsatlas.domain.model.Question
|
||||
import app.bertsatlas.domain.model.QuizDuration
|
||||
import app.bertsatlas.domain.model.RunPhase
|
||||
import app.bertsatlas.domain.model.RunStatus
|
||||
import app.bertsatlas.ui.AnswerState
|
||||
import app.bertsatlas.ui.BertsAtlasTheme
|
||||
import app.bertsatlas.ui.BootstrapScreen
|
||||
@@ -27,6 +28,7 @@ import app.bertsatlas.ui.FlagUi
|
||||
import app.bertsatlas.ui.FunFactScreen
|
||||
import app.bertsatlas.ui.HintChooserScreen
|
||||
import app.bertsatlas.ui.HomeScreen
|
||||
import app.bertsatlas.ui.LeaveQuizConfirmationScreen
|
||||
import app.bertsatlas.ui.LetterSlotUi
|
||||
import app.bertsatlas.ui.LetterTileUi
|
||||
import app.bertsatlas.ui.OptionsScreen
|
||||
@@ -66,12 +68,21 @@ fun App(
|
||||
@Composable
|
||||
private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
|
||||
val run = state.run
|
||||
if (state.leaveConfirmationRequested) {
|
||||
LeaveQuizConfirmationScreen(
|
||||
onCancel = controller::cancelLeave,
|
||||
onLeave = controller::confirmLeave,
|
||||
)
|
||||
return
|
||||
}
|
||||
when (val destination = state.destination) {
|
||||
Destination.Bootstrap -> BootstrapScreen()
|
||||
Destination.Home -> HomeScreen(
|
||||
isFirstLaunch = state.firstLaunch,
|
||||
streakDays = state.streak.currentDays,
|
||||
progressSummary = "${state.attempts.size} questions explored",
|
||||
totalPoints = state.attempts.sumOf { it.pointsAwarded } - state.attempts.sumOf { it.hintPointsDeducted },
|
||||
hasStartedTrivia = !state.firstLaunch,
|
||||
onStart = controller::start,
|
||||
onOptions = controller::openOptions,
|
||||
onProgress = { controller.navigate(Destination.Progress) },
|
||||
@@ -88,7 +99,7 @@ private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
|
||||
score = run.score,
|
||||
timeRemaining = run.sessionRemainingMillis.formatTime(),
|
||||
onResume = controller::resume,
|
||||
onAbandon = controller::abandon,
|
||||
onAbandon = controller::requestLeave,
|
||||
)
|
||||
Destination.Quiz -> if (run == null) BootstrapScreen() else RunDestination(controller, state)
|
||||
Destination.Results -> {
|
||||
@@ -98,19 +109,20 @@ private fun AtlasDestination(controller: AppController, state: AtlasAppState) {
|
||||
correctAnswers = result.correctAnswers,
|
||||
answeredQuestions = result.answeredQuestions,
|
||||
accuracy = "${result.accuracyPercent}%",
|
||||
averageResponseTime = "${result.averageResponseMillis / 1_000.0}s",
|
||||
averageResponseTime = result.averageResponseMillis.formatSeconds(),
|
||||
bestCorrectStreak = result.bestCorrectStreak,
|
||||
masteryImproved = result.improvedMasteryItems,
|
||||
revisitCountries = result.revisitCountryIds.mapNotNull { id -> controller.catalog.countries.firstOrNull { it.id == id }?.displayName },
|
||||
newPersonalBest = state.newPersonalBest,
|
||||
onPlayAgain = controller::start,
|
||||
onHome = { controller.navigate(Destination.Home) },
|
||||
abandoned = result.status == RunStatus.Abandoned,
|
||||
)
|
||||
}
|
||||
Destination.Progress -> ProgressScreen(
|
||||
currentStreak = state.streak.currentDays,
|
||||
bestStreak = state.streak.bestDays,
|
||||
overallAccuracy = if (state.attempts.isEmpty()) "—" else "${state.attempts.count { it.correct } * 100 / state.attempts.size}%",
|
||||
overallAccuracy = if (state.attempts.isEmpty()) "Not yet" else "${state.attempts.count { it.correct } * 100 / state.attempts.size}%",
|
||||
totalAnswered = state.attempts.size,
|
||||
masteryGroups = controller.progressGroups,
|
||||
strongCountries = countryNames(controller, state.mastery.filter { it.level.name in setOf("Familiar", "Mastered") }.map { it.countryId }),
|
||||
@@ -163,28 +175,32 @@ private fun RunDestination(controller: AppController, state: AtlasAppState) {
|
||||
prompt = question.prompt,
|
||||
imageDescription = if (promptFlag == null) null else "A flag to identify",
|
||||
score = run.score,
|
||||
sessionTime = if (run.configuration.duration == QuizDuration.Unlimited) {
|
||||
(run.attempts.sumOf { it.responseMillis } + run.activeQuestion.questionElapsedMillis).formatTime()
|
||||
} else run.sessionRemainingMillis.formatTime(),
|
||||
questionTime = run.activeQuestion.questionRemainingMillis.formatTime(),
|
||||
timerExpired = run.activeQuestion.questionRemainingMillis == 0L,
|
||||
choices = (question as? Question.MultipleChoice)?.let {
|
||||
run.activeQuestion.orderedChoices.map { choice ->
|
||||
val choiceCountry = controller.catalog.countries.firstOrNull { it.id == choice.id }
|
||||
ChoiceUi(
|
||||
id = choice.id,
|
||||
label = choice.label,
|
||||
state = if (choice.id in run.activeQuestion.removedChoiceIds) AnswerState.Eliminated else AnswerState.Available,
|
||||
enabled = choice.id !in run.activeQuestion.removedChoiceIds && run.phase == RunPhase.Answering,
|
||||
flag = choice.assetId?.let { FlagUi(it.removePrefix("flag-"), choiceCountry?.displayName ?: choice.label) },
|
||||
flag = choice.assetId?.let { FlagUi(it.removePrefix("flag-"), choice.label) },
|
||||
)
|
||||
}
|
||||
}.orEmpty(),
|
||||
letterSlots = letterSlots(question, run.activeQuestion.placements, run.activeQuestion.letterTiles),
|
||||
letterSlots = letterSlots(
|
||||
question,
|
||||
run.activeQuestion.placements,
|
||||
run.activeQuestion.letterTiles,
|
||||
run.activeQuestion.appliedHints.mapNotNullTo(linkedSetOf()) { it.revealedTileId },
|
||||
),
|
||||
letterTiles = run.activeQuestion.letterTiles.map { tile ->
|
||||
LetterTileUi(tile.id, tile.character.toString(), run.activeQuestion.placements.firstOrNull { it.tileId == tile.id }?.slotIndex, tile.id !in run.activeQuestion.removedTileIds)
|
||||
},
|
||||
hintAvailable = run.activeQuestion.hint == null && run.phase == RunPhase.Answering,
|
||||
appliedHintMessages = run.activeQuestion.appliedHints.mapNotNull { it.message },
|
||||
hintAvailable = run.activeQuestion.question.supportedHints.any {
|
||||
it !in run.activeQuestion.usedHintTypes
|
||||
} && run.phase == RunPhase.Answering,
|
||||
unlimited = run.configuration.duration == QuizDuration.Unlimited,
|
||||
canSubmit = question is Question.LetterBank && run.activeQuestion.placements.size == question.displayAnswer.count { it.isLetter() },
|
||||
onChoice = controller::answerChoice,
|
||||
@@ -196,11 +212,25 @@ private fun RunDestination(controller: AppController, state: AtlasAppState) {
|
||||
onFinish = controller::finishUnlimited,
|
||||
onLeave = controller::requestLeave,
|
||||
promptFlag = promptFlag,
|
||||
runProgress = run.configuration.duration.activeDurationMillis?.let { duration ->
|
||||
1f - ((run.sessionRemainingMillis ?: duration).toFloat() / duration.toFloat())
|
||||
},
|
||||
runProgressDescription = "Trivia time remaining ${run.sessionRemainingMillis.formatTime()}",
|
||||
)
|
||||
}
|
||||
RunPhase.HintChooser -> HintChooserScreen(
|
||||
score = run.score,
|
||||
hints = run.activeQuestion.question.supportedHints.map { ChoiceUi(it.name, it.displayName()) },
|
||||
hints = run.activeQuestion.question.supportedHints.map { hint ->
|
||||
val used = hint in run.activeQuestion.usedHintTypes
|
||||
ChoiceUi(
|
||||
id = hint.name,
|
||||
label = hint.displayName(),
|
||||
supportingText = if (used) "✓ Used" else null,
|
||||
state = if (used) AnswerState.Selected else AnswerState.Available,
|
||||
enabled = !used,
|
||||
)
|
||||
},
|
||||
chargedHintLabel = "−${run.score / 2} points",
|
||||
onChooseHint = { id -> controller.chooseHint(HintType.valueOf(id)) },
|
||||
onCancel = controller::cancelHint,
|
||||
)
|
||||
@@ -227,7 +257,7 @@ private fun RunDestination(controller: AppController, state: AtlasAppState) {
|
||||
RunPhase.FunFact -> requireNotNull(run.visibleFact).let { fact ->
|
||||
FunFactScreen(fact.heading, fact.body, countryNames(controller, fact.countryIds).joinToString(), onContinue = controller::continueRun)
|
||||
}
|
||||
RunPhase.Completed -> BootstrapScreen("Finishing your field notes…")
|
||||
RunPhase.Completed -> BootstrapScreen("Preparing your summary…")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +271,12 @@ private fun topicChoices() = listOf(
|
||||
ChoiceUi("MixedGeography", "Mixed geography", "A broader grab bag"),
|
||||
)
|
||||
|
||||
private fun letterSlots(question: Question, placements: List<app.bertsatlas.domain.model.LetterPlacement>, tiles: List<app.bertsatlas.domain.model.LetterTile>): List<LetterSlotUi> {
|
||||
private fun letterSlots(
|
||||
question: Question,
|
||||
placements: List<app.bertsatlas.domain.model.LetterPlacement>,
|
||||
tiles: List<app.bertsatlas.domain.model.LetterTile>,
|
||||
revealedTileIds: Set<String>,
|
||||
): List<LetterSlotUi> {
|
||||
val letterBank = question as? Question.LetterBank ?: return emptyList()
|
||||
val tileById = tiles.associateBy { it.id }
|
||||
var editableIndex = 0
|
||||
@@ -249,7 +284,12 @@ private fun letterSlots(question: Question, placements: List<app.bertsatlas.doma
|
||||
if (!character.isLetter()) LetterSlotUi(displayIndex, character.toString(), fixed = true)
|
||||
else {
|
||||
val placement = placements.firstOrNull { it.slotIndex == editableIndex }
|
||||
LetterSlotUi(displayIndex, placement?.let { tileById[it.tileId]?.character?.toString() }, placement?.tileId).also { editableIndex++ }
|
||||
LetterSlotUi(
|
||||
position = displayIndex,
|
||||
character = placement?.let { tileById[it.tileId]?.character?.toString() },
|
||||
tileId = placement?.tileId,
|
||||
fixed = placement?.tileId in revealedTileIds,
|
||||
).also { editableIndex++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +307,8 @@ private fun Long?.formatTime(): String {
|
||||
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
private fun Long.formatSeconds(): String = "${((coerceAtLeast(0) + 500) / 1_000)} sec"
|
||||
|
||||
private fun countryNames(controller: AppController, ids: List<String>): List<String> = ids.distinct().mapNotNull { id ->
|
||||
controller.catalog.countries.firstOrNull { it.id == id }?.displayName
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import app.bertsatlas.ui.StampUi
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.TimeSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -85,6 +86,8 @@ data class AtlasAppState(
|
||||
val completedRuns: List<RunEntity> = emptyList(),
|
||||
val streak: Streak = Streak(),
|
||||
val stamps: List<PassportStamp> = emptyList(),
|
||||
val leaveConfirmationRequested: Boolean = false,
|
||||
val resumeRunAfterLeaveCancellation: Boolean = false,
|
||||
)
|
||||
|
||||
class AppController(
|
||||
@@ -97,6 +100,7 @@ class AppController(
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val mutex = Mutex()
|
||||
private var lastRunPersistence: Job? = null
|
||||
private val monotonicOrigin = TimeSource.Monotonic.markNow()
|
||||
private val monotonicClock = MonotonicClock { monotonicOrigin.elapsedNow().inWholeMilliseconds }
|
||||
private val wallClock = WallClock { Clock.System.now().toEpochMilliseconds() }
|
||||
@@ -114,7 +118,9 @@ class AppController(
|
||||
delay(100)
|
||||
val current = _state.value.run
|
||||
if (current?.phase == RunPhase.Answering) {
|
||||
_state.value = _state.value.copy(run = engine.advanceClocks(current))
|
||||
val advanced = engine.advanceClocks(current)
|
||||
if (advanced.sessionExpired) acceptTransition(engine.finishExpiredRun(advanced))
|
||||
else _state.value = _state.value.copy(run = advanced)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,9 +138,11 @@ class AppController(
|
||||
runCatching { engine.restoreRun(json.decodeFromString<ActiveRunSnapshot>(it.snapshotJson)) }.getOrNull()
|
||||
}
|
||||
if (snapshot != null && restored == null) database.runWriter().clearActiveRun()
|
||||
val hasStartedTrivia = preferences.hasStartedTrivia || restored != null || attempts.isNotEmpty() || runs.isNotEmpty()
|
||||
if (hasStartedTrivia && !preferences.hasStartedTrivia) preferencesStore.markTriviaStarted()
|
||||
_state.value = AtlasAppState(
|
||||
destination = if (restored == null) Destination.Home else Destination.Recovery,
|
||||
firstLaunch = !preferences.hasPreviouslyLaunched,
|
||||
firstLaunch = !hasStartedTrivia,
|
||||
preferences = preferences,
|
||||
selectedDuration = enumValueOrDefault(preferences.duration, QuizDuration.FiveMinutes),
|
||||
run = restored,
|
||||
@@ -144,7 +152,6 @@ class AppController(
|
||||
streak = streak,
|
||||
stamps = stamps,
|
||||
)
|
||||
preferencesStore.markLaunched()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
@@ -184,11 +191,38 @@ class AppController(
|
||||
}
|
||||
}
|
||||
|
||||
fun back() {
|
||||
_state.value = when (val destination = _state.value.destination) {
|
||||
is Destination.Options -> _state.value.copy(destination = if (destination.step == 2) Destination.Options(1) else Destination.Home)
|
||||
Destination.Privacy, Destination.Credits, Destination.Reset, Destination.Debug -> _state.value.copy(destination = Destination.Settings)
|
||||
else -> _state.value.copy(destination = Destination.Home)
|
||||
fun back() { handleBack() }
|
||||
|
||||
fun handleBack(): Boolean {
|
||||
val current = _state.value
|
||||
if (current.leaveConfirmationRequested) {
|
||||
cancelLeave()
|
||||
return true
|
||||
}
|
||||
return when (val destination = current.destination) {
|
||||
Destination.Bootstrap, Destination.Home -> false
|
||||
is Destination.Options -> {
|
||||
_state.value = current.copy(
|
||||
destination = if (destination.step == 2) Destination.Options(1) else Destination.Home,
|
||||
)
|
||||
true
|
||||
}
|
||||
Destination.Recovery -> {
|
||||
requestLeave()
|
||||
true
|
||||
}
|
||||
Destination.Quiz -> {
|
||||
if (current.run?.phase == RunPhase.HintChooser) cancelHint() else requestLeave()
|
||||
true
|
||||
}
|
||||
Destination.Results, Destination.Progress, Destination.Settings -> {
|
||||
_state.value = current.copy(destination = Destination.Home)
|
||||
true
|
||||
}
|
||||
Destination.Privacy, Destination.Credits, Destination.Reset, Destination.Debug -> {
|
||||
_state.value = current.copy(destination = Destination.Settings)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +230,7 @@ class AppController(
|
||||
val now = wallClock.nowEpochMillis()
|
||||
val run = engine.startRun("run-$now", configuration, now xor monotonicClock.nowMillis(), _state.value.mastery)
|
||||
_state.value = _state.value.copy(destination = Destination.Quiz, firstLaunch = false, run = run, result = null)
|
||||
scope.launch { preferencesStore.markTriviaStarted() }
|
||||
persistSnapshot(run)
|
||||
}
|
||||
|
||||
@@ -206,9 +241,11 @@ class AppController(
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
fun answerChoice(choiceId: String) = mutateRun { engine.answerQuestion(it, SubmittedAnswer.ChoiceAnswer(choiceId)) }.also {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
fun answerChoice(choiceId: String) {
|
||||
if (mutateRun { engine.answerQuestion(it, SubmittedAnswer.ChoiceAnswer(choiceId)) }) {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
}
|
||||
}
|
||||
|
||||
fun placeLetter(tileId: String) = mutateRun { engine.placeLetter(it, tileId) }
|
||||
@@ -220,14 +257,18 @@ class AppController(
|
||||
|
||||
fun clearLetters() = mutateRun(engine::clearLetters)
|
||||
|
||||
fun submitLetters() = mutateRun(engine::submitLetters).also {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
fun submitLetters() {
|
||||
if (mutateRun(engine::submitLetters)) {
|
||||
playAnswerFeedback()
|
||||
persistAcceptedAnswer()
|
||||
}
|
||||
}
|
||||
|
||||
fun openHint() = mutateRun(engine::openHintChooser)
|
||||
fun cancelHint() = mutateRun { state -> engine.resumeRun(state.copy(phase = RunPhase.Paused, pausedFromPhase = RunPhase.Answering)) }
|
||||
fun chooseHint(type: HintType) = mutateRun { engine.chooseHint(it, type) }.also { persistSnapshot(_state.value.run) }
|
||||
fun chooseHint(type: HintType) {
|
||||
if (mutateRun { engine.chooseHint(it, type) }) persistSnapshot(_state.value.run)
|
||||
}
|
||||
|
||||
fun continueRun() {
|
||||
val before = _state.value.run ?: return
|
||||
@@ -244,17 +285,65 @@ class AppController(
|
||||
acceptTransition(engine.finishUnlimitedRun(run, _state.value.mastery))
|
||||
}
|
||||
|
||||
fun requestLeave() { abandon() }
|
||||
fun requestLeave() {
|
||||
val current = _state.value
|
||||
val run = current.run ?: return
|
||||
if (run.status != RunStatus.Active || current.leaveConfirmationRequested) return
|
||||
val shouldResumeOnCancel = run.phase != RunPhase.Paused
|
||||
val paused = if (shouldResumeOnCancel) engine.pauseForInterruption(run) else run
|
||||
_state.value = current.copy(
|
||||
run = paused,
|
||||
leaveConfirmationRequested = true,
|
||||
resumeRunAfterLeaveCancellation = shouldResumeOnCancel,
|
||||
)
|
||||
persistSnapshot(paused)
|
||||
}
|
||||
|
||||
fun cancelLeave() {
|
||||
val current = _state.value
|
||||
if (!current.leaveConfirmationRequested) return
|
||||
val run = current.run
|
||||
val resumed = if (
|
||||
current.resumeRunAfterLeaveCancellation &&
|
||||
run?.status == RunStatus.Active &&
|
||||
run.phase == RunPhase.Paused
|
||||
) {
|
||||
engine.resumeRun(run)
|
||||
} else {
|
||||
run
|
||||
}
|
||||
_state.value = current.copy(
|
||||
run = resumed,
|
||||
leaveConfirmationRequested = false,
|
||||
resumeRunAfterLeaveCancellation = false,
|
||||
)
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
fun confirmLeave() { abandon() }
|
||||
|
||||
fun abandon() {
|
||||
val run = _state.value.run ?: return
|
||||
val abandoned = if (run.status == RunStatus.Active) engine.abandonRun(run) else run
|
||||
_state.value = _state.value.copy(destination = Destination.Home, run = null)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
database.runWriter().abandonRun(abandoned.toRunEntity())
|
||||
refreshProgress()
|
||||
}
|
||||
if (abandoned.status != RunStatus.Abandoned) return
|
||||
val summary = engine.abandonedRunSummary(
|
||||
abandoned,
|
||||
improvedMasteryItems = abandoned.attempts
|
||||
.flatMap { attempt -> attempt.countryIds.map { it to attempt.skill } }
|
||||
.distinct()
|
||||
.size,
|
||||
)
|
||||
_state.value = _state.value.copy(
|
||||
destination = Destination.Results,
|
||||
run = abandoned,
|
||||
result = summary,
|
||||
newPersonalBest = false,
|
||||
leaveConfirmationRequested = false,
|
||||
resumeRunAfterLeaveCancellation = false,
|
||||
)
|
||||
enqueueRunPersistence {
|
||||
database.runWriter().abandonRun(abandoned.toRunEntity(summary))
|
||||
refreshProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,16 +357,18 @@ class AppController(
|
||||
|
||||
fun resumeAfterInterruption() {
|
||||
val run = _state.value.run ?: return
|
||||
if (_state.value.leaveConfirmationRequested) return
|
||||
if (_state.value.destination != Destination.Quiz || run.phase != RunPhase.Paused) return
|
||||
val resumed = engine.resumeRun(run)
|
||||
_state.value = _state.value.copy(run = resumed)
|
||||
persistSnapshot(resumed)
|
||||
}
|
||||
|
||||
private fun mutateRun(change: (QuizRunState) -> QuizRunState) {
|
||||
val current = _state.value.run ?: return
|
||||
val next = runCatching { change(current) }.getOrNull() ?: return
|
||||
private fun mutateRun(change: (QuizRunState) -> QuizRunState): Boolean {
|
||||
val current = _state.value.run ?: return false
|
||||
val next = runCatching { change(current) }.getOrNull() ?: return false
|
||||
_state.value = _state.value.copy(destination = destinationFor(next), run = next)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun acceptTransition(next: QuizRunState) {
|
||||
@@ -291,18 +382,16 @@ class AppController(
|
||||
private fun persistAcceptedAnswer() {
|
||||
val run = _state.value.run ?: return
|
||||
val attempt = run.attempts.lastOrNull() ?: return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val before = _state.value.mastery
|
||||
val after = MasteryRules.applyAttemptToCountries(before, attempt)
|
||||
val changed = after.filter { updated -> before.none { it.countryId == updated.countryId && it.skill == updated.skill && it == updated } }
|
||||
database.runWriter().recordAcceptedAnswer(
|
||||
attempt.toEntity(json),
|
||||
changed.map { it.toEntity() },
|
||||
run.toSnapshotEntity(engine, json),
|
||||
)
|
||||
_state.value = _state.value.copy(mastery = after)
|
||||
}
|
||||
enqueueRunPersistence {
|
||||
val before = _state.value.mastery
|
||||
val after = MasteryRules.applyAttemptToCountries(before, attempt)
|
||||
val changed = after.filter { updated -> before.none { it.countryId == updated.countryId && it.skill == updated.skill && it == updated } }
|
||||
database.runWriter().recordAcceptedAnswer(
|
||||
attempt.toEntity(json),
|
||||
changed.map { it.toEntity() },
|
||||
run.toSnapshotEntity(engine, json),
|
||||
)
|
||||
_state.value = _state.value.copy(mastery = after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,18 +403,15 @@ class AppController(
|
||||
|
||||
private fun persistSnapshot(run: QuizRunState?) {
|
||||
if (run == null || run.status != RunStatus.Active) return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
database.runWriter().saveActiveRun(run.toSnapshotEntity(engine, json))
|
||||
}
|
||||
enqueueRunPersistence {
|
||||
database.runWriter().saveActiveRun(run.toSnapshotEntity(engine, json))
|
||||
}
|
||||
}
|
||||
|
||||
private fun complete(run: QuizRunState) {
|
||||
val result = engine.runResult(run, improvedMasteryItems = run.attempts.map { it.countryIds.firstOrNull() to it.skill }.distinct().size)
|
||||
_state.value = _state.value.copy(destination = Destination.Results, run = run, result = result)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
enqueueRunPersistence {
|
||||
val reader = database.progressReader()
|
||||
val oldBest = reader.personalBestsNow().firstOrNull {
|
||||
it.duration == run.configuration.duration.name && it.topic == run.configuration.topic.name
|
||||
@@ -357,7 +443,14 @@ class AppController(
|
||||
)
|
||||
_state.value = _state.value.copy(newPersonalBest = isBest, streak = newStreak, stamps = existingStamps + newlyEarned)
|
||||
refreshProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueRunPersistence(block: suspend () -> Unit) {
|
||||
val precedingWrite = lastRunPersistence
|
||||
lastRunPersistence = scope.launch {
|
||||
precedingWrite?.join()
|
||||
mutex.withLock { block() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +480,7 @@ class AppController(
|
||||
}
|
||||
|
||||
fun resetAll() {
|
||||
scope.launch {
|
||||
enqueueRunPersistence {
|
||||
database.runWriter().resetProgress()
|
||||
preferencesStore.resetAll()
|
||||
_state.value = AtlasAppState(destination = Destination.Home)
|
||||
@@ -398,10 +491,12 @@ class AppController(
|
||||
if (!debugBuild) return
|
||||
when (id) {
|
||||
"reset-first" -> scope.launch { preferencesStore.resetFirstLaunchAndConfiguration(); _state.value = _state.value.copy(firstLaunch = true, destination = Destination.Home) }
|
||||
"reset-progress" -> scope.launch { database.runWriter().resetProgress(); refreshProgress(); _state.value = _state.value.copy(destination = Destination.Home, run = null) }
|
||||
"reset-progress" -> enqueueRunPersistence { database.runWriter().resetProgress(); refreshProgress(); _state.value = _state.value.copy(destination = Destination.Home, run = null) }
|
||||
"reset-all" -> resetAll()
|
||||
"question-zero" -> updateRunDebug { it.copy(activeQuestion = it.activeQuestion.copy(questionRemainingMillis = 0)) }
|
||||
"session-zero" -> updateRunDebug { it.copy(sessionRemainingMillis = 0, sessionExpired = true) }
|
||||
"session-zero" -> updateRunDebug {
|
||||
it.copy(sessionRemainingMillis = 0, sessionExpired = true, runCompletionPending = true)
|
||||
}
|
||||
"score-100" -> updateRunDebug { it.copy(score = 100) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ object CatalogLoader {
|
||||
val similarityGroupsByCountry = source.similarityGroups
|
||||
.flatMap { group -> group.countryIds.map { it to group.id } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
val curatedCapitalCountryIds = source.curatedQuestions
|
||||
.filter { it.id.startsWith("mixed-capital-") }
|
||||
.flatMapTo(mutableSetOf()) { it.countryIds }
|
||||
val domainCountries = source.countries.map { country ->
|
||||
country.toDomain(similarityGroupsByCountry[country.id].orEmpty())
|
||||
}
|
||||
@@ -53,11 +56,10 @@ object CatalogLoader {
|
||||
similarityGroupIds = similarityGroupsByCountry[country.id].orEmpty(),
|
||||
similarityGroups = source.similarityGroups,
|
||||
relatedFacts = factsByCountry[country.id].orEmpty(),
|
||||
hasCuratedCapitalQuestion = country.id in curatedCapitalCountryIds,
|
||||
)
|
||||
}
|
||||
val curatedQuestions = source.curatedQuestions.map { question ->
|
||||
question.toDomain(countriesById)
|
||||
}
|
||||
val curatedQuestions = source.curatedQuestions.map { question -> question.toDomain(countriesById) }
|
||||
return Catalog(
|
||||
schemaVersion = source.schemaVersion,
|
||||
contentVersion = source.catalogVersion,
|
||||
@@ -76,6 +78,7 @@ object CatalogLoader {
|
||||
similarityGroupIds: List<String>,
|
||||
similarityGroups: List<SourceSimilarityGroup>,
|
||||
relatedFacts: List<SourceFact>,
|
||||
hasCuratedCapitalQuestion: Boolean,
|
||||
): List<Question> {
|
||||
val distractors = chooseDistractors(
|
||||
country = country,
|
||||
@@ -94,13 +97,22 @@ object CatalogLoader {
|
||||
require(capitalChoices.size == 4) { "Not enough unique capital choices for ${country.id}" }
|
||||
val difficulty = generatedDifficulty(countryIndex)
|
||||
val continentHint = country.region.toContinent().displayName()
|
||||
val factualClue = relatedFacts.firstOrNull()?.text
|
||||
val factualClue = relatedFacts.firstOrNull()?.text?.withoutAnswer(country)
|
||||
val capitalFactualClue = factualClue?.takeUnless { clue ->
|
||||
country.capital.names.any { capital -> clue.contains(capital, ignoreCase = true) }
|
||||
}
|
||||
val commonHintText = buildMap {
|
||||
put(HintType.RevealContinent, continentHint)
|
||||
factualClue?.let { put(HintType.FunnyFactualClue, it) }
|
||||
}
|
||||
val factualHint = if (factualClue == null) emptySet() else setOf(HintType.FunnyFactualClue)
|
||||
val sharedHints = setOf(HintType.RevealContinent) + factualHint
|
||||
val capitalHintText = buildMap {
|
||||
put(HintType.RevealContinent, continentHint)
|
||||
capitalFactualClue?.let { put(HintType.FunnyFactualClue, it) }
|
||||
}
|
||||
val capitalHints = setOf(HintType.RevealContinent) +
|
||||
if (capitalFactualClue == null) emptySet() else setOf(HintType.FunnyFactualClue)
|
||||
val flagToCountry = Question.MultipleChoice(
|
||||
id = "generated-flag-to-country-${country.id}",
|
||||
prompt = "Which country does this flag belong to?",
|
||||
@@ -125,8 +137,12 @@ object CatalogLoader {
|
||||
explanation = "This is the flag of ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
choices = choices.map {
|
||||
Choice(id = it.id, label = it.displayName, assetId = it.flagAssetId)
|
||||
choices = choices.mapIndexed { choiceIndex, choice ->
|
||||
Choice(
|
||||
id = choice.id,
|
||||
label = "Flag ${('A'.code + choiceIndex).toChar()}",
|
||||
assetId = choice.flagAssetId,
|
||||
)
|
||||
},
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
@@ -155,26 +171,34 @@ object CatalogLoader {
|
||||
skill = Skill.CapitalRecall,
|
||||
topic = QuizTopic.Capitals,
|
||||
difficulty = difficulty,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital answer used for ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital of ${country.displayName}.",
|
||||
supportedHints = capitalHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = capitalHintText,
|
||||
choices = capitalChoices.map { Choice(it.id, it.capital.acceptedAnswer) },
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
val capitalToCountry = Question.MultipleChoice(
|
||||
id = "generated-capital-to-country-${country.id}",
|
||||
prompt = "${country.capital.acceptedAnswer} is the capital answer for which country?",
|
||||
prompt = "${country.capital.acceptedAnswer} is the capital of which country?",
|
||||
countryIds = listOf(country.id),
|
||||
skill = Skill.CapitalRecognition,
|
||||
topic = QuizTopic.Capitals,
|
||||
difficulty = difficulty,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital answer used for ${country.displayName}.",
|
||||
supportedHints = sharedHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = commonHintText,
|
||||
explanation = country.capital.note ?: "${country.capital.acceptedAnswer} is the capital of ${country.displayName}.",
|
||||
supportedHints = capitalHints + HintType.RemoveIncorrectOptions,
|
||||
hintText = capitalHintText,
|
||||
choices = choices.map { Choice(it.id, it.displayName) },
|
||||
correctChoiceId = country.id,
|
||||
)
|
||||
return listOf(flagToCountry, countryToFlag, letterBank, countryToCapital, capitalToCountry)
|
||||
return buildList {
|
||||
add(flagToCountry)
|
||||
add(countryToFlag)
|
||||
add(letterBank)
|
||||
if (country.capital.questionEligible) {
|
||||
if (!hasCuratedCapitalQuestion) add(countryToCapital)
|
||||
add(capitalToCountry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseDistractors(
|
||||
@@ -209,7 +233,11 @@ object CatalogLoader {
|
||||
val answerIndex = choices.indexOf(answer)
|
||||
require(answerIndex >= 0) { "Curated question $id does not contain its answer" }
|
||||
val primaryCountry = countryIds.firstNotNullOfOrNull(countriesById::get)
|
||||
val mappedHints = supportedHints.mapTo(linkedSetOf()) { it.toHintType() }
|
||||
val mappedHints = supportedHints
|
||||
.mapTo(linkedSetOf()) { it.toHintType() }
|
||||
.apply {
|
||||
if (explanation.contains(answer, ignoreCase = true)) remove(HintType.FunnyFactualClue)
|
||||
}
|
||||
val hintText = buildMap {
|
||||
if (HintType.RevealContinent in mappedHints && primaryCountry != null) {
|
||||
put(HintType.RevealContinent, primaryCountry.region.toContinent().displayName())
|
||||
@@ -335,6 +363,14 @@ object CatalogLoader {
|
||||
|
||||
private const val DECOY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
private val decoder = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun String.withoutAnswer(country: SourceCountry): String {
|
||||
val names = (country.acceptedAliases + country.displayName).sortedByDescending(String::length)
|
||||
val redacted = names.fold(this) { text, name ->
|
||||
text.replace(Regex(Regex.escape(name), RegexOption.IGNORE_CASE), "this country")
|
||||
}
|
||||
return redacted.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -373,6 +409,7 @@ private data class SourceCapital(
|
||||
val kind: String,
|
||||
val names: List<String>,
|
||||
val acceptedAnswer: String,
|
||||
val questionEligible: Boolean,
|
||||
val note: String?,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
data class SavedPreferences(
|
||||
val hasPreviouslyLaunched: Boolean = false,
|
||||
val hasStartedTrivia: Boolean = false,
|
||||
val duration: String = "FiveMinutes",
|
||||
val topic: String = "MostlyFlags",
|
||||
val soundEnabled: Boolean = true,
|
||||
@@ -41,7 +41,7 @@ class PreferencesStore(
|
||||
}
|
||||
.map { preferences ->
|
||||
SavedPreferences(
|
||||
hasPreviouslyLaunched = preferences[Keys.previouslyLaunched] ?: false,
|
||||
hasStartedTrivia = preferences[Keys.hasStartedTrivia] ?: false,
|
||||
duration = preferences[Keys.duration] ?: "FiveMinutes",
|
||||
topic = preferences[Keys.topic] ?: "MostlyFlags",
|
||||
soundEnabled = preferences[Keys.soundEnabled] ?: true,
|
||||
@@ -51,10 +51,10 @@ class PreferencesStore(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun hasLaunchedBefore(): Boolean = values.first().hasPreviouslyLaunched
|
||||
override suspend fun hasStartedTrivia(): Boolean = values.first().hasStartedTrivia
|
||||
|
||||
override suspend fun markLaunched() {
|
||||
dataStore.edit { it[Keys.previouslyLaunched] = true }
|
||||
override suspend fun markTriviaStarted() {
|
||||
dataStore.edit { it[Keys.hasStartedTrivia] = true }
|
||||
}
|
||||
|
||||
override suspend fun loadConfiguration(): QuizConfiguration {
|
||||
@@ -70,8 +70,8 @@ class PreferencesStore(
|
||||
}
|
||||
|
||||
suspend fun saveConfiguration(duration: String, topic: String) {
|
||||
require(QuizDuration.entries.any { it.name == duration }) { "Unknown quiz duration: $duration" }
|
||||
require(QuizTopic.entries.any { it.name == topic }) { "Unknown quiz topic: $topic" }
|
||||
require(QuizDuration.entries.any { it.name == duration }) { "Unknown trivia duration: $duration" }
|
||||
require(QuizTopic.entries.any { it.name == topic }) { "Unknown trivia topic: $topic" }
|
||||
dataStore.edit {
|
||||
it[Keys.duration] = duration
|
||||
it[Keys.topic] = topic
|
||||
@@ -114,7 +114,7 @@ class PreferencesStore(
|
||||
}
|
||||
|
||||
suspend fun resetFirstLaunch() {
|
||||
dataStore.edit { it.remove(Keys.previouslyLaunched) }
|
||||
dataStore.edit { it.remove(Keys.hasStartedTrivia) }
|
||||
}
|
||||
|
||||
suspend fun resetConfiguration() {
|
||||
@@ -135,7 +135,7 @@ class PreferencesStore(
|
||||
|
||||
suspend fun resetFirstLaunchAndConfiguration() {
|
||||
dataStore.edit {
|
||||
it.remove(Keys.previouslyLaunched)
|
||||
it.remove(Keys.hasStartedTrivia)
|
||||
it.remove(Keys.duration)
|
||||
it.remove(Keys.topic)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ class PreferencesStore(
|
||||
}
|
||||
|
||||
private object Keys {
|
||||
val previouslyLaunched = booleanPreferencesKey("previously_launched")
|
||||
val hasStartedTrivia = booleanPreferencesKey("has_started_trivia")
|
||||
val duration = stringPreferencesKey("quiz_duration")
|
||||
val topic = stringPreferencesKey("quiz_topic")
|
||||
val soundEnabled = booleanPreferencesKey("sound_enabled")
|
||||
|
||||
@@ -15,6 +15,7 @@ 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -23,14 +24,22 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -59,12 +68,6 @@ import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
import org.jetbrains.compose.resources.ResourceItem
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import berts_atlas.composeapp.generated.resources.Res
|
||||
import io.github.alexzhirkevich.compottie.Compottie
|
||||
import io.github.alexzhirkevich.compottie.LottieCompositionSpec
|
||||
import io.github.alexzhirkevich.compottie.animateLottieCompositionAsState
|
||||
import io.github.alexzhirkevich.compottie.rememberLottieComposition
|
||||
import io.github.alexzhirkevich.compottie.rememberLottiePainter
|
||||
|
||||
private val ScreenShape = RoundedCornerShape(24.dp)
|
||||
private val ControlShape = RoundedCornerShape(16.dp)
|
||||
@@ -77,12 +80,16 @@ fun AtlasScreen(
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(AtlasTheme.colors.parchment),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(AtlasTheme.colors.parchment)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
val contentModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
.fillMaxHeight()
|
||||
.widthIn(max = 720.dp)
|
||||
.padding(contentPadding)
|
||||
.let { if (scrollable) it.verticalScroll(rememberScrollState()) else it }
|
||||
Box(modifier = contentModifier, content = content)
|
||||
@@ -134,12 +141,22 @@ fun AtlasButton(
|
||||
AtlasButtonStyle.Primary, AtlasButtonStyle.Destructive -> colors.white
|
||||
AtlasButtonStyle.Secondary, AtlasButtonStyle.Quiet -> colors.plum
|
||||
}
|
||||
val border = if (style == AtlasButtonStyle.Secondary) Modifier.border(2.dp, colors.atlasBlue, ControlShape) else Modifier
|
||||
val border = when (style) {
|
||||
AtlasButtonStyle.Secondary -> Modifier.border(1.5.dp, colors.atlasBlue.copy(alpha = .75f), ControlShape)
|
||||
AtlasButtonStyle.Destructive -> Modifier.border(1.5.dp, colors.orangeAccent, ControlShape)
|
||||
else -> Modifier
|
||||
}
|
||||
val resolvedBackground = when {
|
||||
!enabled -> colors.outline.copy(alpha = .45f)
|
||||
style == AtlasButtonStyle.Destructive -> colors.paper
|
||||
else -> background
|
||||
}
|
||||
val resolvedForeground = if (style == AtlasButtonStyle.Destructive) colors.error else foreground
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = 52.dp)
|
||||
.then(border)
|
||||
.background(if (enabled) background else colors.outline, ControlShape)
|
||||
.background(resolvedBackground, ControlShape)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
this.contentDescription = contentDescription
|
||||
@@ -149,7 +166,7 @@ fun AtlasButton(
|
||||
.padding(horizontal = 22.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AtlasText(text, style = AtlasTheme.typography.label, color = foreground, textAlign = TextAlign.Center)
|
||||
AtlasText(text, style = AtlasTheme.typography.label, color = resolvedForeground, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +180,7 @@ fun AtlasCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(AtlasTheme.colors.paper, ScreenShape)
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .45f), ScreenShape)
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .25f), ScreenShape)
|
||||
.then(if (contentDescription == null) Modifier else Modifier.semantics(mergeDescendants = true) { this.contentDescription = contentDescription })
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -180,24 +197,25 @@ fun AtlasChoice(
|
||||
val colors = AtlasTheme.colors
|
||||
val background = when (choice.state) {
|
||||
AnswerState.Available -> colors.paper
|
||||
AnswerState.Selected -> colors.mustard.copy(alpha = .28f)
|
||||
AnswerState.Selected -> colors.seaGlass
|
||||
AnswerState.Correct -> colors.success.copy(alpha = .16f)
|
||||
AnswerState.Incorrect -> colors.error.copy(alpha = .14f)
|
||||
AnswerState.Incorrect -> colors.orangeAccent.copy(alpha = .18f)
|
||||
AnswerState.Eliminated -> colors.outline.copy(alpha = .12f)
|
||||
}
|
||||
val outline = when (choice.state) {
|
||||
AnswerState.Available -> colors.outline
|
||||
AnswerState.Selected -> colors.mustard
|
||||
AnswerState.Correct -> colors.success
|
||||
AnswerState.Incorrect -> colors.error
|
||||
AnswerState.Incorrect -> colors.orangeAccent
|
||||
AnswerState.Eliminated -> colors.outline.copy(alpha = .5f)
|
||||
}
|
||||
val stateText = when (choice.state) {
|
||||
AnswerState.Available -> "Available"
|
||||
AnswerState.Selected -> "Selected"
|
||||
AnswerState.Correct -> "Correct answer"
|
||||
AnswerState.Incorrect -> "Incorrect answer"
|
||||
AnswerState.Eliminated -> "Eliminated"
|
||||
val stateText = when {
|
||||
!choice.enabled && choice.state == AnswerState.Selected -> "Used"
|
||||
choice.state == AnswerState.Available -> "Available"
|
||||
choice.state == AnswerState.Selected -> "Selected"
|
||||
choice.state == AnswerState.Correct -> "Correct answer"
|
||||
choice.state == AnswerState.Incorrect -> "Incorrect answer"
|
||||
else -> "Eliminated"
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -258,12 +276,13 @@ fun FlagImage(
|
||||
),
|
||||
)
|
||||
}
|
||||
val accessibleLabel = if (flag.countryName.startsWith("Flag ", ignoreCase = true)) flag.countryName else "Flag of ${flag.countryName}"
|
||||
val imageModifier = modifier
|
||||
.aspectRatio(3f / 2f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(AtlasTheme.colors.paper, RoundedCornerShape(8.dp))
|
||||
.border(1.dp, AtlasTheme.colors.outline.copy(alpha = .55f), RoundedCornerShape(8.dp))
|
||||
.then(if (accessible) Modifier.semantics { contentDescription = "Flag of ${flag.countryName}" } else Modifier)
|
||||
.then(if (accessible) Modifier.semantics { contentDescription = accessibleLabel } else Modifier)
|
||||
if (validCode) {
|
||||
Image(
|
||||
painter = painterResource(resource),
|
||||
@@ -314,7 +333,7 @@ fun AtlasToggle(setting: SettingToggleUi, onToggle: (String, Boolean) -> Unit) {
|
||||
}
|
||||
Box(
|
||||
Modifier.size(width = 54.dp, height = 32.dp)
|
||||
.background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.outline, CircleShape)
|
||||
.background(if (setting.checked) AtlasTheme.colors.success else AtlasTheme.colors.seaGlass, CircleShape)
|
||||
.padding(4.dp),
|
||||
contentAlignment = if (setting.checked) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
) {
|
||||
@@ -351,50 +370,22 @@ fun BertIllustration(
|
||||
StaticBertIllustration(pose, modifier, contentDescription)
|
||||
return
|
||||
}
|
||||
val compositionResult = rememberLottieComposition(pose) {
|
||||
LottieCompositionSpec.JsonString(
|
||||
Res.readBytes("files/${pose.animationFileName}").decodeToString(),
|
||||
)
|
||||
}
|
||||
val composition = compositionResult.value
|
||||
if (composition == null || compositionResult.isFailure) {
|
||||
StaticBertIllustration(pose, modifier, contentDescription)
|
||||
return
|
||||
}
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = Compottie.IterateForever,
|
||||
val transition = rememberInfiniteTransition(label = "Bert bulb")
|
||||
val rayLength by transition.animateFloat(
|
||||
initialValue = .82f,
|
||||
targetValue = 1.08f,
|
||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||
label = "Bulb ray length",
|
||||
)
|
||||
Box(
|
||||
modifier = modifier.size(180.dp).semantics { this.contentDescription = contentDescription },
|
||||
) {
|
||||
StaticBertIllustration(pose, Modifier.matchParentSize(), contentDescription = null)
|
||||
Image(
|
||||
painter = rememberLottiePainter(composition = composition, progress = { progress }),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
StaticBertIllustration(pose, modifier, contentDescription, rayLength)
|
||||
}
|
||||
|
||||
private val BertPose.animationFileName: String
|
||||
get() = when (this) {
|
||||
BertPose.Idle -> "bert_idle.json"
|
||||
BertPose.Correct -> "bert_correct.json"
|
||||
BertPose.Incorrect -> "bert_incorrect.json"
|
||||
BertPose.Overtime -> "bert_overtime.json"
|
||||
BertPose.Hint -> "bert_hint.json"
|
||||
BertPose.Fact -> "bert_fact.json"
|
||||
BertPose.Results -> "bert_results.json"
|
||||
BertPose.Streak -> "bert_streak.json"
|
||||
BertPose.Stamp -> "bert_stamp.json"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StaticBertIllustration(
|
||||
pose: BertPose,
|
||||
modifier: Modifier,
|
||||
contentDescription: String?,
|
||||
rayLength: Float = 1f,
|
||||
) {
|
||||
val colors = AtlasTheme.colors
|
||||
val accessibleModifier = if (contentDescription == null) Modifier else Modifier.semantics { this.contentDescription = contentDescription }
|
||||
@@ -437,24 +428,75 @@ private fun StaticBertIllustration(
|
||||
quadraticTo(90f * scale, (if (pose == BertPose.Incorrect) 106f else 130f) * scale, 109f * scale, mouthY * scale)
|
||||
}
|
||||
drawPath(mouth, colors.plum, style = Stroke(4f * scale, cap = StrokeCap.Round))
|
||||
val accent = when (pose) {
|
||||
BertPose.Correct, BertPose.Streak, BertPose.Stamp -> colors.success
|
||||
BertPose.Incorrect -> colors.error
|
||||
BertPose.Overtime, BertPose.Hint -> colors.mustard
|
||||
BertPose.Fact, BertPose.Results, BertPose.Idle -> colors.atlasBlue
|
||||
}
|
||||
val accent = colors.orangeAccent
|
||||
drawCircle(accent, 10f * scale, p(145f, 39f))
|
||||
drawLine(accent, p(145f, 17f), p(145f, 7f), 4f * scale, StrokeCap.Round)
|
||||
drawLine(accent, p(161f, 25f), p(169f, 18f), 4f * scale, StrokeCap.Round)
|
||||
drawLine(accent, p(145f, 17f), p(145f, 17f - 10f * rayLength), 4f * scale, StrokeCap.Round)
|
||||
drawLine(accent, p(161f, 25f), p(161f + 8f * rayLength, 25f - 7f * rayLength), 4f * scale, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenTopBar(title: String, onBack: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (onBack != null) AtlasButton("Back", onBack, style = AtlasButtonStyle.Quiet)
|
||||
AtlasText(title, Modifier.weight(1f).semantics { heading() }, style = AtlasTheme.typography.heading)
|
||||
trailing?.invoke()
|
||||
Row(Modifier.fillMaxWidth().heightIn(min = 52.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (onBack != null) AtlasBackButton(onBack) else Spacer(Modifier.size(48.dp))
|
||||
AtlasText(
|
||||
title,
|
||||
Modifier.weight(1f).semantics { heading() },
|
||||
style = AtlasTheme.typography.heading,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (trailing == null) Spacer(Modifier.size(48.dp)) else Box(Modifier.widthIn(min = 48.dp), contentAlignment = Alignment.CenterEnd) { trailing() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasBackButton(onClick: () -> Unit, contentDescription: String = "Back") {
|
||||
val color = AtlasTheme.colors.plum
|
||||
Box(
|
||||
Modifier.size(48.dp)
|
||||
.semantics { role = Role.Button; this.contentDescription = contentDescription }
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(24.dp)) {
|
||||
drawLine(color, Offset(size.width * .7f, size.height * .16f), Offset(size.width * .3f, size.height * .5f), 3.2.dp.toPx(), StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .3f, size.height * .5f), Offset(size.width * .7f, size.height * .84f), 3.2.dp.toPx(), StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasExitButton(onClick: () -> Unit, contentDescription: String = "Leave trivia") {
|
||||
val color = AtlasTheme.colors.plum
|
||||
Box(
|
||||
Modifier.size(48.dp)
|
||||
.semantics { role = Role.Button; this.contentDescription = contentDescription }
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(26.dp)) {
|
||||
val stroke = 2.6.dp.toPx()
|
||||
drawLine(color, Offset(size.width * .18f, size.height * .12f), Offset(size.width * .18f, size.height * .88f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .18f, size.height * .12f), Offset(size.width * .56f, size.height * .12f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .18f, size.height * .88f), Offset(size.width * .56f, size.height * .88f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .48f, size.height * .5f), Offset(size.width * .9f, size.height * .5f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .73f, size.height * .32f), Offset(size.width * .9f, size.height * .5f), stroke, StrokeCap.Round)
|
||||
drawLine(color, Offset(size.width * .9f, size.height * .5f), Offset(size.width * .73f, size.height * .68f), stroke, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AtlasLinkRow(text: String, onClick: () -> Unit, destructive: Boolean = false) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().heightIn(min = 56.dp)
|
||||
.semantics { role = Role.Button; contentDescription = text }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AtlasText(text, Modifier.weight(1f), style = AtlasTheme.typography.label, color = if (destructive) AtlasTheme.colors.error else AtlasTheme.colors.plum)
|
||||
AtlasText("›", style = AtlasTheme.typography.heading, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ data class AtlasColors(
|
||||
val mutedPlum: Color,
|
||||
val atlasBlue: Color,
|
||||
val atlasBluePressed: Color,
|
||||
val seaGlass: Color,
|
||||
val orangeAccent: Color,
|
||||
val coral: Color,
|
||||
val mustard: Color,
|
||||
val success: Color,
|
||||
@@ -42,19 +44,30 @@ data class AtlasTypography(
|
||||
val timer: TextStyle,
|
||||
)
|
||||
|
||||
private val AtlasDeepGreen = Color(0xFF27635B)
|
||||
private val AtlasBlueGray = Color(0xFF778BA5)
|
||||
private val AtlasSeaGlass = Color(0xFFB7CFC8)
|
||||
private val AtlasShell = Color(0xFFF6E9E1)
|
||||
private val AtlasSand = Color(0xFFF1DCC7)
|
||||
private val AtlasPink = Color(0xFFF7BFBE)
|
||||
private val AtlasPeach = Color(0xFFE9AF8B)
|
||||
private val AtlasOrange = Color(0xFFE1814E)
|
||||
|
||||
private val LightAtlasColors = AtlasColors(
|
||||
parchment = Color(0xFFF7F0DF),
|
||||
paper = Color(0xFFFFFBF2),
|
||||
plum = Color(0xFF3E2447),
|
||||
mutedPlum = Color(0xFF69536F),
|
||||
atlasBlue = Color(0xFF17647E),
|
||||
atlasBluePressed = Color(0xFF0E4B60),
|
||||
coral = Color(0xFFE96E5B),
|
||||
mustard = Color(0xFFE3AD35),
|
||||
success = Color(0xFF147568),
|
||||
error = Color(0xFF9C3E32),
|
||||
outline = Color(0xFF8A748E),
|
||||
white = Color(0xFFFFFFFF),
|
||||
parchment = AtlasShell,
|
||||
paper = AtlasSand,
|
||||
plum = AtlasDeepGreen,
|
||||
mutedPlum = AtlasDeepGreen,
|
||||
atlasBlue = AtlasDeepGreen,
|
||||
atlasBluePressed = AtlasDeepGreen,
|
||||
seaGlass = AtlasSeaGlass,
|
||||
orangeAccent = AtlasOrange,
|
||||
coral = AtlasPink,
|
||||
mustard = AtlasPeach,
|
||||
success = AtlasDeepGreen,
|
||||
error = AtlasDeepGreen,
|
||||
outline = AtlasBlueGray,
|
||||
white = AtlasShell,
|
||||
)
|
||||
|
||||
private fun atlasTypography(fontFamily: FontFamily) = AtlasTypography(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.bertsatlas.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -8,10 +10,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -21,6 +31,8 @@ fun HomeScreen(
|
||||
isFirstLaunch: Boolean,
|
||||
streakDays: Int = 0,
|
||||
progressSummary: String = "",
|
||||
totalPoints: Int = 0,
|
||||
hasStartedTrivia: Boolean = !isFirstLaunch,
|
||||
onStart: () -> Unit,
|
||||
onOptions: () -> Unit = {},
|
||||
onProgress: () -> Unit = {},
|
||||
@@ -31,9 +43,16 @@ fun HomeScreen(
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
BertIllustration(BertPose.Idle, contentDescription = "Bert smiles behind oversized glasses")
|
||||
if (hasStartedTrivia) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
PointsBadge(totalPoints, Modifier.weight(1f))
|
||||
SettingsIconButton(onSettings)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(if (hasStartedTrivia) 8.dp else 24.dp))
|
||||
HomeBertIllustration(large = !hasStartedTrivia)
|
||||
AtlasText(
|
||||
"Bert's Atlas",
|
||||
Modifier.semantics { heading() },
|
||||
@@ -41,21 +60,85 @@ fun HomeScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AtlasText("Getting nerdier every day.", color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
AtlasButton("Start", onStart, Modifier.fillMaxWidth().widthIn(max = 420.dp))
|
||||
if (!isFirstLaunch) {
|
||||
if (hasStartedTrivia) {
|
||||
AtlasButton("Options", onOptions, Modifier.fillMaxWidth().widthIn(max = 420.dp), AtlasButtonStyle.Secondary)
|
||||
SectionGap()
|
||||
AtlasCard(Modifier.widthIn(max = 420.dp), "Current streak: $streakDays days. $progressSummary") {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
AtlasText("$streakDays", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral)
|
||||
AtlasText("✦", style = AtlasTheme.typography.title, color = AtlasTheme.colors.coral)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
AtlasText(if (streakDays == 1) "day curious" else "days curious", style = AtlasTheme.typography.label)
|
||||
AtlasText(if (streakDays == 1) "1 curious day" else "$streakDays curious days", style = AtlasTheme.typography.label)
|
||||
AtlasText(progressSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
AtlasButton("Progress", onProgress, style = AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
AtlasButton("Settings", onSettings, style = AtlasButtonStyle.Quiet)
|
||||
AtlasButton("See your progress", onProgress, style = AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeBertIllustration(large: Boolean) {
|
||||
val size = if (large) 270.dp else 200.dp
|
||||
val colors = AtlasTheme.colors
|
||||
androidx.compose.foundation.layout.Box(Modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
BertIllustration(BertPose.Idle, Modifier.fillMaxWidth(), contentDescription = "Bert smiles with a bright idea and a folded map")
|
||||
Canvas(Modifier.fillMaxWidth().size(size)) {
|
||||
val w = this.size.width
|
||||
val h = this.size.height
|
||||
val outline = 4.dp.toPx()
|
||||
val map = Path().apply {
|
||||
moveTo(w * .04f, h * .68f)
|
||||
lineTo(w * .14f, h * .63f)
|
||||
lineTo(w * .25f, h * .68f)
|
||||
lineTo(w * .36f, h * .63f)
|
||||
lineTo(w * .36f, h * .88f)
|
||||
lineTo(w * .25f, h * .93f)
|
||||
lineTo(w * .14f, h * .88f)
|
||||
lineTo(w * .04f, h * .93f)
|
||||
close()
|
||||
}
|
||||
drawPath(map, colors.paper)
|
||||
drawPath(map, colors.plum, style = Stroke(outline, join = androidx.compose.ui.graphics.StrokeJoin.Round))
|
||||
drawLine(colors.plum, Offset(w * .14f, h * .63f), Offset(w * .14f, h * .88f), outline)
|
||||
drawLine(colors.plum, Offset(w * .25f, h * .68f), Offset(w * .25f, h * .93f), outline)
|
||||
drawLine(colors.orangeAccent, Offset(w * .06f, h * .83f), Offset(w * .34f, h * .76f), outline * .7f, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PointsBadge(points: Int, modifier: Modifier = Modifier) {
|
||||
val colors = AtlasTheme.colors
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Canvas(Modifier.size(24.dp)) {
|
||||
drawCircle(colors.mustard, size.minDimension * .40f)
|
||||
drawCircle(colors.plum, size.minDimension * .40f, style = Stroke(2.4.dp.toPx()))
|
||||
drawCircle(colors.plum, size.minDimension * .10f)
|
||||
}
|
||||
AtlasText(points.toString(), style = AtlasTheme.typography.heading, color = colors.plum)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsIconButton(onClick: () -> Unit) {
|
||||
val color = AtlasTheme.colors.plum
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.size(48.dp).semantics { role = Role.Button; contentDescription = "Open settings" }.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.size(26.dp)) {
|
||||
val center = Offset(size.width / 2, size.height / 2)
|
||||
drawCircle(color, size.minDimension * .27f, center, style = Stroke(3.dp.toPx()))
|
||||
drawCircle(color, size.minDimension * .07f, center)
|
||||
repeat(8) { index ->
|
||||
val angle = index * kotlin.math.PI.toFloat() / 4f
|
||||
val start = Offset(center.x + kotlin.math.cos(angle) * size.width * .31f, center.y + kotlin.math.sin(angle) * size.height * .31f)
|
||||
val end = Offset(center.x + kotlin.math.cos(angle) * size.width * .43f, center.y + kotlin.math.sin(angle) * size.height * .43f)
|
||||
drawLine(color, start, end, 3.dp.toPx(), StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +203,7 @@ fun RecoveryScreen(
|
||||
AtlasText(questionSummary, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
AtlasButton("Resume run", onResume, Modifier.fillMaxWidth())
|
||||
AtlasButton("Leave this run", onAbandon, Modifier.fillMaxWidth(), AtlasButtonStyle.Quiet)
|
||||
AtlasButton("Leave this trivia run", onAbandon, Modifier.fillMaxWidth(), AtlasButtonStyle.Quiet)
|
||||
AtlasText("Answered questions still count toward what Bert revisits next.", style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -31,12 +33,12 @@ fun QuizScreen(
|
||||
prompt: String,
|
||||
imageDescription: String?,
|
||||
score: Int,
|
||||
sessionTime: String,
|
||||
questionTime: String,
|
||||
timerExpired: Boolean,
|
||||
choices: List<ChoiceUi> = emptyList(),
|
||||
letterSlots: List<LetterSlotUi> = emptyList(),
|
||||
letterTiles: List<LetterTileUi> = emptyList(),
|
||||
appliedHintMessages: List<String> = emptyList(),
|
||||
hintAvailable: Boolean,
|
||||
unlimited: Boolean,
|
||||
canSubmit: Boolean = false,
|
||||
@@ -50,46 +52,46 @@ fun QuizScreen(
|
||||
onLeave: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
promptFlag: FlagUi? = null,
|
||||
runProgress: Float? = null,
|
||||
runProgressDescription: String = "Trivia progress",
|
||||
overtimeMessage: String = "Time's up. Keep going for half points",
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
if (maxWidth < 480.dp) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Leave", onLeave, style = AtlasButtonStyle.Quiet)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
QuizMetric("Score", score.toString(), Modifier.weight(1f))
|
||||
QuizMetric(if (unlimited) "Elapsed" else "Run", sessionTime, Modifier.weight(1f))
|
||||
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Leave", onLeave, style = AtlasButtonStyle.Quiet)
|
||||
QuizMetric("Score", score.toString(), Modifier.weight(1f))
|
||||
QuizMetric(if (unlimited) "Elapsed" else "Run", sessionTime, Modifier.weight(1f))
|
||||
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
|
||||
}
|
||||
}
|
||||
AtlasScreen(modifier, contentPadding = PaddingValues(0.dp)) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
runProgress?.let { SubtleRunProgress(it, runProgressDescription) }
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
QuizMetric("Score", score.toString(), Modifier.weight(1f))
|
||||
QuizMetric("Question", questionTime, Modifier.weight(1f), warning = timerExpired)
|
||||
AtlasExitButton(onLeave)
|
||||
}
|
||||
if (timerExpired) {
|
||||
AtlasText("Overtime · you can still answer for half points", color = AtlasTheme.colors.error, style = AtlasTheme.typography.label)
|
||||
AtlasText(overtimeMessage, Modifier.fillMaxWidth(), color = AtlasTheme.colors.error, style = AtlasTheme.typography.label, textAlign = TextAlign.Center)
|
||||
}
|
||||
if (promptFlag != null || imageDescription != null) {
|
||||
AtlasCard(contentDescription = imageDescription ?: "Flag of ${promptFlag?.countryName}") {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 116.dp), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().heightIn(min = 148.dp)
|
||||
.semantics { contentDescription = imageDescription ?: "Flag to identify" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.width(230.dp).padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
if (promptFlag != null) {
|
||||
FlagImage(promptFlag, Modifier.fillMaxWidth().heightIn(min = 116.dp))
|
||||
FlagImage(promptFlag, Modifier.fillMaxWidth(), accessible = false, showFallbackLabel = false)
|
||||
} else {
|
||||
AtlasText("⚑", style = AtlasTheme.typography.display, color = AtlasTheme.colors.atlasBlue)
|
||||
}
|
||||
}
|
||||
imageDescription?.let {
|
||||
AtlasText(it, Modifier.fillMaxWidth(), style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
AtlasHeading(prompt)
|
||||
AtlasHeading(prompt, Modifier.fillMaxWidth(), centered = true)
|
||||
appliedHintMessages.forEach { message ->
|
||||
AtlasCard(contentDescription = "Applied hint: $message") {
|
||||
AtlasText("Hint: $message", color = AtlasTheme.colors.atlasBlue)
|
||||
}
|
||||
}
|
||||
if (choices.isNotEmpty()) ResponsiveChoices(choices, onChoice)
|
||||
if (letterSlots.isNotEmpty() || letterTiles.isNotEmpty()) {
|
||||
LetterBank(letterSlots, letterTiles, onLetter, onRemoveLetter)
|
||||
@@ -99,21 +101,42 @@ fun QuizScreen(
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AtlasButton("Hint", onHint, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = hintAvailable)
|
||||
AtlasButton("Get a hint", onHint, Modifier.weight(1f), AtlasButtonStyle.Secondary, enabled = hintAvailable)
|
||||
if (unlimited) AtlasButton("Finish", onFinish, Modifier.weight(1f), AtlasButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuizMetric(label: String, value: String, modifier: Modifier, warning: Boolean = false) {
|
||||
Column(modifier.semantics(mergeDescendants = true) { contentDescription = "$label: $value" }, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
AtlasText(value, style = AtlasTheme.typography.timer, color = if (warning) AtlasTheme.colors.error else AtlasTheme.colors.plum, textAlign = TextAlign.Center)
|
||||
Column(
|
||||
modifier.semantics(mergeDescendants = true) { contentDescription = "$label: $value" },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AtlasText(
|
||||
value,
|
||||
style = AtlasTheme.typography.timer,
|
||||
color = if (warning) AtlasTheme.colors.error else AtlasTheme.colors.plum,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AtlasText(label, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubtleRunProgress(progress: Float, description: String) {
|
||||
val value = progress.coerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier.fillMaxWidth().heightIn(min = 4.dp)
|
||||
.background(AtlasTheme.colors.outline.copy(alpha = .14f), RoundedCornerShape(4.dp))
|
||||
.semantics { contentDescription = description; stateDescription = "${(value * 100).toInt()} percent" },
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(value).heightIn(min = 4.dp).background(AtlasTheme.colors.coral, RoundedCornerShape(4.dp)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResponsiveChoices(choices: List<ChoiceUi>, onChoice: (String) -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
@@ -144,27 +167,51 @@ private fun LetterBank(
|
||||
val slot = slots[index]
|
||||
val label = slot.character ?: "blank"
|
||||
Box(
|
||||
Modifier.size(52.dp).background(AtlasTheme.colors.paper, RoundedCornerShape(12.dp))
|
||||
.border(2.dp, AtlasTheme.colors.atlasBlue, RoundedCornerShape(12.dp))
|
||||
Modifier.size(48.dp)
|
||||
.semantics { contentDescription = "Answer position ${slot.position + 1}: $label" }
|
||||
.clickable(enabled = slot.tileId != null && !slot.fixed) { slot.tileId?.let(onRemoveLetter) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AtlasText(slot.character ?: "_", style = AtlasTheme.typography.heading) }
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(38.dp)
|
||||
.background(AtlasTheme.colors.paper, RoundedCornerShape(10.dp))
|
||||
.border(2.dp, AtlasTheme.colors.atlasBlue, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AtlasText(slot.character ?: "_", style = AtlasTheme.typography.heading)
|
||||
}
|
||||
}
|
||||
}
|
||||
WrappingCells(tiles.size) { index ->
|
||||
val tile = tiles[index]
|
||||
val available = tile.enabled && tile.placedSlot == null
|
||||
Box(
|
||||
Modifier.size(52.dp).background(if (available) AtlasTheme.colors.mustard.copy(alpha = .3f) else AtlasTheme.colors.outline.copy(alpha = .12f), RoundedCornerShape(12.dp))
|
||||
.border(1.dp, AtlasTheme.colors.outline, RoundedCornerShape(12.dp))
|
||||
Modifier.size(48.dp)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
contentDescription = "Letter ${tile.letter}"
|
||||
stateDescription = if (available) "Available" else "Placed"
|
||||
stateDescription = when {
|
||||
available -> "Available"
|
||||
!tile.enabled -> "Removed"
|
||||
else -> "Placed"
|
||||
}
|
||||
}
|
||||
.clickable(enabled = available) { onLetter(tile.id) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AtlasText(tile.letter, style = AtlasTheme.typography.label) }
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(44.dp)
|
||||
.background(
|
||||
if (available) AtlasTheme.colors.mustard.copy(alpha = .3f)
|
||||
else AtlasTheme.colors.outline.copy(alpha = .12f),
|
||||
RoundedCornerShape(11.dp),
|
||||
)
|
||||
.border(1.dp, AtlasTheme.colors.outline, RoundedCornerShape(11.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AtlasText(tile.letter, style = AtlasTheme.typography.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +219,7 @@ private fun LetterBank(
|
||||
@Composable
|
||||
private fun WrappingCells(count: Int, content: @Composable (Int) -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val columns = (maxWidth / 60.dp).toInt().coerceAtLeast(1)
|
||||
val columns = (maxWidth / 56.dp).toInt().coerceAtLeast(1)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
(0 until count).chunked(columns).forEach { indices ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -190,15 +237,17 @@ fun HintChooserScreen(
|
||||
onChooseHint: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
introText: String = "Choose the clue that sounds most useful.",
|
||||
chargedHintLabel: String = "−25 points",
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ScreenTopBar("Choose a hint", onCancel)
|
||||
BertIllustration(BertPose.Hint)
|
||||
BertIllustration(BertPose.Hint, Modifier.size(150.dp))
|
||||
AtlasHeading("A little nudge?", Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText("Every hint costs up to 25 points. Your score can never drop below zero.", textAlign = TextAlign.Center)
|
||||
AtlasCard(contentDescription = "Current score $score. Score after hint ${maxOf(0, score - 25)}") {
|
||||
StatRow(listOf(StatUi("Now", score.toString()), StatUi("After hint", maxOf(0, score - 25).toString())))
|
||||
AtlasText(introText, textAlign = TextAlign.Center, color = AtlasTheme.colors.mutedPlum)
|
||||
if (score > 0) {
|
||||
AtlasText(chargedHintLabel, style = AtlasTheme.typography.label, color = AtlasTheme.colors.outline, textAlign = TextAlign.Center)
|
||||
}
|
||||
hints.forEach { AtlasChoice(it, onChooseHint) }
|
||||
}
|
||||
@@ -217,12 +266,14 @@ fun FeedbackScreen(
|
||||
autoAdvanceMessage: String?,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
continueLabel: String = "Continue",
|
||||
) {
|
||||
val pose = when { overtime && correct -> BertPose.Overtime; correct -> BertPose.Correct; else -> BertPose.Incorrect }
|
||||
val status = when { overtime && correct -> "Overtime correct"; correct -> "Correct"; else -> "Incorrect" }
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BertIllustration(pose, contentDescription = "Bert reacts: $status")
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Spacer(Modifier.heightIn(min = 8.dp))
|
||||
BertIllustration(pose, Modifier.size(190.dp), contentDescription = "Bert reacts: $status")
|
||||
AtlasHeading(heading, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText(
|
||||
if (correct) "+$pointsAwarded points" else "No points this time",
|
||||
@@ -235,7 +286,7 @@ fun FeedbackScreen(
|
||||
explanation?.let { AtlasText(it) }
|
||||
}
|
||||
if (overtime && correct) AtlasText("The clock gave up. You didn't. Half points.", textAlign = TextAlign.Center)
|
||||
AtlasButton("Continue", onContinue, Modifier.fillMaxWidth())
|
||||
AtlasButton(continueLabel, onContinue, Modifier.fillMaxWidth())
|
||||
autoAdvanceMessage?.let { AtlasText(it, style = AtlasTheme.typography.detail, color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center) }
|
||||
}
|
||||
}
|
||||
@@ -249,14 +300,39 @@ fun FunFactScreen(
|
||||
artDescription: String? = null,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
kicker: String = "Fun fact about",
|
||||
continueLabel: String = "Continue",
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BertIllustration(BertPose.Fact, contentDescription = "Bert has found an oddly specific fact")
|
||||
AtlasText("FIELD NOTE · $relatedTo", style = AtlasTheme.typography.label, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasText("${kicker} $relatedTo", style = AtlasTheme.typography.label, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasHeading(heading, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasCard(contentDescription = artDescription) { AtlasText(fact, style = AtlasTheme.typography.heading, textAlign = TextAlign.Center) }
|
||||
AtlasButton("Back to the atlas", onContinue, Modifier.fillMaxWidth())
|
||||
AtlasButton(continueLabel, onContinue, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveQuizConfirmationScreen(
|
||||
onCancel: () -> Unit,
|
||||
onLeave: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Leave this trivia run?",
|
||||
body: String = "Your answered questions will stay in your practice progress. Ending now won't count toward your personal best or daily streak.",
|
||||
) {
|
||||
AtlasScreen(modifier, scrollable = false) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
BertIllustration(BertPose.Hint, Modifier.size(160.dp), contentDescription = "Bert keeps your practice progress safe")
|
||||
AtlasHeading(title, Modifier.fillMaxWidth(), centered = true)
|
||||
AtlasText(body, Modifier.fillMaxWidth(), color = AtlasTheme.colors.mutedPlum, textAlign = TextAlign.Center)
|
||||
AtlasButton("Keep playing", onCancel, Modifier.fillMaxWidth())
|
||||
AtlasButton("Leave trivia", onLeave, Modifier.fillMaxWidth(), AtlasButtonStyle.Destructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,28 @@ fun ResultsScreen(
|
||||
onPlayAgain: () -> Unit,
|
||||
onHome: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
abandoned: Boolean = false,
|
||||
) {
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BertIllustration(BertPose.Results, contentDescription = "Bert celebrates a completed atlas run")
|
||||
AtlasHeading(if (newPersonalBest) "New personal best" else "Run complete", Modifier.fillMaxWidth(), centered = true)
|
||||
BertIllustration(if (abandoned) BertPose.Hint else BertPose.Results, contentDescription = if (abandoned) "Bert keeps your practice progress" else "Bert celebrates completed trivia")
|
||||
AtlasHeading(
|
||||
when {
|
||||
abandoned -> "Trivia ended"
|
||||
newPersonalBest -> "New personal best"
|
||||
else -> "Trivia complete"
|
||||
},
|
||||
Modifier.fillMaxWidth(),
|
||||
centered = true,
|
||||
)
|
||||
if (abandoned) {
|
||||
AtlasText(
|
||||
"Your answers still shaped what you'll practise next. This trivia run didn't count toward your personal best or daily streak.",
|
||||
Modifier.fillMaxWidth(),
|
||||
color = AtlasTheme.colors.mutedPlum,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
AtlasText(score.toString(), style = AtlasTheme.typography.display, color = AtlasTheme.colors.atlasBlue)
|
||||
AtlasText("points", color = AtlasTheme.colors.mutedPlum)
|
||||
StatRow(
|
||||
@@ -46,7 +63,7 @@ fun ResultsScreen(
|
||||
),
|
||||
)
|
||||
AtlasCard {
|
||||
AtlasText("Run notes", style = AtlasTheme.typography.heading)
|
||||
AtlasText("Highlights", style = AtlasTheme.typography.heading)
|
||||
AtlasText("Average response · $averageResponseTime")
|
||||
AtlasText("New or improved knowledge · $masteryImproved")
|
||||
}
|
||||
@@ -56,7 +73,7 @@ fun ResultsScreen(
|
||||
AtlasText(revisitCountries.joinToString(" · "), color = AtlasTheme.colors.mutedPlum)
|
||||
}
|
||||
}
|
||||
AtlasButton("Play again", onPlayAgain, Modifier.fillMaxWidth())
|
||||
AtlasButton(if (abandoned) "Start new trivia" else "Play again", onPlayAgain, Modifier.fillMaxWidth())
|
||||
AtlasButton("Home", onHome, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,15 @@ fun SettingsScreen(
|
||||
AtlasScreen(modifier) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ScreenTopBar("Settings", onBack)
|
||||
SectionGap()
|
||||
AtlasCard {
|
||||
settings.forEach { AtlasToggle(it, onToggle) }
|
||||
}
|
||||
AtlasButton("Privacy", onPrivacy, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
AtlasButton("Credits and licenses", onCredits, Modifier.fillMaxWidth(), AtlasButtonStyle.Secondary)
|
||||
AtlasButton("Reset local data", onReset, Modifier.fillMaxWidth(), AtlasButtonStyle.Destructive)
|
||||
AtlasCard {
|
||||
AtlasLinkRow("Privacy", onPrivacy)
|
||||
AtlasLinkRow("Credits and licenses", onCredits)
|
||||
AtlasLinkRow("Reset local data", onReset, destructive = true)
|
||||
}
|
||||
AtlasText(
|
||||
"Bert's Atlas $version",
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -84,7 +87,7 @@ fun PrivacyScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
title = "Privacy",
|
||||
paragraphs = listOf(
|
||||
"Bert's Atlas works entirely offline. It does not ask for an account or personal information.",
|
||||
"Your quiz history, progress, streaks, stamps, and preferences stay on this device. The app has no advertising, analytics, remote configuration, or product telemetry.",
|
||||
"Your trivia history, progress, streaks, stamps, and preferences stay on this device. The app has no advertising, analytics, remote configuration, or product telemetry.",
|
||||
"Clearing app data or deleting the app may permanently remove your progress. Operating-system and store diagnostics are controlled by your platform settings.",
|
||||
),
|
||||
onBack = onBack,
|
||||
|
||||