copilot added.
parent
7b48b9d836
commit
b776b68121
@ -0,0 +1,52 @@
|
||||
<svg width="74" height="90" viewBox="0 0 74 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_22474_3185" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="74" height="90">
|
||||
<path d="M73.9931 0H0V89.2806H73.9931V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_22474_3185)">
|
||||
<path d="M55.0668 41.8958L54.1466 40.9929C56.0094 39.0946 59.664 35.3384 60.1634 34.7202L61.1637 35.534C60.5034 36.3478 55.288 41.6655 55.0668 41.8892" fill="#307CAB"/>
|
||||
<path d="M39.0394 79.2844L38.1612 78.3402C40.109 76.5245 43.9337 72.9466 44.4611 72.3564L45.4218 73.2148C44.7236 73.9964 39.2713 79.069 39.0394 79.2844Z" fill="#307CAB"/>
|
||||
<path d="M60.6124 54.8803C59.797 54.22 54.4594 49.0286 54.2333 48.8074L55.1321 47.8838C57.0386 49.7392 60.8072 53.3806 61.4229 53.8783L60.6124 54.8803Z" fill="#307CAB"/>
|
||||
<path d="M46.2755 86.1937C45.4163 85.5937 39.7305 80.7844 39.4887 80.5813L40.3215 79.5967C42.3526 81.3151 46.3655 84.6841 47.0142 85.1381L46.2755 86.1937Z" fill="#307CAB"/>
|
||||
<path d="M31.5521 53.7389C30.7194 53.1018 25.2481 48.0498 25.0154 47.8352L25.8894 46.8877C27.8438 48.6927 31.7065 52.2343 32.3354 52.7147L31.5521 53.7389Z" fill="#307CAB"/>
|
||||
<path d="M20.6899 20.1584C19.8571 19.5212 14.3859 14.4692 14.1531 14.2546L15.028 13.3071C16.9816 15.1122 20.8442 18.6538 21.4732 19.1341L20.6899 20.1584Z" fill="#307CAB"/>
|
||||
<path d="M59.1061 29.7785C58.2255 29.2082 52.3746 24.6019 52.1253 24.4063L52.9234 23.3936C55.0132 25.0393 59.1424 28.2631 59.8068 28.6956L59.1061 29.7785Z" fill="#307CAB"/>
|
||||
<path d="M9.04498 52.881C8.2122 52.2439 2.74095 47.1919 2.50821 46.9773L3.38143 46.0298C5.33503 47.8348 9.19767 51.3764 9.82659 51.8568L9.04498 52.881Z" fill="#307CAB"/>
|
||||
<path d="M6.58773 64.3483L5.65014 63.4643C7.47581 61.5289 11.0562 57.7042 11.544 57.0811L12.56 57.8742C11.9145 58.6996 6.80563 64.1188 6.58856 64.3499" fill="#307CAB"/>
|
||||
<path d="M70.1408 32.6937C70.1408 34.0185 69.7479 35.3136 69.0119 36.4152C68.2759 37.5167 67.2297 38.3753 66.0057 38.8823C64.7817 39.3893 63.4348 39.522 62.1354 39.2635C60.8361 39.005 59.6425 38.3671 58.7057 37.4303C57.7689 36.4934 57.1309 35.2999 56.8724 34.0005C56.614 32.7011 56.7466 31.3542 57.2536 30.1302C57.7606 28.9062 58.6192 27.8601 59.7207 27.124C60.8223 26.388 62.1174 25.9951 63.4423 25.9951C64.3219 25.9951 65.193 26.1684 66.0057 26.505C66.8184 26.8416 67.5568 27.3351 68.1788 27.9571C68.8009 28.5791 69.2943 29.3175 69.6309 30.1302C69.9675 30.9429 70.1408 31.814 70.1408 32.6937Z" fill="#92D9F8"/>
|
||||
<path d="M70.1408 58.2025C70.1408 59.5273 69.7479 60.8224 69.0119 61.924C68.2759 63.0255 67.2297 63.8841 66.0057 64.3911C64.7817 64.8981 63.4348 65.0308 62.1354 64.7723C60.8361 64.5138 59.6425 63.8759 58.7057 62.939C57.7689 62.0022 57.1309 60.8087 56.8724 59.5093C56.614 58.2099 56.7466 56.863 57.2536 55.639C57.7606 54.415 58.6192 53.3689 59.7207 52.6328C60.8223 51.8968 62.1174 51.5039 63.4423 51.5039C65.2188 51.5039 66.9226 52.2096 68.1788 53.4659C69.4351 54.7221 70.1408 56.4259 70.1408 58.2025Z" fill="#92D9F8"/>
|
||||
<path d="M17.5473 33.888C17.5473 36.8593 15.441 39.2726 12.8428 39.2726C10.2446 39.2726 8.13833 36.8617 8.13833 33.888C8.13833 30.9143 10.2446 28.5034 12.8428 28.5034C15.441 28.5034 17.5473 30.9143 17.5473 33.888Z" fill="#5CB1DB"/>
|
||||
<path d="M6.24956 45.1713C6.24956 48.1426 4.85059 50.5559 3.12478 50.5559C1.39897 50.5559 0 48.1451 0 45.1713C0 42.1976 1.39897 39.79 3.12478 39.79C4.85059 39.79 6.24956 42.2009 6.24956 45.1746" fill="#226187"/>
|
||||
<path d="M7.44958 27.8169C5.79393 30.2797 3.64719 31.7356 2.65595 31.0688C1.6647 30.4019 2.20283 27.8648 3.85931 25.4019C5.51579 22.9391 7.66087 21.4831 8.65294 22.15C9.64502 22.8169 9.10606 25.354 7.44958 27.8169Z" fill="#226187"/>
|
||||
<path d="M54.4957 3.86274C54.4957 5.84358 52.0725 7.45301 49.0831 7.45301C46.0936 7.45301 43.6712 5.84523 43.6712 3.86274C43.6712 1.88024 46.0936 0.272461 49.0822 0.272461C52.0708 0.272461 54.4957 1.88024 54.4957 3.86274Z" fill="#92D9F8"/>
|
||||
<path d="M36.9178 82.4693C33.9466 82.4693 31.5349 80.6205 31.5349 78.3426C31.5349 76.0646 33.9449 74.2158 36.9178 74.2158C39.8907 74.2158 42.3008 76.0646 42.3008 78.3426C42.3008 80.6205 39.8907 82.4693 36.9178 82.4693Z" fill="#5CB1DB"/>
|
||||
<path d="M49.5756 89.2808C46.5564 89.2808 44.1093 87.7786 44.1093 85.9241C44.1093 84.0695 46.5564 82.5674 49.5756 82.5674C52.5947 82.5674 55.0419 84.0695 55.0419 85.9241C55.0419 87.7786 52.5947 89.2808 49.5756 89.2808Z" fill="#5CB1DB"/>
|
||||
<path d="M50.047 16.0918C49.3556 16.1414 48.6615 16.1389 47.9705 16.0844C46.2554 15.8203 43.5598 14.7143 42.4546 10.5281C42.278 9.47917 41.8098 8.50108 41.1036 7.70563C40.3974 6.91018 39.4816 6.32946 38.4609 6.02984C37.4403 5.73022 36.3559 5.72379 35.3318 6.01127C34.3077 6.29876 33.3851 6.86857 32.6695 7.65558C31.9539 8.44259 31.4741 9.41505 31.285 10.4618C31.0959 11.5086 31.2051 12.5875 31.6002 13.5751C31.9952 14.5627 32.6601 15.4193 33.519 16.0469C34.3778 16.6746 35.3959 17.0479 36.4568 17.1243C38.5285 17.5494 43.2792 19.0325 44.7442 23.8237L44.7574 23.8014C45.1289 24.7653 45.7566 25.6094 46.5728 26.2426C47.389 26.8758 48.3626 27.2741 49.3886 27.3944C50.4145 27.5147 51.4539 27.3524 52.3944 26.9252C53.3349 26.4979 54.1409 25.8219 54.7253 24.9701C55.3096 24.1183 55.6503 23.123 55.7103 22.0918C55.7704 21.0605 55.5477 20.0324 55.0661 19.1185C54.5846 18.2046 53.8626 17.4396 52.9781 16.906C52.0935 16.3725 51.08 16.0915 50.047 16.0918Z" fill="#92D9F8"/>
|
||||
</g>
|
||||
<mask id="mask1_22474_3185" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="7" y="14" width="36" height="60">
|
||||
<path d="M17.6225 21.227C17.6227 23.0034 18.3286 24.707 19.5848 25.963C20.841 27.2191 22.5446 27.9247 24.3211 27.9247C24.3211 27.9247 29.3433 27.7365 29.4713 32.6655C29.4752 33.1186 29.3873 33.5679 29.2129 33.9861C28.6972 34.9913 27.9603 35.8665 27.0577 36.5459C26.1551 37.2254 25.1103 37.6914 24.0017 37.909C23.3385 38.0409 22.6659 38.1199 21.9903 38.145C21.9903 38.145 21.9969 38.1574 21.9977 38.1607C20.2934 38.2601 18.6914 39.0069 17.5196 40.2484C16.3478 41.4899 15.6946 43.1322 15.6937 44.8394C15.6943 45.081 15.7078 45.3223 15.7341 45.5625C15.7601 46.0998 15.7324 46.6384 15.6516 47.1702C15.3313 48.2523 14.4136 49.8114 11.8104 50.5113L11.8211 50.5195C10.8598 50.8305 9.99905 51.3928 9.32801 52.1482C8.65698 52.9036 8.20005 53.8246 8.00451 54.8159C7.80898 55.8071 7.88197 56.8327 8.2159 57.7863C8.54984 58.7399 9.1326 59.5869 9.90384 60.2397L9.87247 60.2553L9.98637 60.3106C10.9833 61.1256 12.2288 61.5753 13.5164 61.585C14.1087 61.6677 14.6826 61.8508 15.2133 62.1264C16.7047 62.9518 18.2547 64.742 18.4635 68.6228C18.4635 68.6318 18.4635 68.6409 18.4635 68.65C18.4635 68.6847 18.4693 68.7177 18.471 68.7523L18.4784 68.7441C18.6064 69.8088 19.0338 70.8154 19.7108 71.6471C20.3878 72.4788 21.2868 73.1015 22.3034 73.4429C23.32 73.7843 24.4126 73.8305 25.4544 73.576C26.4962 73.3216 27.4445 72.7769 28.1892 72.0053C28.934 71.2337 29.4447 70.2667 29.6621 69.2165C29.8795 68.1664 29.7946 67.0761 29.4174 66.0722C29.0402 65.0684 28.386 64.192 27.5309 63.5449C26.6757 62.8977 25.6546 62.5063 24.586 62.4161L24.5926 62.4029C23.961 62.3481 23.3462 62.1707 22.7826 61.8805C21.3325 61.0014 20.272 59.6027 19.8171 57.9691C19.4153 56.9218 19.3609 55.7731 19.662 54.6925C20.1248 53.6193 20.8946 52.707 21.8747 52.0703V52.0778C22.3577 51.7527 22.9068 51.539 23.4825 51.4522V51.4455C24.8777 51.2154 26.1642 50.5495 27.1576 49.5432C28.151 48.5369 28.8002 47.2419 29.0124 45.8439L29.0264 45.8513C29.0681 45.097 29.2463 44.3566 29.5521 43.6658C30.276 42.4941 31.2692 41.5122 32.4491 40.8017C33.629 40.0913 34.9615 39.6728 36.3357 39.5811L36.3184 39.5655C38.0749 39.5188 39.7427 38.7835 40.962 37.5182C42.1813 36.2529 42.8544 34.5591 42.8361 32.802C42.8178 31.0449 42.1096 29.3655 40.8642 28.1259C39.6188 26.8863 37.936 26.1859 36.1789 26.1758C36.0435 26.1758 35.9098 26.1882 35.7761 26.1956L35.7868 26.184C35.7868 26.184 32.0298 26.3491 31.0683 22.068C31.0283 21.8225 31.0098 21.5739 31.013 21.3252C31.013 21.2938 31.018 21.2625 31.018 21.2303C31.018 19.4537 30.3122 17.7499 29.056 16.4937C27.7998 15.2375 26.096 14.5317 24.3194 14.5317C22.5428 14.5317 20.839 15.2375 19.5828 16.4937C18.3266 17.7499 17.6209 19.4537 17.6209 21.2303" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_22474_3185)">
|
||||
<path d="M42.8836 14.9359L42.343 14.5273H42.8522L42.8836 14.5513V14.9359Z" fill="#50A2CE"/>
|
||||
<path d="M42.8837 15.3241L41.8338 14.5317H42.343L42.8837 14.9403V15.3241Z" fill="#51A3CE"/>
|
||||
<path d="M42.8859 15.7087L41.3268 14.5317H41.8352L42.8851 15.3241L42.8859 15.7087Z" fill="#52A4CF"/>
|
||||
<path d="M42.8859 16.0933L40.8176 14.5317H41.3268L42.8859 15.7087V16.0933Z" fill="#52A5D0"/>
|
||||
<path d="M42.8838 16.4779L40.3062 14.5317H40.8154L42.8838 16.0941V16.4779Z" fill="#53A6D1"/>
|
||||
<path d="M42.8838 16.8625L39.797 14.5317H40.3062L42.8838 16.4812V16.8625Z" fill="#54A6D2"/>
|
||||
<path d="M42.8839 17.2427L39.2878 14.5273H39.7962L42.883 16.8581L42.8839 17.2427Z" fill="#54A7D3"/>
|
||||
<path d="M42.8816 17.6274L38.7764 14.5273H39.2856L42.8816 17.2427V17.6274Z" fill="#55A8D3"/>
|
||||
<path d="M42.8839 18.012L38.2694 14.5273H38.7786L42.8839 17.6274V18.012Z" fill="#56A9D4"/>
|
||||
<path d="M42.884 18.4002L37.7602 14.5317H38.2694L42.884 18.0164V18.4002Z" fill="#57AAD5"/>
|
||||
<path d="M42.8821 18.7848L37.2532 14.5317H37.7616L42.8854 18.4002L42.8821 18.7848Z" fill="#57ABD6"/>
|
||||
<path d="M42.8854 19.1694L36.744 14.5317H37.2532L42.8854 18.7848V19.1694Z" fill="#58ACD7"/>
|
||||
<path d="M42.8833 19.554L36.2326 14.5317H36.7418L42.8833 19.1694V19.554Z" fill="#59ADD8"/>
|
||||
<path d="M42.8833 19.9386L35.7234 14.5317H36.2326L42.8833 19.554V19.9386Z" fill="#59AED9"/>
|
||||
<path d="M42.8811 20.3224L35.2119 14.5317H35.7204L42.8803 19.9386L42.8811 20.3224Z" fill="#5AAFDA"/>
|
||||
<path d="M42.8834 20.707L34.705 14.5317H35.215L42.8842 20.3224L42.8834 20.707Z" fill="#5BB0DA"/>
|
||||
<path d="M42.887 21.0916L34.2002 14.5317H34.7095L42.8879 20.707L42.887 21.0916Z" fill="#5CB1DB"/>
|
||||
<path d="M42.8832 55.9891L29.4795 73.7391H7.89745V28.2738L18.2738 14.5317H34.1923L42.8832 21.0916V55.9891Z" fill="#5CB1DB"/>
|
||||
</g>
|
||||
<path d="M28.9025 81.1212C27.7988 80.4581 26.5637 80.0438 25.2834 79.9071H25.2883C25.2429 79.9071 25.2058 79.9022 25.1637 79.8997H25.0894C24.68 79.8944 24.2736 79.8285 23.8836 79.7041C23.1225 79.2851 22.4562 78.7133 21.9266 78.0246C21.3969 77.3359 21.0154 76.5451 20.8058 75.702C20.4423 74.1732 19.6341 72.7863 18.4833 71.7163C16.3374 69.5704 13.6963 68.7203 12.5969 69.8238C11.4975 70.9273 12.3419 73.5626 14.4894 75.7102C15.3222 76.5664 16.3148 77.2509 17.4112 77.7249C17.4112 77.7249 18.5667 78.145 18.9612 78.348C20.0704 78.9852 22.1652 80.3949 22.5853 82.1306C22.8692 83.4 24.1757 84.9203 26.0798 86.0197C28.711 87.5391 31.476 87.6753 32.2559 86.3234C33.0359 84.9715 31.5354 82.6464 28.9042 81.1237" fill="#226187"/>
|
||||
<path d="M57.0677 44.8021C57.0677 43.0255 56.3619 41.3217 55.1057 40.0655C53.8495 38.8093 52.1457 38.1035 50.3691 38.1035C48.5926 38.1035 46.8888 38.8093 45.6325 40.0655C44.3763 41.3217 43.6706 43.0255 43.6706 44.8021C43.6706 44.8021 43.4288 49.4108 35.9536 50.2279L35.9651 50.2386C34.3688 50.3493 32.8649 51.0285 31.7264 52.1529C30.5878 53.2773 29.8899 54.7725 29.7592 56.3673C29.6285 57.9622 30.0738 59.551 31.0141 60.8458C31.9545 62.1405 33.3277 63.0554 34.8847 63.4244V63.4377C34.8847 63.4377 39.7692 65.1304 41.5478 71.9132C41.7801 73.1097 42.3346 74.2201 43.1514 75.1248C43.9682 76.0295 45.0163 76.6941 46.183 77.0471C47.3496 77.4001 48.5904 77.4281 49.7718 77.128C50.9531 76.8279 52.0301 76.2111 52.8868 75.3441C53.7436 74.4771 54.3474 73.3928 54.6334 72.208C54.9194 71.0231 54.8767 69.7827 54.5098 68.6204C54.1429 67.4581 53.4658 66.4179 52.5514 65.6119C51.6371 64.806 50.5201 64.2648 49.3209 64.0468L49.3259 64.041C49.3259 64.041 49.2838 64.041 49.2079 64.0294C49.1319 64.0179 49.0354 64.0005 48.9479 63.9898C47.8683 63.7942 44.7576 62.8409 43.4708 58.422C43.1796 57.3823 43.2083 56.279 43.5534 55.256C43.815 54.7166 44.1235 54.2012 44.4753 53.7158C45.625 52.5765 47.0949 51.8149 48.6887 51.5328C49.1691 51.4727 49.6542 51.4589 50.1372 51.4915C50.2148 51.4915 50.2924 51.5023 50.37 51.5023C52.1465 51.502 53.8501 50.7962 55.1063 49.5401C56.3625 48.2839 57.0683 46.5802 57.0685 44.8037" fill="#307CAB"/>
|
||||
<path d="M31.0103 0.785833C30.1354 -0.505016 27.387 -0.170749 24.8722 1.53278C23.788 2.23958 22.8723 3.17584 22.1898 4.27542L22.1823 4.26386C22.1823 4.26386 22.1625 4.30926 22.1246 4.38436C22.1138 4.40417 22.1048 4.42315 22.094 4.44296C21.8783 4.84247 21.618 5.21626 21.3182 5.55719C20.3891 6.69923 19.1853 7.58669 17.8195 8.13641L17.7205 8.16199C17.6768 8.17355 17.633 8.18098 17.5893 8.19088C17.3566 8.23525 17.121 8.26283 16.8844 8.27342L16.8894 8.27837C15.6068 8.38484 13.942 9.36948 12.5571 10.9698C10.5688 13.2668 9.91598 15.9566 11.0921 16.9776C12.2682 17.9985 14.8392 16.9644 16.8275 14.6666C17.6991 13.6958 18.3443 12.5437 18.7167 11.2934C18.7604 11.1283 18.8058 10.9806 18.8521 10.8444C19.3396 10.0434 19.9929 9.35597 20.768 8.82826C21.5431 8.30055 22.4221 7.94477 23.3461 7.78481C23.4592 7.76995 23.5656 7.74932 23.6845 7.73941C25.2542 7.67106 26.7685 7.13948 28.0366 6.21169C30.5514 4.50734 31.881 2.07998 31.0078 0.788309" fill="#226187"/>
|
||||
<path d="M9.67559 61.961C11.1612 64.5311 11.3172 67.2185 10.0272 67.9629C8.73716 68.7074 6.48973 67.2275 5.00575 64.6566C3.52177 62.0856 3.36413 59.3999 4.65415 58.6555C5.94418 57.911 8.19161 59.3908 9.67559 61.961Z" fill="#5CB1DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 14.25C16.4142 14.25 16.75 13.9142 16.75 13.5C16.75 13.0858 16.4142 12.75 16 12.75H8C7.58579 12.75 7.25 13.0858 7.25 13.5C7.25 13.9142 7.58579 14.25 8 14.25H16Z" fill="#5A5E79"/>
|
||||
<path d="M12 9.25C12.4142 9.25 12.75 8.91421 12.75 8.5C12.75 8.08579 12.4142 7.75 12 7.75H8C7.58579 7.75 7.25 8.08579 7.25 8.5C7.25 8.91421 7.58579 9.25 8 9.25H12Z" fill="#5A5E79"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0564 1.75H9.94358C8.10583 1.74998 6.65019 1.74997 5.51098 1.90314C4.33856 2.06076 3.38961 2.39288 2.64124 3.14124C1.89288 3.88961 1.56076 4.83856 1.40314 6.01098C1.24997 7.15019 1.24998 8.60582 1.25 10.4436V11.0564C1.24998 12.8942 1.24997 14.3498 1.40314 15.489C1.56076 16.6614 1.89288 17.6104 2.64124 18.3588C3.2938 19.0113 4.16653 19.4182 5.20554 19.6261C5.15328 19.976 5.12848 20.3508 5.17661 20.7076C5.23587 21.1469 5.4178 21.6316 5.86769 21.9667L5.8745 21.9716C6.42468 22.3718 7.05474 22.2687 7.5611 22.1052C8.06411 21.9428 8.68959 21.6378 9.42573 21.2788L9.47858 21.253C9.66498 21.1621 9.84756 21.0714 10.0273 20.982C10.9554 20.5206 11.8084 20.0966 12.722 19.8861C13.0923 19.8018 13.4821 19.7621 14.0084 19.75H14.0564C15.8942 19.75 17.3498 19.75 18.489 19.5969C19.6614 19.4392 20.6104 19.1071 21.3588 18.3588C22.1071 17.6104 22.4392 16.6614 22.5969 15.489C22.75 14.3498 22.75 12.8942 22.75 11.0565V10.4436C22.75 8.60585 22.75 7.15018 22.5969 6.01098C22.4392 4.83856 22.1071 3.88961 21.3588 3.14124C20.6104 2.39288 19.6614 2.06076 18.489 1.90314C17.3498 1.74997 15.8942 1.74998 14.0564 1.75ZM6.97438 18.6322C6.92368 18.8117 6.87395 18.9922 6.82918 19.1709C6.82737 19.1787 6.82544 19.1864 6.82339 19.1941C6.78247 19.3588 6.74591 19.5219 6.71685 19.681C6.65372 20.0267 6.6358 20.3044 6.66315 20.5071C6.68396 20.6614 6.72301 20.7234 6.7478 20.7495C6.78544 20.7524 6.88587 20.747 7.10026 20.6777C7.48912 20.5522 8.01766 20.2965 8.82115 19.9047C8.98311 19.8258 9.14918 19.7429 9.31907 19.6581C10.2444 19.1965 11.2832 18.6781 12.3863 18.4241L12.388 18.4238C12.8927 18.3088 13.3933 18.2633 13.9833 18.2502L13.9916 18.25H14C15.9068 18.25 17.2615 18.2484 18.2892 18.1102C19.2952 17.975 19.8749 17.7213 20.2981 17.2981C20.7213 16.8749 20.975 16.2952 21.1102 15.2892C21.2484 14.2615 21.25 12.9068 21.25 11V10.5C21.25 8.59318 21.2484 7.23851 21.1102 6.21085C20.975 5.20476 20.7213 4.62511 20.2981 4.2019C19.8749 3.77869 19.2952 3.52503 18.2892 3.38976C17.2615 3.25159 15.9068 3.25 14 3.25H10C8.09318 3.25 6.73851 3.25159 5.71085 3.38976C4.70476 3.52503 4.12511 3.77869 3.7019 4.2019C3.27869 4.62511 3.02503 5.20476 2.88976 6.21085C2.75159 7.23851 2.75 8.59318 2.75 10.5V11C2.75 12.9068 2.75159 14.2615 2.88976 15.2892C3.02503 16.2952 3.27869 16.8749 3.7019 17.2981C4.09991 17.6961 4.69415 18.0015 5.54776 18.1648L5.58204 18.044C5.66396 17.7558 5.72847 17.5289 5.76529 17.3493C5.84851 16.9436 6.24491 16.6821 6.65067 16.7653C7.05644 16.8485 7.31792 17.2449 7.23471 17.6507C7.18548 17.8907 7.10155 18.1854 7.02008 18.4713L6.97438 18.6322ZM6.72919 20.7457C6.7296 20.7453 6.73361 20.7459 6.74003 20.7487C6.732 20.7476 6.72879 20.7462 6.72919 20.7457Z" fill="#5A5E79"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.4932 14.2808C21.7528 9.59639 18.955 4.77023 14.2297 3.50853C10.7085 2.56832 7.11906 3.87593 4.99728 6.54158C4.84468 6.7333 4.86798 7.00772 5.04125 7.18099L6.04846 8.1882C6.18151 8.32125 6.1938 8.54137 6.18151 8.74316C6.14872 9.28189 5.6854 9.69203 5.14668 9.65923L2.69362 9.50989C2.38358 9.49102 2.10097 9.32587 1.93232 9.06502C1.76368 8.80416 1.72909 8.47867 1.83914 8.1882C3.77489 3.07901 9.3266 0.176346 14.7339 1.62017C20.4935 3.15805 23.9235 9.05039 22.3807 14.7883C20.8381 20.525 14.9137 23.9174 9.1554 22.3799C4.87709 21.2375 1.88693 17.6944 1.26134 13.5982C1.17985 13.0647 1.54632 12.5661 2.07986 12.4846C2.61339 12.4031 3.11197 12.7696 3.19345 13.3031C3.70523 16.6541 6.15307 19.5552 9.65962 20.4915C14.3861 21.7536 19.2332 18.9666 20.4932 14.2808Z" fill="#5A5E79"/>
|
||||
<path d="M13 8C13 7.44772 12.5523 7 12 7C11.4477 7 11 7.44772 11 8V12C11 12.2652 11.1054 12.5196 11.2929 12.7071L13.2929 14.7071C13.6834 15.0976 14.3166 15.0976 14.7071 14.7071C15.0976 14.3166 15.0976 13.6834 14.7071 13.2929L13 11.5858V8Z" fill="#5A5E79"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 23.3 512.1 465.4">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="9.6%" stop-color="#00aeff"/>
|
||||
<stop offset="77.3%" stop-color="#2253ce"/>
|
||||
<stop offset="100%" stop-color="#0736c4"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="b" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffb657"/>
|
||||
<stop offset="63.4%" stop-color="#ff5f3d"/>
|
||||
<stop offset="92.3%" stop-color="#c02b3c"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="c" x1="151.476" y1="61.457" x2="178.106" y2="369.549" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="15.6%" stop-color="#0d91e1"/>
|
||||
<stop offset="48.7%" stop-color="#52b471"/>
|
||||
<stop offset="65.2%" stop-color="#98bd42"/>
|
||||
<stop offset="93.7%" stop-color="#ffc800"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="d" x1="154.129" y1="22.884" x2="168.669" y2="358.988" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#3dcbff"/>
|
||||
<stop offset="24.7%" stop-color="#0588f7" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="e" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="6.6%" stop-color="#8c48ff"/>
|
||||
<stop offset="50%" stop-color="#f2598a"/>
|
||||
<stop offset="89.6%" stop-color="#ffb152"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="f" x1="352.459" y1="131.769" x2="352.268" y2="223.337" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="5.8%" stop-color="#f8adfa"/>
|
||||
<stop offset="70.8%" stop-color="#a86edd" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d="M374 62c-6.7-22.9-27.8-38.7-51.7-38.7h-15.7c-26 0-48.3 18.6-53 44.2l-26.9 146.8 6.7-22.9c6.7-23 27.8-38.8 51.7-38.8h91.4l38.3 14.9 36.9-14.9H441c-23.9 0-45-15.8-51.7-38.7z" fill="url(#a)"/>
|
||||
<path d="M143.5 449.8c6.7 23 27.8 38.9 51.8 38.9h33.4c29.2 0 53.1-23.3 53.9-52.5l3.6-141.5-7.6 26c-6.7 23-27.8 38.7-51.7 38.7h-92.2l-32.9-17.8-35.6 17.8h10.6c24 0 45.1 15.9 51.8 38.9z" fill="url(#b)"/>
|
||||
<path d="M320 23.3H133.4C80 23.3 48 93.7 26.7 164.2 1.4 247.7-31.6 359.4 64 359.4h80.6c24.1 0 45.2-15.9 51.8-39.1 14-49 38.6-134.5 57.9-199.6 9.8-33.1 18-61.5 30.5-79.2 7.1-9.9 18.8-18.2 35.2-18.2" fill="url(#c)"/>
|
||||
<path d="M320 23.3H133.4C80 23.3 48 93.7 26.7 164.2 1.4 247.7-31.6 359.4 64 359.4h80.6c24.1 0 45.2-15.9 51.8-39.1 14-49 38.6-134.5 57.9-199.6 9.8-33.1 18-61.5 30.5-79.2 7.1-9.9 18.8-18.2 35.2-18.2" fill="url(#d)"/>
|
||||
<path d="M192 488.7h186.7c53.3 0 85.3-70.5 106.7-141 25.3-83.5 58.3-195.2-37.3-195.2h-80.6c-24.1 0-45.2 15.9-51.8 39.1-14 49-38.6 134.6-57.9 199.7-9.8 33.1-18 61.5-30.5 79.2-7.2 9.9-18.9 18.2-35.3 18.2" fill="url(#e)"/>
|
||||
<path d="M192 488.7h186.7c53.3 0 85.3-70.5 106.7-141 25.3-83.5 58.3-195.2-37.3-195.2h-80.6c-24.1 0-45.2 15.9-51.8 39.1-14 49-38.6 134.6-57.9 199.7-9.8 33.1-18 61.5-30.5 79.2-7.2 9.9-18.9 18.2-35.3 18.2" fill="url(#f)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.66675 15.9998C1.66675 23.9159 8.084 30.3332 16.0001 30.3332C23.9162 30.3332 30.3334 23.9159 30.3334 15.9998C30.3334 8.08376 23.9162 1.6665 16.0001 1.6665C8.084 1.6665 1.66675 8.08376 1.66675 15.9998ZM20.706 14.6666L15.9994 9.95947L11.2917 14.6665L12.7059 16.0808L14.9993 13.7877V22.0404H16.9993V13.7881L19.2917 16.0807L20.706 14.6666Z" fill="#3DA5E5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@ -0,0 +1,6 @@
|
||||
|
||||
library;
|
||||
|
||||
export 'src/beam_clock.dart' show BeamClock;
|
||||
export 'src/beam_style.dart' show BeamSize, BeamColorVariant, BeamTheme;
|
||||
export 'src/border_beam.dart' show BorderBeam;
|
||||
@ -0,0 +1,41 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
class BeamClock extends ChangeNotifier implements ValueListenable<double> {
|
||||
BeamClock._();
|
||||
static final BeamClock instance = BeamClock._();
|
||||
|
||||
Ticker? _ticker;
|
||||
int _attachCount = 0;
|
||||
double _base = 0;
|
||||
double _value = 0;
|
||||
@override
|
||||
double get value => _value;
|
||||
|
||||
@visibleForTesting
|
||||
bool get isRunning => _ticker != null;
|
||||
|
||||
@visibleForTesting
|
||||
int get attachCount => _attachCount;
|
||||
|
||||
void attach() {
|
||||
_attachCount++;
|
||||
if (_ticker == null) {
|
||||
_base = _value;
|
||||
_ticker = Ticker(_onTick)..start();
|
||||
}
|
||||
}
|
||||
|
||||
void detach() {
|
||||
if (_attachCount == 0) return;
|
||||
_attachCount--;
|
||||
if (_attachCount == 0) {
|
||||
_ticker?.dispose();
|
||||
_ticker = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
_value = _base + elapsed.inMicroseconds / Duration.microsecondsPerSecond;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,317 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
import 'beam_style.dart';
|
||||
|
||||
@immutable
|
||||
class AmbientBlobFrame {
|
||||
const AmbientBlobFrame({
|
||||
required this.pathFraction,
|
||||
required this.restFraction,
|
||||
required this.color,
|
||||
required this.alpha,
|
||||
});
|
||||
|
||||
final double pathFraction;
|
||||
final double restFraction;
|
||||
final Color color;
|
||||
final double alpha;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
const ambientDriftHz = <double>[0.047, 0.069, 0.091, 0.113, 0.135];
|
||||
|
||||
/// See [ambientDriftHz].
|
||||
@visibleForTesting
|
||||
const ambientBreatheHz = <double>[0.14, 0.185, 0.23, 0.275, 0.32];
|
||||
|
||||
@visibleForTesting
|
||||
List<AmbientBlobFrame> ambientBlobFrames({
|
||||
required BeamPreset preset,
|
||||
required double time,
|
||||
required BeamColorVariant variant,
|
||||
required BeamTheme theme,
|
||||
required double bloom,
|
||||
}) {
|
||||
final frames = <AmbientBlobFrame>[];
|
||||
for (var i = 0; i < preset.blobCount; i++) {
|
||||
final rest = (i * 0.6180339887) % 1.0;
|
||||
final driftSpeed = ambientDriftHz[i % ambientDriftHz.length];
|
||||
final breatheSpeed = ambientBreatheHz[i % ambientBreatheHz.length];
|
||||
final phase = i * 2.399963; // decorrelates blobs from each other
|
||||
|
||||
final wobble = math.sin(time * driftSpeed * 2 * math.pi + phase);
|
||||
final pathFraction = BeamPainter._wrap(rest + preset.driftAmplitude * wobble);
|
||||
|
||||
// Hue drifts slowly per-blob on top of a slow overall creep, so
|
||||
// colours shift gradually rather than sweeping in one direction.
|
||||
final hue = rest + time * preset.revolutionsPerSecond + i * 0.07;
|
||||
final color = BeamPalette.colorAt(variant, hue, theme);
|
||||
|
||||
final breathe = 0.55 + 0.45 * math.sin(time * breatheSpeed + phase * 1.7);
|
||||
final alpha = (breathe * bloom).clamp(0.0, 1.0).toDouble();
|
||||
|
||||
frames.add(AmbientBlobFrame(
|
||||
pathFraction: pathFraction,
|
||||
restFraction: rest,
|
||||
color: color,
|
||||
alpha: alpha,
|
||||
));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
class BeamPainter extends CustomPainter {
|
||||
BeamPainter({
|
||||
required this.clock,
|
||||
required this.timeOffset,
|
||||
required this.speed,
|
||||
required this.preset,
|
||||
required this.variant,
|
||||
required this.theme,
|
||||
required this.strength,
|
||||
required this.radius,
|
||||
required this.inset,
|
||||
double? frozenTime,
|
||||
}) : frozenTime = frozenTime,
|
||||
super(repaint: frozenTime == null ? clock : null);
|
||||
|
||||
final ValueListenable<double> clock;
|
||||
final double timeOffset;
|
||||
final double speed;
|
||||
final BeamPreset preset;
|
||||
final BeamColorVariant variant;
|
||||
final BeamTheme theme;
|
||||
|
||||
/// 0..1 glow intensity, as handed to the widget.
|
||||
final double strength;
|
||||
|
||||
/// Corner radius of the border the beam rides.
|
||||
final double radius;
|
||||
final double inset;
|
||||
final double? frozenTime;
|
||||
|
||||
// Building the path and its metric is the expensive part, so both are
|
||||
// cached and only rebuilt when the box actually changes.
|
||||
Size? _cachedSize;
|
||||
double? _cachedRadius;
|
||||
double? _cachedInset;
|
||||
ui.PathMetric? _metric;
|
||||
Path? _path;
|
||||
|
||||
double get _time => frozenTime ?? (clock.value - timeOffset) * speed;
|
||||
|
||||
void _ensurePath(Size size) {
|
||||
if (_metric != null &&
|
||||
_cachedSize == size &&
|
||||
_cachedRadius == radius &&
|
||||
_cachedInset == inset) {
|
||||
return;
|
||||
}
|
||||
final rect = Offset.zero & size;
|
||||
final grown = rect.inflate(inset);
|
||||
// Clamp so a radius larger than the box can't produce a degenerate path.
|
||||
final r = math.min(radius + inset, math.min(grown.width, grown.height) / 2);
|
||||
final rrect = RRect.fromRectAndRadius(grown, Radius.circular(math.max(0, r)));
|
||||
final path = Path()..addRRect(rrect);
|
||||
final metrics = path.computeMetrics().toList(growable: false);
|
||||
|
||||
_path = path;
|
||||
_metric = metrics.isEmpty ? null : metrics.first;
|
||||
_cachedSize = size;
|
||||
_cachedRadius = radius;
|
||||
_cachedInset = inset;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (size.isEmpty) return;
|
||||
_ensurePath(size);
|
||||
final metric = _metric;
|
||||
final path = _path;
|
||||
if (metric == null || path == null || metric.length <= 0) return;
|
||||
|
||||
final bloom = BeamPalette.bloomFor(strength.clamp(0.0, 1.0).toDouble());
|
||||
if (preset.isPulse) {
|
||||
_paintPulse(canvas, size, path, bloom);
|
||||
} else if (preset.isAmbient) {
|
||||
_paintAmbient(canvas, metric, bloom);
|
||||
} else {
|
||||
_paintComet(canvas, metric, bloom);
|
||||
}
|
||||
}
|
||||
|
||||
/// A comet with a fading tail, riding the perimeter.
|
||||
void _paintComet(Canvas canvas, ui.PathMetric metric, double bloom) {
|
||||
final length = metric.length;
|
||||
final head = _time * preset.revolutionsPerSecond;
|
||||
final tail = preset.tailFraction;
|
||||
final segments = preset.segments;
|
||||
|
||||
// Bloom pass first, crisp core over it.
|
||||
for (final isGlow in const [true, false]) {
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..isAntiAlias = true;
|
||||
if (isGlow) {
|
||||
paint.maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma * bloom);
|
||||
}
|
||||
|
||||
Offset? previous;
|
||||
for (var i = 0; i <= segments; i++) {
|
||||
final f = i / segments;
|
||||
final at = head - f * tail;
|
||||
final point = metric.getTangentForOffset(_wrap(at) * length)?.position;
|
||||
if (point == null) {
|
||||
previous = null;
|
||||
continue;
|
||||
}
|
||||
if (previous != null) {
|
||||
// Squared falloff gives a bright head and a long, soft tail.
|
||||
final fade = (1 - f) * (1 - f);
|
||||
final alpha = fade * bloom * (isGlow ? 0.75 : 1.0);
|
||||
if (alpha > 0.004) {
|
||||
paint
|
||||
..color = BeamPalette.colorAt(variant, at, theme)
|
||||
.withAlpha((alpha.clamp(0.0, 1.0) * 255).round())
|
||||
..strokeWidth =
|
||||
preset.strokeWidth * (isGlow ? 2.6 : 1.0) * (0.55 + 0.45 * fade);
|
||||
canvas.drawLine(previous, point, paint);
|
||||
}
|
||||
}
|
||||
previous = point;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft, independently-drifting colour blobs along the border — an
|
||||
/// ambient light leak rather than a travelling comet.
|
||||
///
|
||||
/// The motion and colour math lives in [ambientBlobFrames], kept separate
|
||||
/// from canvas calls so it can be tested without rendering anything.
|
||||
void _paintAmbient(Canvas canvas, ui.PathMetric metric, double bloom) {
|
||||
final length = metric.length;
|
||||
final blobRadius = preset.blobRadius;
|
||||
final additive = theme == BeamTheme.dark;
|
||||
final blend = additive ? BlendMode.plus : BlendMode.srcOver;
|
||||
|
||||
for (final frame in ambientBlobFrames(
|
||||
preset: preset,
|
||||
time: _time,
|
||||
variant: variant,
|
||||
theme: theme,
|
||||
bloom: bloom,
|
||||
)) {
|
||||
final tangent = metric.getTangentForOffset(frame.pathFraction * length);
|
||||
if (tangent == null) continue;
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(tangent.position.dx, tangent.position.dy);
|
||||
canvas.rotate(tangent.angle);
|
||||
// Stretched along the edge, flattened across it, so each blob hugs
|
||||
// the border instead of reading as a perfect floating dot.
|
||||
canvas.scale(1.5, 0.85);
|
||||
|
||||
// Wide, soft wash first...
|
||||
canvas.drawCircle(
|
||||
Offset.zero,
|
||||
blobRadius,
|
||||
Paint()
|
||||
..color = frame.color.withAlpha(_alpha8(frame.alpha * 0.5))
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma)
|
||||
..blendMode = blend,
|
||||
);
|
||||
// ...then a tighter, slightly brighter core so the blob still reads
|
||||
// as a source of colour rather than pure haze.
|
||||
canvas.drawCircle(
|
||||
Offset.zero,
|
||||
blobRadius * 0.5,
|
||||
Paint()
|
||||
..color = frame.color.withAlpha(_alpha8(frame.alpha * 0.8))
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma * 0.45)
|
||||
..blendMode = blend,
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
static int _alpha8(double a) => (a.clamp(0.0, 1.0) * 255).round();
|
||||
|
||||
/// The whole rim, breathing in or out.
|
||||
void _paintPulse(Canvas canvas, Size size, Path path, double bloom) {
|
||||
final breath = 0.45 + 0.55 * ((math.sin(_time * 2 * math.pi * 0.35) + 1) / 2);
|
||||
final spin = _time * preset.revolutionsPerSecond;
|
||||
final rect = (Offset.zero & size).inflate(inset);
|
||||
final alpha = (breath * bloom).clamp(0.0, 1.0);
|
||||
Color rampAt(int i, double a) => BeamPalette
|
||||
.colorAt(variant, i / 6 + spin, theme)
|
||||
.withAlpha((a.clamp(0.0, 1.0) * 255).round());
|
||||
|
||||
final stops = List<double>.generate(7, (i) => i / 6);
|
||||
final glowShader = ui.Gradient.sweep(
|
||||
rect.center,
|
||||
[for (var i = 0; i <= 6; i++) rampAt(i, alpha)],
|
||||
stops,
|
||||
);
|
||||
final coreShader = ui.Gradient.sweep(
|
||||
rect.center,
|
||||
[for (var i = 0; i <= 6; i++) rampAt(i, alpha * 0.7)],
|
||||
stops,
|
||||
);
|
||||
|
||||
canvas.save();
|
||||
if (preset.size == BeamSize.pulseInner) {
|
||||
// Keep the glow on the surface: clip to the shape itself.
|
||||
canvas.clipPath(path);
|
||||
} else {
|
||||
// Keep the glow outside: punch the shape out of a generous bounds.
|
||||
canvas.clipPath(
|
||||
Path.combine(
|
||||
PathOperation.difference,
|
||||
Path()..addRect(rect.inflate(preset.bleed * 2)),
|
||||
path,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = preset.strokeWidth * 3.2
|
||||
..shader = glowShader
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, preset.glowSigma * bloom),
|
||||
);
|
||||
canvas.restore();
|
||||
|
||||
// A restrained core so the rim still reads as an edge, not just haze.
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = preset.strokeWidth
|
||||
..shader = coreShader,
|
||||
);
|
||||
}
|
||||
|
||||
/// Wraps to [0,1) for any input, including negatives from the tail.
|
||||
static double _wrap(double v) => v - v.floorToDouble();
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant BeamPainter old) {
|
||||
return old.clock != clock ||
|
||||
old.timeOffset != timeOffset ||
|
||||
old.speed != speed ||
|
||||
old.preset != preset ||
|
||||
old.variant != variant ||
|
||||
old.theme != theme ||
|
||||
old.strength != strength ||
|
||||
old.radius != radius ||
|
||||
old.inset != inset ||
|
||||
old.frozenTime != frozenTime;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,231 @@
|
||||
import 'dart:ui' show Color, lerpDouble;
|
||||
|
||||
enum BeamSize {
|
||||
/// Medium travelling comet — the default, tuned for cards.
|
||||
md,
|
||||
|
||||
/// Smaller, tighter comet, for buttons and inputs.
|
||||
sm,
|
||||
|
||||
/// A crisp hairline with barely any bloom.
|
||||
line,
|
||||
|
||||
/// The whole rim breathes, glowing inwards over the surface.
|
||||
pulseInner,
|
||||
pulseOutside,
|
||||
ambient,
|
||||
}
|
||||
|
||||
/// Colour ramps the beam cycles through.
|
||||
enum BeamColorVariant {
|
||||
/// Full rainbow sweep.
|
||||
colorful,
|
||||
|
||||
/// Single ink, resolved from the beam's theme.
|
||||
mono,
|
||||
|
||||
/// Deep blue through cyan.
|
||||
ocean,
|
||||
|
||||
/// Amber through pink to violet.
|
||||
sunset,
|
||||
}
|
||||
|
||||
/// Light or dark rendering, for [BeamColorVariant.mono] and overall bloom.
|
||||
enum BeamTheme { light, dark }
|
||||
|
||||
/// Per-[BeamSize] tuning. These are separate designs, not one design scaled:
|
||||
/// each carries its own stroke, bloom, comet length and travel speed.
|
||||
class BeamPreset {
|
||||
const BeamPreset({
|
||||
required this.size,
|
||||
required this.strokeWidth,
|
||||
required this.glowSigma,
|
||||
required this.tailFraction,
|
||||
required this.revolutionsPerSecond,
|
||||
required this.segments,
|
||||
required this.isPulse,
|
||||
this.isAmbient = false,
|
||||
this.blobCount = 0,
|
||||
this.blobRadius = 0,
|
||||
this.driftAmplitude = 0,
|
||||
});
|
||||
|
||||
/// Which variant this preset describes.
|
||||
final BeamSize size;
|
||||
|
||||
/// Width of the crisp core stroke, in logical pixels.
|
||||
final double strokeWidth;
|
||||
|
||||
/// Blur sigma of the bloom pass.
|
||||
final double glowSigma;
|
||||
|
||||
/// How much of the perimeter the comet's tail covers, 0..1.
|
||||
final double tailFraction;
|
||||
|
||||
/// Laps around the border per second at `speed: 1`.
|
||||
final double revolutionsPerSecond;
|
||||
|
||||
/// Segments used to draw the comet. More is smoother, and costs more.
|
||||
final int segments;
|
||||
|
||||
/// Whether this preset pulses the whole rim instead of travelling.
|
||||
final bool isPulse;
|
||||
|
||||
/// Whether this preset is the ambient blob field rather than a comet.
|
||||
final bool isAmbient;
|
||||
|
||||
/// How many independent blobs drift along the border.
|
||||
final int blobCount;
|
||||
|
||||
/// A blob's radius, in logical pixels.
|
||||
///
|
||||
/// Fixed rather than a fraction of the wrapped box: every other preset
|
||||
/// here sizes itself in absolute pixels regardless of what it wraps,
|
||||
/// and [bleed] can only reserve a fixed amount of padding — sizing this
|
||||
/// as a fraction of the box would make the blobs outgrow that padding on
|
||||
/// a large card and get clipped by whatever ancestor the padding was
|
||||
/// meant to protect against.
|
||||
final double blobRadius;
|
||||
|
||||
/// How far each blob wanders from its resting point on the perimeter,
|
||||
/// as a fraction of the perimeter's length.
|
||||
final double driftAmplitude;
|
||||
|
||||
static const Map<BeamSize, BeamPreset> _presets = {
|
||||
BeamSize.md: BeamPreset(
|
||||
size: BeamSize.md,
|
||||
strokeWidth: 2.4,
|
||||
glowSigma: 13,
|
||||
tailFraction: 0.34,
|
||||
revolutionsPerSecond: 0.42,
|
||||
segments: 52,
|
||||
isPulse: false,
|
||||
),
|
||||
BeamSize.sm: BeamPreset(
|
||||
size: BeamSize.sm,
|
||||
strokeWidth: 1.6,
|
||||
glowSigma: 7,
|
||||
tailFraction: 0.24,
|
||||
revolutionsPerSecond: 0.55,
|
||||
segments: 40,
|
||||
isPulse: false,
|
||||
),
|
||||
BeamSize.line: BeamPreset(
|
||||
size: BeamSize.line,
|
||||
strokeWidth: 1.2,
|
||||
glowSigma: 2.5,
|
||||
tailFraction: 0.18,
|
||||
revolutionsPerSecond: 0.7,
|
||||
segments: 34,
|
||||
isPulse: false,
|
||||
),
|
||||
BeamSize.pulseInner: BeamPreset(
|
||||
size: BeamSize.pulseInner,
|
||||
strokeWidth: 2.0,
|
||||
glowSigma: 17,
|
||||
tailFraction: 1,
|
||||
revolutionsPerSecond: 0.16,
|
||||
segments: 1,
|
||||
isPulse: true,
|
||||
),
|
||||
BeamSize.pulseOutside: BeamPreset(
|
||||
size: BeamSize.pulseOutside,
|
||||
strokeWidth: 2.0,
|
||||
glowSigma: 21,
|
||||
tailFraction: 1,
|
||||
revolutionsPerSecond: 0.16,
|
||||
segments: 1,
|
||||
isPulse: true,
|
||||
),
|
||||
BeamSize.ambient: BeamPreset(
|
||||
size: BeamSize.ambient,
|
||||
strokeWidth: 0,
|
||||
// A wide, soft blur reads as a light leak; a narrow one reads as a
|
||||
// stroke, which is exactly the "linear" look this mode replaces.
|
||||
glowSigma: 18,
|
||||
tailFraction: 0,
|
||||
// Slow overall hue creep, independent of any one blob's drift.
|
||||
revolutionsPerSecond: 0.025,
|
||||
segments: 1,
|
||||
isPulse: false,
|
||||
isAmbient: true,
|
||||
blobCount: 5,
|
||||
// Calibrated against a reference screenshot of the effect: a glow
|
||||
// that reads as a soft light leak hugging the edge, not a halo that
|
||||
// dwarfs the element it's on.
|
||||
blobRadius: 28,
|
||||
driftAmplitude: 0.045,
|
||||
),
|
||||
};
|
||||
|
||||
static BeamPreset of(BeamSize size) => _presets[size]!;
|
||||
|
||||
/// How far the bloom reaches past the border, so the widget can reserve
|
||||
/// room and the glow isn't clipped by an ancestor.
|
||||
///
|
||||
/// Additive, not a max of the two terms: a blob's visual reach is its own
|
||||
/// radius *plus* however far its blur bleeds past that radius, and both
|
||||
/// terms are present at once. The `blobRadius * 1.5` rather than `* 1`
|
||||
/// accounts for the tangential stretch the painter applies to each blob
|
||||
/// (`canvas.scale(1.5, 0.85)`) — `bleed` pads every side equally, so it
|
||||
/// has to cover the wider of the two axes.
|
||||
double get bleed => glowSigma * 2.2 + strokeWidth + blobRadius * 1.5;
|
||||
}
|
||||
|
||||
/// Colour ramps, sampled by [colorAt].
|
||||
class BeamPalette {
|
||||
const BeamPalette._();
|
||||
|
||||
static const List<Color> _colorful = [
|
||||
Color(0xFFFF4D4D),
|
||||
Color(0xFFFFB020),
|
||||
Color(0xFF32D74B),
|
||||
Color(0xFF32ADE6),
|
||||
Color(0xFF7A5CFF),
|
||||
Color(0xFFFF4DA6),
|
||||
];
|
||||
|
||||
static const List<Color> _ocean = [
|
||||
Color(0xFF0B3D91),
|
||||
Color(0xFF1E6FD9),
|
||||
Color(0xFF29C7D6),
|
||||
Color(0xFF7FF3E1),
|
||||
Color(0xFF1E6FD9),
|
||||
];
|
||||
|
||||
static const List<Color> _sunset = [
|
||||
Color(0xFFFFC24D),
|
||||
Color(0xFFFF7A3D),
|
||||
Color(0xFFFF4D8D),
|
||||
Color(0xFF9B4DFF),
|
||||
Color(0xFFFF7A3D),
|
||||
];
|
||||
|
||||
/// Samples the ramp for [variant] at [u], wrapping so the ring has no seam.
|
||||
static Color colorAt(BeamColorVariant variant, double u, BeamTheme theme) {
|
||||
if (variant == BeamColorVariant.mono) {
|
||||
return theme == BeamTheme.dark
|
||||
? const Color(0xFFFFFFFF)
|
||||
: const Color(0xFF111111);
|
||||
}
|
||||
final ramp = switch (variant) {
|
||||
BeamColorVariant.colorful => _colorful,
|
||||
BeamColorVariant.ocean => _ocean,
|
||||
BeamColorVariant.sunset => _sunset,
|
||||
BeamColorVariant.mono => _colorful, // unreachable, handled above
|
||||
};
|
||||
// Wrap into [0,1) even for negative input, then walk the ramp as a loop.
|
||||
final wrapped = u - u.floorToDouble();
|
||||
final scaled = wrapped * ramp.length;
|
||||
final i = scaled.floor() % ramp.length;
|
||||
final next = (i + 1) % ramp.length;
|
||||
final f = scaled - scaled.floorToDouble();
|
||||
return Color.lerp(ramp[i], ramp[next], f) ?? ramp[i];
|
||||
}
|
||||
|
||||
/// Eased strength curve: low `strength` values stay subtle instead of
|
||||
/// jumping straight to a heavy bloom.
|
||||
static double bloomFor(double strength) =>
|
||||
lerpDouble(0.18, 1.0, strength * strength)!;
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'beam_clock.dart';
|
||||
import 'beam_painter.dart';
|
||||
import 'beam_style.dart';
|
||||
|
||||
class BorderBeam extends StatefulWidget {
|
||||
const BorderBeam({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.size = BeamSize.md,
|
||||
this.colorVariant = BeamColorVariant.colorful,
|
||||
this.strength = 0.7,
|
||||
this.active = true,
|
||||
this.theme,
|
||||
this.borderRadius = 16,
|
||||
this.speed = 1.0,
|
||||
this.reserveGlowSpace = true,
|
||||
}) : assert(strength >= 0 && strength <= 1, 'strength must be 0..1'),
|
||||
assert(speed > 0, 'speed must be positive'),
|
||||
assert(borderRadius >= 0, 'borderRadius must not be negative');
|
||||
|
||||
/// The widget the beam rides around.
|
||||
final Widget child;
|
||||
|
||||
/// Which beam treatment to use.
|
||||
final BeamSize size;
|
||||
|
||||
/// Which colour ramp the beam cycles through.
|
||||
final BeamColorVariant colorVariant;
|
||||
|
||||
/// Glow intensity, 0..1. Eased, so low values stay genuinely subtle.
|
||||
final double strength;
|
||||
|
||||
/// Freezes the beam on its current frame, and releases its hold on the
|
||||
/// shared ticker, when `false`. Re-activating resumes from that frame.
|
||||
final bool active;
|
||||
|
||||
/// Light or dark rendering. Defaults to the surrounding [Theme] brightness.
|
||||
final BeamTheme? theme;
|
||||
|
||||
/// Corner radius of the border the beam follows. Match this to your
|
||||
/// child's own radius.
|
||||
final double borderRadius;
|
||||
|
||||
/// Multiplier on the preset's baked travel speed.
|
||||
final double speed;
|
||||
|
||||
/// Whether to pad the widget so the bloom has room to spill.
|
||||
///
|
||||
/// The beam paints outside the child's box, which an ancestor that clips
|
||||
/// (a `ListView`, a `ClipRRect`) would cut off. Leaving this `true` adds
|
||||
/// outer padding sized to the preset's bloom. Set it `false` if you'd
|
||||
/// rather manage spacing yourself and are sure nothing clips.
|
||||
final bool reserveGlowSpace;
|
||||
|
||||
@override
|
||||
State<BorderBeam> createState() => _BorderBeamState();
|
||||
}
|
||||
|
||||
class _BorderBeamState extends State<BorderBeam> {
|
||||
/// Frame shown when animation is suppressed, picked so the comet sits
|
||||
/// somewhere recognisable rather than at a seam.
|
||||
static const double _staticFrame = 0.6;
|
||||
|
||||
final BeamClock _clock = BeamClock.instance;
|
||||
|
||||
bool _attached = false;
|
||||
double _offset = 0;
|
||||
double? _frozenTime;
|
||||
|
||||
double get _currentTime => _frozenTime ?? (_clock.value - _offset) * widget.speed;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_sync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BorderBeam old) {
|
||||
super.didUpdateWidget(old);
|
||||
_sync();
|
||||
}
|
||||
|
||||
/// Re-derives whether the beam should run, keeping the animation
|
||||
/// continuous across every transition.
|
||||
///
|
||||
/// [MediaQuery] and [TickerMode] are read here rather than in a tick
|
||||
/// callback: resolving an inherited widget outside build or a lifecycle
|
||||
/// callback isn't safe.
|
||||
void _sync() {
|
||||
final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
|
||||
final tickerEnabled = TickerMode.of(context);
|
||||
final shouldRun = widget.active && !reduceMotion && tickerEnabled;
|
||||
|
||||
if (reduceMotion) {
|
||||
_frozenTime = _staticFrame;
|
||||
} else if (!shouldRun) {
|
||||
_frozenTime ??= _currentTime;
|
||||
} else {
|
||||
// Rebase so resuming continues from the frozen frame, and a speed
|
||||
// change bends the curve instead of teleporting.
|
||||
_offset = _clock.value - (_frozenTime ?? _currentTime) / widget.speed;
|
||||
_frozenTime = null;
|
||||
}
|
||||
|
||||
_setAttached(shouldRun);
|
||||
}
|
||||
|
||||
void _setAttached(bool value) {
|
||||
if (value == _attached) return;
|
||||
_attached = value;
|
||||
if (value) {
|
||||
_clock.attach();
|
||||
} else {
|
||||
_clock.detach();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_setAttached(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preset = BeamPreset.of(widget.size);
|
||||
final theme = widget.theme ?? (Theme.of(context).brightness == Brightness.dark ? BeamTheme.dark : BeamTheme.light);
|
||||
|
||||
// Inner pulses and the ambient field already sit centered on the
|
||||
// border path; the travelling styles read better nudged just outside
|
||||
// the child's edge.
|
||||
final zeroInset = preset.size == BeamSize.pulseInner || preset.size == BeamSize.ambient;
|
||||
final inset = zeroInset ? 0.0 : preset.strokeWidth;
|
||||
|
||||
Widget painted = CustomPaint(
|
||||
foregroundPainter: BeamPainter(
|
||||
clock: _clock,
|
||||
timeOffset: _offset,
|
||||
speed: widget.speed,
|
||||
preset: preset,
|
||||
variant: widget.colorVariant,
|
||||
theme: theme,
|
||||
strength: widget.strength,
|
||||
radius: widget.borderRadius,
|
||||
inset: inset,
|
||||
frozenTime: _frozenTime,
|
||||
),
|
||||
isComplex: false,
|
||||
willChange: _frozenTime == null,
|
||||
child: widget.child,
|
||||
);
|
||||
|
||||
// Isolated so the beam's per-frame repaint never dirties the child or
|
||||
// its siblings. The painter is decorative and adds nothing to the
|
||||
// semantics tree, so the child's own semantics come through untouched.
|
||||
painted = RepaintBoundary(child: painted);
|
||||
|
||||
if (!widget.reserveGlowSpace) return painted;
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(preset.bleed),
|
||||
child: painted,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class OrbClock extends ChangeNotifier implements ValueListenable<double> {
|
||||
OrbClock._();
|
||||
|
||||
/// The shared instance. Not disposed — it owns no resources while idle.
|
||||
static final OrbClock instance = OrbClock._();
|
||||
|
||||
Ticker? _ticker;
|
||||
int _attachCount = 0;
|
||||
double _base = 0;
|
||||
double _value = 0;
|
||||
|
||||
@override
|
||||
double get value => _value;
|
||||
|
||||
/// Whether the ticker is currently running. For tests.
|
||||
@visibleForTesting
|
||||
bool get isRunning => _ticker != null;
|
||||
|
||||
/// Number of live subscribers. For tests.
|
||||
@visibleForTesting
|
||||
int get attachCount => _attachCount;
|
||||
|
||||
void attach() {
|
||||
_attachCount++;
|
||||
if (_ticker == null) {
|
||||
// A restarted Ticker reports elapsed from zero again, so carry the
|
||||
// previous value forward as a base and keep `value` monotonic.
|
||||
_base = _value;
|
||||
_ticker = Ticker(_onTick)..start();
|
||||
}
|
||||
}
|
||||
|
||||
void detach() {
|
||||
if (_attachCount == 0) return;
|
||||
_attachCount--;
|
||||
if (_attachCount == 0) {
|
||||
_ticker?.dispose();
|
||||
_ticker = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
_value = _base + elapsed.inMicroseconds / Duration.microsecondsPerSecond;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,421 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'orb_clock.dart';
|
||||
import 'thinking_orb.dart' show ThinkingOrbState;
|
||||
|
||||
@immutable
|
||||
class OrbDot {
|
||||
const OrbDot(this.x, this.y, this.r, this.opacity);
|
||||
|
||||
final double x;
|
||||
final double y;
|
||||
final double r;
|
||||
final double opacity;
|
||||
}
|
||||
|
||||
class OrbPainter extends CustomPainter {
|
||||
OrbPainter({
|
||||
required this.state,
|
||||
required this.clock,
|
||||
required this.timeOffset,
|
||||
required this.speed,
|
||||
required this.color,
|
||||
required this.large,
|
||||
double? frozenTime,
|
||||
}) : frozenTime = frozenTime,
|
||||
super(repaint: frozenTime == null ? clock : null);
|
||||
|
||||
/// Which of the nine animated states to render.
|
||||
final ThinkingOrbState state;
|
||||
|
||||
/// The shared clock every orb reads, so instances stay in phase.
|
||||
final ValueListenable<double> clock;
|
||||
|
||||
/// Subtracted from the clock before scaling, so pausing and speed changes
|
||||
/// don't make the animation jump.
|
||||
final double timeOffset;
|
||||
|
||||
/// Multiplier on the state's baked-in speed.
|
||||
final double speed;
|
||||
|
||||
/// Dot colour, fully opaque; per-dot alpha is applied at paint time.
|
||||
final Color color;
|
||||
|
||||
/// `true` for the 64px-scale tuning (more, smaller dots), `false` for the
|
||||
/// 20px-scale tuning (fewer, larger dots).
|
||||
final bool large;
|
||||
|
||||
/// When non-null the orb is frozen on this timestamp: paused, or showing
|
||||
/// the static representative frame for reduce-motion.
|
||||
final double? frozenTime;
|
||||
|
||||
double get _time => frozenTime ?? (clock.value - timeOffset) * speed;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final radius = size.shortestSide / 2;
|
||||
if (radius <= 0) return;
|
||||
|
||||
final dots = dotsForState(state, _time, large);
|
||||
final baseDotRadius = radius * (large ? 0.052 : 0.095);
|
||||
// Leaves a hair of breathing room so a dot centred on the unit circle
|
||||
// still paints fully inside the widget's box.
|
||||
final spread = radius - baseDotRadius * 1.4;
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true;
|
||||
|
||||
for (final dot in dots) {
|
||||
final pos = center + Offset(dot.x * spread, dot.y * spread);
|
||||
final dotRadius = (baseDotRadius * dot.r).clamp(0.35, radius).toDouble();
|
||||
final alpha = (dot.opacity.clamp(0.0, 1.0) * 255).round();
|
||||
if (alpha <= 0) continue;
|
||||
// withAlpha rather than withOpacity: not deprecated on any Flutter
|
||||
// version this package supports.
|
||||
paint.color = color.withAlpha(alpha);
|
||||
canvas.drawCircle(pos, dotRadius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant OrbPainter old) {
|
||||
return old.state != state ||
|
||||
old.color != color ||
|
||||
old.large != large ||
|
||||
old.speed != speed ||
|
||||
old.timeOffset != timeOffset ||
|
||||
old.frozenTime != frozenTime ||
|
||||
old.clock != clock;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<OrbDot> dotsForState(ThinkingOrbState state, double t, bool large) {
|
||||
switch (state) {
|
||||
case ThinkingOrbState.working:
|
||||
return _working(t, large);
|
||||
case ThinkingOrbState.searching:
|
||||
return _searching(t, large);
|
||||
case ThinkingOrbState.solving:
|
||||
return _solving(t, large);
|
||||
case ThinkingOrbState.listening:
|
||||
return _listening(t, large);
|
||||
case ThinkingOrbState.connecting:
|
||||
return _connecting(t, large);
|
||||
case ThinkingOrbState.weaving:
|
||||
return _weaving(t, large);
|
||||
case ThinkingOrbState.composing:
|
||||
return _composing(t, large);
|
||||
case ThinkingOrbState.breathing:
|
||||
return _breathing(t, large);
|
||||
case ThinkingOrbState.shaping:
|
||||
return _shaping(t, large);
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-width of the unit circle at height [v] — used to tuck bands and
|
||||
/// strands inside the orb instead of letting them run past its edge.
|
||||
double _chord(double v) {
|
||||
final s = 1 - v * v;
|
||||
return s <= 0 ? 0 : math.sqrt(s);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// working — particles on tilted orbits
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _working(double t, bool large) {
|
||||
final n = large ? 18 : 9;
|
||||
const tilts = <double>[0.0, math.pi / 3, -math.pi / 3];
|
||||
final dots = <OrbDot>[];
|
||||
|
||||
for (var i = 0; i < n; i++) {
|
||||
final orbit = i % 3;
|
||||
final tilt = tilts[orbit];
|
||||
final speed = 0.55 + orbit * 0.16;
|
||||
final angle = t * speed * 2 * math.pi + (i / n) * 2 * math.pi;
|
||||
|
||||
// Point on a squashed ellipse, then rotated into screen space by `tilt`
|
||||
// so the three orbits genuinely cross rather than sitting flat.
|
||||
final ax = math.cos(angle) * 0.94;
|
||||
final ay = math.sin(angle) * 0.36;
|
||||
final x = ax * math.cos(tilt) - ay * math.sin(tilt);
|
||||
final y = ax * math.sin(tilt) + ay * math.cos(tilt);
|
||||
|
||||
// Minor axis carries depth: the particle is in front on the way "down".
|
||||
final near = (math.sin(angle) + 1) / 2;
|
||||
dots.add(OrbDot(x, y, 0.55 + 0.55 * near, 0.28 + 0.72 * near));
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// searching — a scan meridian sweeps a dotted globe
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _searching(double t, bool large) {
|
||||
final latSteps = large ? 7 : 5;
|
||||
final lonSteps = large ? 12 : 8;
|
||||
final sweep = (t * 0.5 * 2 * math.pi) % (2 * math.pi);
|
||||
final dots = <OrbDot>[];
|
||||
|
||||
for (var la = 0; la < latSteps; la++) {
|
||||
final lat = (la / (latSteps - 1) - 0.5) * math.pi * 0.82;
|
||||
final rowRadius = math.cos(lat);
|
||||
final y = math.sin(lat);
|
||||
// Thin the rows out towards the poles so dots don't bunch up there.
|
||||
final count = math.max(3, (lonSteps * rowRadius).round());
|
||||
|
||||
for (var lo = 0; lo < count; lo++) {
|
||||
final lon = (lo / count) * 2 * math.pi;
|
||||
final z = math.sin(lon) * rowRadius;
|
||||
if (z < -0.25) continue; // cull most of the back face
|
||||
|
||||
var diff = (lon - sweep) % (2 * math.pi);
|
||||
if (diff > math.pi) diff -= 2 * math.pi;
|
||||
final beam = math.exp(-(diff * diff) / 0.1);
|
||||
final front = (z + 1) / 2;
|
||||
|
||||
dots.add(OrbDot(
|
||||
math.cos(lon) * rowRadius,
|
||||
y,
|
||||
0.5 + 0.3 * front + beam * 0.35,
|
||||
0.16 + 0.32 * front + beam * 0.7,
|
||||
));
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// solving — bands scramble, then click back solved
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _solving(double t, bool large) {
|
||||
final rows = large ? 6 : 4;
|
||||
final cols = large ? 6 : 4;
|
||||
final cycle = (t * 0.35) % 1.0;
|
||||
final scramble = cycle < 0.7 ? math.sin(cycle / 0.7 * math.pi) : 0.0;
|
||||
final dots = <OrbDot>[];
|
||||
|
||||
for (var r = 0; r < rows; r++) {
|
||||
final yBase = (r / (rows - 1) - 0.5) * 1.5;
|
||||
for (var c = 0; c < cols; c++) {
|
||||
final xBase = (c / (cols - 1) - 0.5) * 1.5;
|
||||
// Keep the solved state a disc, not a square: drop corner cells.
|
||||
if (xBase * xBase + yBase * yBase > 0.95) continue;
|
||||
|
||||
final seed = r * 31.0 + c * 7.0;
|
||||
final jx = math.sin(seed * 12.9898 + t * (2 + (r + c) % 5)) * 0.45;
|
||||
final jy = math.cos(seed * 7.233 + t * (2 + (r + c) % 3)) * 0.45;
|
||||
|
||||
// Clamp the displaced point by magnitude. Clamping each axis
|
||||
// independently let corner dots reach |p| = 1.41 and paint outside.
|
||||
var x = xBase + jx * scramble;
|
||||
var y = yBase + jy * scramble;
|
||||
final mag = math.sqrt(x * x + y * y);
|
||||
if (mag > 1) {
|
||||
x /= mag;
|
||||
y /= mag;
|
||||
}
|
||||
dots.add(OrbDot(x, y, 0.75, 0.45 + 0.55 * (1 - scramble)));
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listening — a waveform rolls through the rings
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _listening(double t, bool large) {
|
||||
final rings = large ? 4 : 3;
|
||||
final perRing = large ? 14 : 9;
|
||||
final dots = <OrbDot>[];
|
||||
|
||||
for (var ri = 0; ri < rings; ri++) {
|
||||
final baseR = (ri + 1) / rings;
|
||||
for (var i = 0; i < perRing; i++) {
|
||||
final angle = (i / perRing) * 2 * math.pi;
|
||||
final wave = math.sin(angle * 3 + t * 3 - ri * 0.6);
|
||||
final r = baseR * (0.75 + 0.24 * wave);
|
||||
final near = (wave + 1) / 2;
|
||||
dots.add(OrbDot(
|
||||
math.cos(angle) * r,
|
||||
math.sin(angle) * r,
|
||||
0.55 + 0.45 * near,
|
||||
0.35 + 0.55 * near,
|
||||
));
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// connecting — a constellation wires itself
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _connecting(double t, bool large) {
|
||||
final nodeCount = large ? 9 : 6;
|
||||
// Two extra beats at the end so the finished constellation holds for a
|
||||
// moment before the loop restarts, instead of snapping away.
|
||||
final cycle = (t * 0.22) % 1.0;
|
||||
final progress = cycle * (nodeCount + 2);
|
||||
final revealed = math.min(progress.floor() + 1, nodeCount);
|
||||
|
||||
final nodes = <Offset>[];
|
||||
for (var i = 0; i < nodeCount; i++) {
|
||||
final angle = i * 2.39996; // golden angle, keeps nodes well spread
|
||||
final r = 0.32 + 0.58 * math.sqrt((i + 0.5) / nodeCount);
|
||||
nodes.add(Offset(math.cos(angle) * r, math.sin(angle) * r));
|
||||
}
|
||||
|
||||
final dots = <OrbDot>[];
|
||||
for (var i = 0; i < revealed; i++) {
|
||||
final isNewest = i == revealed - 1 && revealed < nodeCount;
|
||||
final pulse = isNewest ? 0.5 + 0.5 * math.sin((progress % 1.0) * math.pi) : 1.0;
|
||||
dots.add(OrbDot(nodes[i].dx, nodes[i].dy, 0.7 + 0.5 * pulse, 0.45 + 0.55 * pulse));
|
||||
|
||||
if (i > 0) {
|
||||
final a = nodes[i - 1];
|
||||
final b = nodes[i];
|
||||
const steps = 4;
|
||||
for (var s = 1; s < steps; s++) {
|
||||
final f = s / steps;
|
||||
dots.add(OrbDot(
|
||||
a.dx + (b.dx - a.dx) * f,
|
||||
a.dy + (b.dy - a.dy) * f,
|
||||
0.32,
|
||||
0.3 * (isNewest ? pulse : 1.0),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// weaving — three strands plait around the sphere
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _weaving(double t, bool large) {
|
||||
final perStrand = large ? 14 : 8;
|
||||
final dots = <OrbDot>[];
|
||||
|
||||
for (var s = 0; s < 3; s++) {
|
||||
final phase = s * (2 * math.pi / 3);
|
||||
for (var i = 0; i < perStrand; i++) {
|
||||
final u = i / (perStrand - 1);
|
||||
final y = (u - 0.5) * 1.78;
|
||||
// Ride the sphere's silhouette instead of a bounding box, so the
|
||||
// strands stay inside the orb at every height.
|
||||
final halfWidth = _chord(y) * 0.98;
|
||||
final theta = u * 4 * math.pi + t * 1.3 + phase;
|
||||
final depth = math.sin(theta);
|
||||
final front = (depth + 1) / 2;
|
||||
dots.add(OrbDot(
|
||||
math.cos(theta) * halfWidth,
|
||||
y,
|
||||
0.5 + 0.45 * front,
|
||||
0.25 + 0.65 * front,
|
||||
));
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composing — an undulating multi-band sash
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _composing(double t, bool large) {
|
||||
final bands = large ? 4 : 3;
|
||||
final perBand = large ? 13 : 8;
|
||||
final dots = <OrbDot>[];
|
||||
|
||||
for (var b = 0; b < bands; b++) {
|
||||
final spread = bands == 1 ? 0.0 : (b / (bands - 1) - 0.5);
|
||||
for (var i = 0; i < perBand; i++) {
|
||||
final u = i / (perBand - 1);
|
||||
final x = (u - 0.5) * 1.84;
|
||||
// Bands converge towards the orb's edge rather than running past it.
|
||||
final halfHeight = _chord(x) * 0.96;
|
||||
final wave = math.sin(u * 4 * math.pi + t * 2.4 + b * 1.1);
|
||||
final near = (wave + 1) / 2;
|
||||
final y = (spread * 1.5 + wave * 0.28).clamp(-1.0, 1.0) * halfHeight;
|
||||
dots.add(OrbDot(x, y, 0.5 + 0.4 * near, 0.38 + 0.52 * near));
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// breathing — a ring slowly morphing
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _breathing(double t, bool large) {
|
||||
final n = large ? 24 : 13;
|
||||
final breath = (math.sin(t * 1.05) + 1) / 2;
|
||||
final dots = <OrbDot>[];
|
||||
|
||||
for (var i = 0; i < n; i++) {
|
||||
final angle = (i / n) * 2 * math.pi;
|
||||
// Two slow harmonics on top of the breath, so the ring morphs shape as
|
||||
// well as scale rather than just pulsing in and out.
|
||||
final r = 0.60 +
|
||||
0.16 * breath +
|
||||
0.09 * math.sin(2 * angle + t * 0.7) +
|
||||
0.06 * math.sin(3 * angle - t * 0.45);
|
||||
dots.add(OrbDot(
|
||||
math.cos(angle) * r,
|
||||
math.sin(angle) * r,
|
||||
0.55 + 0.45 * breath,
|
||||
0.42 + 0.48 * breath,
|
||||
));
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shaping — dotted outline: circle -> triangle -> square
|
||||
// ---------------------------------------------------------------------------
|
||||
List<OrbDot> _shaping(double t, bool large) {
|
||||
final n = large ? 28 : 16;
|
||||
final phase = (t * 0.33) % 3.0;
|
||||
final shapeA = phase.floor() % 3;
|
||||
final shapeB = (shapeA + 1) % 3;
|
||||
final morph = (1 - math.cos((phase - phase.floor()) * math.pi)) / 2;
|
||||
|
||||
final dots = <OrbDot>[];
|
||||
for (var i = 0; i < n; i++) {
|
||||
final u = i / n;
|
||||
final a = _pointOnShape(shapeA, u);
|
||||
final b = _pointOnShape(shapeB, u);
|
||||
dots.add(OrbDot(
|
||||
(a.dx + (b.dx - a.dx) * morph) * 0.9,
|
||||
(a.dy + (b.dy - a.dy) * morph) * 0.9,
|
||||
0.65,
|
||||
0.78,
|
||||
));
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
Offset _pointOnShape(int shape, double u) {
|
||||
switch (shape) {
|
||||
case 0: // circle
|
||||
final a = u * 2 * math.pi - math.pi / 2;
|
||||
return Offset(math.cos(a), math.sin(a));
|
||||
case 1: // triangle, point up
|
||||
return _polygonPoint(u, 3, -math.pi / 2);
|
||||
default: // square, sitting flat rather than on a corner
|
||||
return _polygonPoint(u, 4, -math.pi / 4);
|
||||
}
|
||||
}
|
||||
|
||||
Offset _polygonPoint(double u, int sides, double rotation) {
|
||||
final seg = u * sides;
|
||||
final index = seg.floor() % sides;
|
||||
final f = seg - seg.floor();
|
||||
final a0 = index / sides * 2 * math.pi + rotation;
|
||||
final a1 = (index + 1) / sides * 2 * math.pi + rotation;
|
||||
final p0 = Offset(math.cos(a0), math.sin(a0));
|
||||
final p1 = Offset(math.cos(a1), math.sin(a1));
|
||||
return Offset(p0.dx + (p1.dx - p0.dx) * f, p0.dy + (p1.dy - p0.dy) * f);
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'orb_clock.dart';
|
||||
import 'orb_painter.dart';
|
||||
|
||||
enum ThinkingOrbState {
|
||||
/// Particles on tilted orbits.
|
||||
working,
|
||||
|
||||
/// A scan meridian sweeps a dotted globe.
|
||||
searching,
|
||||
|
||||
/// Bands scramble, then click back solved.
|
||||
solving,
|
||||
|
||||
/// A waveform rolls through the rings.
|
||||
listening,
|
||||
|
||||
/// A constellation wires itself.
|
||||
connecting,
|
||||
|
||||
/// Three strands plait around the sphere.
|
||||
weaving,
|
||||
|
||||
/// An undulating multi-band sash.
|
||||
composing,
|
||||
|
||||
/// A ring slowly morphing.
|
||||
breathing,
|
||||
|
||||
/// Dotted outline: circle -> triangle -> square.
|
||||
shaping,
|
||||
}
|
||||
|
||||
/// How [ThinkingOrb] resolves its (strictly monochrome) dot colour.
|
||||
enum ThinkingOrbTheme {
|
||||
/// Follow the ambient [Theme] brightness: light dots on dark backgrounds,
|
||||
/// dark dots on light ones. The default.
|
||||
auto,
|
||||
|
||||
/// Pin to light dots, for dark backgrounds.
|
||||
dark,
|
||||
|
||||
/// Pin to dark dots, for light backgrounds.
|
||||
light,
|
||||
}
|
||||
|
||||
class ThinkingOrb extends StatefulWidget {
|
||||
const ThinkingOrb({
|
||||
super.key,
|
||||
this.state = ThinkingOrbState.working,
|
||||
this.size = 64,
|
||||
this.speed = 1.0,
|
||||
this.paused = false,
|
||||
this.theme = ThinkingOrbTheme.auto,
|
||||
this.color,
|
||||
this.semanticLabel,
|
||||
}) : assert(size > 0, 'size must be positive'),
|
||||
assert(speed > 0, 'speed must be positive');
|
||||
|
||||
/// Which animated state to render.
|
||||
final ThinkingOrbState state;
|
||||
|
||||
final double size;
|
||||
|
||||
final double speed;
|
||||
|
||||
final bool paused;
|
||||
|
||||
/// How to resolve the dot colour. Ignored when [color] is set.
|
||||
final ThinkingOrbTheme theme;
|
||||
|
||||
/// Overrides [theme] with an explicit colour. Any alpha is ignored — the
|
||||
/// orb applies its own per-dot alpha.
|
||||
final Color? color;
|
||||
|
||||
/// Overrides the per-state default semantics label (e.g. "Searching…").
|
||||
final String? semanticLabel;
|
||||
|
||||
/// Sizes at or above this use the large dot-field tuning.
|
||||
static const double kLargePresetMinSize = 40;
|
||||
|
||||
@override
|
||||
State<ThinkingOrb> createState() => _ThinkingOrbState();
|
||||
}
|
||||
|
||||
class _ThinkingOrbState extends State<ThinkingOrb> {
|
||||
/// The frame shown when animation is suppressed for reduce-motion — picked
|
||||
/// so every state reads as recognisably itself rather than mid-blank.
|
||||
static const double _staticFrame = 0.35;
|
||||
|
||||
final OrbClock _clock = OrbClock.instance;
|
||||
|
||||
bool _attached = false;
|
||||
double _offset = 0;
|
||||
double? _frozenTime;
|
||||
|
||||
double get _currentTime => _frozenTime ?? (_clock.value - _offset) * widget.speed;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_sync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ThinkingOrb old) {
|
||||
super.didUpdateWidget(old);
|
||||
_sync();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
|
||||
// Respects the TickerMode an enclosing route sets, so orbs on a covered
|
||||
// route stop animating for free.
|
||||
final tickerEnabled = TickerMode.of(context);
|
||||
final shouldRun = !widget.paused && !reduceMotion && tickerEnabled;
|
||||
|
||||
if (reduceMotion) {
|
||||
_frozenTime = _staticFrame;
|
||||
} else if (!shouldRun) {
|
||||
// Freeze on whatever is on screen right now.
|
||||
_frozenTime ??= _currentTime;
|
||||
} else {
|
||||
// Rebase so the resumed animation continues from the frozen frame, and
|
||||
// so a speed change bends the curve instead of teleporting.
|
||||
_rebaseTo(_frozenTime ?? _currentTime);
|
||||
_frozenTime = null;
|
||||
}
|
||||
|
||||
_setAttached(shouldRun);
|
||||
}
|
||||
|
||||
/// Chooses an offset such that [_currentTime] evaluates to [time] right now.
|
||||
void _rebaseTo(double time) {
|
||||
_offset = _clock.value - time / widget.speed;
|
||||
}
|
||||
|
||||
void _setAttached(bool value) {
|
||||
if (value == _attached) return;
|
||||
_attached = value;
|
||||
if (value) {
|
||||
_clock.attach();
|
||||
} else {
|
||||
_clock.detach();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_setAttached(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color _resolveColor() {
|
||||
final explicit = widget.color;
|
||||
if (explicit != null) return explicit.withAlpha(255);
|
||||
final dark = switch (widget.theme) {
|
||||
ThinkingOrbTheme.dark => true,
|
||||
ThinkingOrbTheme.light => false,
|
||||
ThinkingOrbTheme.auto => Theme.of(context).brightness == Brightness.dark,
|
||||
};
|
||||
return dark ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
|
||||
}
|
||||
|
||||
String get _defaultLabel => switch (widget.state) {
|
||||
ThinkingOrbState.working => 'Working',
|
||||
ThinkingOrbState.searching => 'Searching',
|
||||
ThinkingOrbState.solving => 'Solving',
|
||||
ThinkingOrbState.listening => 'Listening',
|
||||
ThinkingOrbState.connecting => 'Connecting',
|
||||
ThinkingOrbState.weaving => 'Weaving',
|
||||
ThinkingOrbState.composing => 'Composing',
|
||||
ThinkingOrbState.breathing => 'Breathing',
|
||||
ThinkingOrbState.shaping => 'Shaping',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: widget.semanticLabel ?? _defaultLabel,
|
||||
image: true,
|
||||
container: true,
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
size: Size.square(widget.size),
|
||||
isComplex: false,
|
||||
willChange: _frozenTime == null,
|
||||
painter: OrbPainter(
|
||||
state: widget.state,
|
||||
clock: _clock,
|
||||
timeOffset: _offset,
|
||||
speed: widget.speed,
|
||||
color: _resolveColor(),
|
||||
large: widget.size >= ThinkingOrb.kLargePresetMinSize,
|
||||
frozenTime: _frozenTime,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
library;
|
||||
|
||||
export 'src/orb_clock.dart' show OrbClock;
|
||||
export 'src/thinking_orb.dart' show ThinkingOrb, ThinkingOrbState, ThinkingOrbTheme;
|
||||
Loading…
Reference in New Issue