Compare commits
99 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
439140dba2 | 4 days ago |
|
|
67802bb021 | 4 days ago |
|
|
59efdf382b | 4 days ago |
|
|
d7b88e2e73 | 4 days ago |
|
|
f9332d29ae | 4 days ago |
|
|
95888fdb64 | 4 days ago |
|
|
548aa501a1 | 5 days ago |
|
|
38906e24b7 | 5 days ago |
|
|
c32b155718 | 5 days ago |
|
|
efee685178 | 5 days ago |
|
|
f483c858ef | 2 weeks ago |
|
|
3dcb332a04 | 2 weeks ago |
|
|
48e5d70ada | 2 weeks ago |
|
|
3cf2c2a4c8 | 2 weeks ago |
|
|
06295821aa | 2 weeks ago |
|
|
ae1bd7f0c5 | 2 weeks ago |
|
|
6ba06065b3 | 2 weeks ago |
|
|
d75f9a9cc3 | 2 weeks ago |
|
|
63f678b666 | 2 weeks ago |
|
|
ef7078c152 | 2 weeks ago |
|
|
252b9b471c | 2 weeks ago |
|
|
bd6e9577e3 | 2 weeks ago |
|
|
e6cc2491b4 | 2 weeks ago |
|
|
5194f8b1fd | 2 weeks ago |
|
|
2fb4a99a42 | 2 weeks ago |
|
|
fe36263ac0 | 2 weeks ago |
|
|
99c2d658e1 | 2 weeks ago |
|
|
1d2ed863ae | 2 weeks ago |
|
|
b931ed8138 | 2 weeks ago |
|
|
b25b398695 | 2 weeks ago |
|
|
c957d547a8 | 3 weeks ago |
|
|
a517e4e48b | 3 weeks ago |
|
|
a0c5ac7e6c | 3 weeks ago |
|
|
7043f0f91c | 3 weeks ago |
|
|
99646c90c5 | 3 weeks ago |
|
|
1a4dd29a9b | 3 weeks ago |
|
|
9bcb6c6b8a | 3 weeks ago |
|
|
e66154d476 | 3 weeks ago |
|
|
2a4cd36ba9 | 3 weeks ago |
|
|
a152fe1da2 | 3 weeks ago |
|
|
6b364b4109 | 3 weeks ago |
|
|
367218702b | 3 weeks ago |
|
|
c976b9c9db | 3 weeks ago |
|
|
ca9c7f7e5d | 3 weeks ago |
|
|
d23910351f | 3 weeks ago |
|
|
49d46dada2 | 3 weeks ago |
|
|
fc04ff13bc | 3 weeks ago |
|
|
a0967051e8 | 3 weeks ago |
|
|
bdf3337af4 | 3 weeks ago |
|
|
9aa368dbf7 | 3 weeks ago |
|
|
d6fdcb214d | 3 weeks ago |
|
|
a8f9d275f5 | 3 weeks ago |
|
|
ecf1c7c948 | 3 weeks ago |
|
|
5a96d64d9c | 3 weeks ago |
|
|
bd6e6c6f59 | 3 weeks ago |
|
|
7c9599ff6e | 3 weeks ago |
|
|
62058d6dd9 | 3 weeks ago |
|
|
37303458bf | 3 weeks ago |
|
|
cd2703cbc5 | 3 weeks ago |
|
|
6d4bddd42d | 3 weeks ago |
|
|
3a73ec1c2e | 3 weeks ago |
|
|
59bba20e20 | 3 weeks ago |
|
|
4b1589f7df | 3 weeks ago |
|
|
975f6622b4 | 3 weeks ago |
|
|
154b62bbad | 3 weeks ago |
|
|
821cdf474d | 3 weeks ago |
|
|
f6865959e6 | 3 weeks ago |
|
|
e21738cf5f | 3 weeks ago |
|
|
518c766756 | 3 weeks ago |
|
|
ec37e74bc0 | 3 weeks ago |
|
|
58908df424 | 3 weeks ago |
|
|
7065a4fb23 | 3 weeks ago |
|
|
cc03ab6a80 | 3 weeks ago |
|
|
287afc6ff1 | 3 weeks ago |
|
|
6aec5b1a8c | 3 weeks ago |
|
|
4cd4766ab8 | 3 weeks ago |
|
|
798584431e | 3 weeks ago |
|
|
e134e8d4e5 | 3 weeks ago |
|
|
825845b4c3 | 3 weeks ago |
|
|
0a270bd323 | 3 weeks ago |
|
|
fa1cbf866b | 3 weeks ago |
|
|
ae816a1f8f | 3 weeks ago |
|
|
371a3ecb74 | 3 weeks ago |
|
|
f19a1441b3 | 3 weeks ago |
|
|
857d1e74ac | 3 weeks ago |
|
|
e05e97a536 | 3 weeks ago |
|
|
6344d78302 | 1 month ago |
|
|
4baf5f9389 | 1 month ago |
|
|
cd9f8e0b48 | 1 month ago |
|
|
c49872f798 | 1 month ago |
|
|
f503dca074 | 1 month ago |
|
|
9dcbb0d855 | 1 month ago |
|
|
a6be594b3b | 1 month ago |
|
|
9284d0e100 | 1 month ago |
|
|
0175c958ef | 1 month ago |
|
|
3cfe5355ef | 2 months ago |
|
|
4f4db082fa | 10 months ago |
|
|
f253a5e99e | 10 months ago |
|
|
234dce65e5 | 10 months ago |
@ -1,6 +1,6 @@
|
||||
#Tue Jul 08 11:08:34 AST 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
<svg width="74" height="74" viewBox="0 0 74 74" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_119_438)">
|
||||
<rect x="17" y="17" width="40" height="40" rx="20" fill="url(#paint0_linear_119_438)"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_119_438)">
|
||||
<rect x="13" y="13" width="48" height="48" rx="24" fill="url(#paint1_linear_119_438)" fill-opacity="0.4" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.9992 37.5291C40.7637 38.0552 39.4301 38.3796 38.0352 38.4637C37.7946 38.4782 37.5523 38.4856 37.3082 38.4856C37.0642 38.4856 36.8219 38.4782 36.5813 38.4637C35.1861 38.3796 33.8522 38.055 32.6165 37.5287C30.0232 36.4243 27.8623 34.4316 26.4749 31.9094C28.6373 27.9782 32.6791 25.3333 37.3082 25.3333C41.9374 25.3333 45.9792 27.9782 48.1416 31.9094C46.7541 34.4319 44.5928 36.4247 41.9992 37.5291Z" fill="white"/>
|
||||
<path d="M32.6163 38.5525C32.3175 38.4253 32.0245 38.2863 31.7378 38.136C30.8058 38.8264 30.018 39.6862 29.4248 40.6673C30.4331 42.3347 32.0037 43.6522 33.8885 44.3824C34.7866 44.7303 35.7561 44.9449 36.7701 45.0005H37.8268C38.8406 44.9449 40.6224 46.0011 38.035 48.6666C39.9201 47.9364 44.1637 42.3349 45.1721 40.6673C44.5806 39.689 43.7956 38.8313 42.8671 38.1419C42.5837 38.29 42.2941 38.4272 41.999 38.5529C40.7635 39.079 39.4299 39.4034 38.035 39.4875C37.7945 39.502 37.5521 39.5093 37.3081 39.5093C37.0641 39.5093 36.8217 39.502 36.5811 39.4875C35.1859 39.4034 33.852 39.0788 32.6163 38.5525Z" fill="white"/>
|
||||
<rect x="32.0844" y="30.2271" width="10.4705" height="3.93258" rx="1.96629" fill="#162550"/>
|
||||
<ellipse cx="40.2071" cy="32.1717" rx="0.685571" ry="0.720974" fill="#04FED1"/>
|
||||
<ellipse cx="37.2984" cy="41.8719" rx="0.685571" ry="0.720974" fill="#162550"/>
|
||||
<ellipse cx="34.5566" cy="32.1717" rx="0.685571" ry="0.720974" fill="#04FED1"/>
|
||||
<ellipse cx="34.5566" cy="41.8719" rx="0.685571" ry="0.720974" fill="#162550"/>
|
||||
<ellipse cx="40.0411" cy="41.8719" rx="0.685571" ry="0.720974" fill="#162550"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_119_438" x="4" y="4" width="66" height="66" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.0784314 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_119_438"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_119_438" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_119_438" x="0" y="0" width="74" height="74" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.0784314 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_119_438"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_119_438" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_119_438" x1="-12.3178" y1="86.2302" x2="-12.3178" y2="18.3971" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="#259CB8"/>
|
||||
<stop stop-color="#32D892"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_119_438" x1="-22.1814" y1="96.0762" x2="-22.1814" y2="14.6765" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="#259CB8"/>
|
||||
<stop stop-color="#32D892"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@ -0,0 +1,3 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.8331 11.6668C5.83305 11.7752 5.86321 11.8815 5.9202 11.9737C5.97718 12.066 6.05874 12.1405 6.15572 12.1889L7.3225 12.7723C7.41146 12.8167 7.51031 12.8377 7.60967 12.8332C7.70902 12.8287 7.80557 12.7989 7.89015 12.7466C7.97473 12.6943 8.04454 12.6212 8.09293 12.5343C8.14133 12.4474 8.16671 12.3496 8.16666 12.2502V8.16678C8.16679 7.87766 8.27427 7.5989 8.46827 7.38452L12.6821 2.72418C12.7576 2.64051 12.8073 2.53675 12.8251 2.42544C12.8429 2.31414 12.828 2.20007 12.7824 2.09702C12.7367 1.99396 12.6622 1.90635 12.5678 1.84477C12.4733 1.78318 12.3631 1.75027 12.2504 1.75H1.74938C1.63655 1.75004 1.52616 1.78279 1.43158 1.84429C1.33699 1.90579 1.26226 1.99339 1.21645 2.09648C1.17064 2.19958 1.15571 2.31375 1.17347 2.42516C1.19123 2.53656 1.24091 2.64043 1.3165 2.72418L5.53149 7.38452C5.72549 7.5989 5.83297 7.87766 5.8331 8.16678V11.6668Z" stroke-linecap="round" stroke="#202B33"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1001 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 559 B |
@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 6C11 7.65685 9.65685 9 8 9C6.34315 9 5 7.65685 5 6C5 4.34315 6.34315 3 8 3C9.65685 3 11 4.34315 11 6Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="#26343D"/>
|
||||
<path d="M2 20V16C1.9946 14.8394 2.32594 13.7021 2.95386 12.726C3.58179 11.7499 4.47934 10.9768 5.53775 10.5006C6.59616 10.0243 7.76999 9.86518 8.917 10.0426C10.064 10.22 11.1349 10.7262 12 11.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="#26343D"/>
|
||||
<path d="M19.5 11C19.5 12.3807 18.3807 13.5 17 13.5C15.6193 13.5 14.5 12.3807 14.5 11C14.5 9.61929 15.6193 8.5 17 8.5C18.3807 8.5 19.5 9.61929 19.5 11Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="#26343D"/>
|
||||
<path d="M12 21V18.5C12 17.1739 12.5268 15.9021 13.4645 14.9645C14.4021 14.0268 15.6739 13.5 17 13.5C18.3261 13.5 19.5979 14.0268 20.5355 14.9645C21.4732 15.9021 22 17.1739 22 18.5V21H12Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="#26343D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="arrow-right">
|
||||
<path id="Vector" d="M3.1245 7.5H11.8755M7.5 11.8755L11.8755 7.5L7.5 3.1245" stroke-width="1.7" stroke-linecap="round" stroke="#147D6B"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="arrow-up-right">
|
||||
<path id="Vector" d="M14.166 14.166V5.834H5.834M14.166 5.834L5.834 14.166" stroke-width="1.7" stroke-linecap="round" stroke="#143F36"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="chevron-down">
|
||||
<path id="Vector" d="M3 4.5L6 7.5L9 4.5" stroke-width="1.7" stroke-linecap="round" stroke="#147D6B"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="chevron-left">
|
||||
<path id="Vector" d="M12.5 15L7.5 10L12.5 5" stroke-width="1.7" stroke-linecap="round" stroke="#202F32"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@ -0,0 +1,10 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="compass" clip-path="url(#clip0_0_24)">
|
||||
<path id="Vector" d="M20.1674 11C20.1674 16.063 16.063 20.1674 11 20.1674C5.93698 20.1674 1.8326 16.063 1.8326 11C1.8326 5.93698 5.93698 1.8326 11 1.8326C16.063 1.8326 20.1674 5.93698 20.1674 11ZM14.8868 7.11281L13.233 12.0733C13.143 12.3433 12.9913 12.5887 12.79 12.79C12.5887 12.9913 12.3433 13.143 12.0733 13.233L7.11281 14.8868L8.76661 9.92628C8.85661 9.65623 9.00827 9.41084 9.20955 9.20955C9.41084 9.00827 9.65623 8.85661 9.92628 8.76661L14.8868 7.11281Z" stroke-width="1.7" stroke-linecap="round" stroke="#147D6B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_24">
|
||||
<rect width="22" height="22" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 845 B |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="heart">
|
||||
<path id="Vector" d="M1.2495 5.93763C1.24951 5.24212 1.46051 4.56298 1.85463 3.9899C2.24875 3.41682 2.80744 2.97676 3.45693 2.72785C4.10641 2.47893 4.81612 2.43287 5.49233 2.59575C6.16855 2.75863 6.77944 3.12278 7.24434 3.64011C7.27708 3.67512 7.31667 3.70303 7.36064 3.72211C7.40462 3.74119 7.45204 3.75104 7.49998 3.75104C7.54792 3.75104 7.59534 3.74119 7.63932 3.72211C7.68329 3.70303 7.72288 3.67512 7.75563 3.64011C8.21906 3.11941 8.8301 2.7522 9.50741 2.58735C10.1847 2.42249 10.8962 2.46781 11.5471 2.71728C12.198 2.96674 12.7575 3.40851 13.1511 3.9838C13.5447 4.55909 13.7538 5.2406 13.7505 5.93763C13.7505 7.36889 12.8129 8.43765 11.8753 9.37516L8.44255 12.6958C8.32609 12.8296 8.18249 12.937 8.0213 13.011C7.86011 13.085 7.68502 13.1239 7.50766 13.125C7.3303 13.1261 7.15473 13.0895 6.99262 13.0175C6.83051 12.9456 6.68556 12.84 6.56741 12.7077L3.12464 9.37516C2.18707 8.43765 1.2495 7.37514 1.2495 5.93763Z" stroke-width="1.7" stroke-linecap="round" stroke="#202F32"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="heart">
|
||||
<path id="Vector" d="M1.8326 8.70852C1.83262 7.68845 2.14208 6.69237 2.72012 5.85185C3.29816 5.01134 4.11758 4.36592 5.07016 4.00084C6.02273 3.63577 7.06365 3.56821 8.05542 3.8071C9.0472 4.04598 9.94318 4.58007 10.625 5.33882C10.6731 5.39017 10.7311 5.43111 10.7956 5.45909C10.8601 5.48708 10.9297 5.50152 11 5.50152C11.0703 5.50152 11.1398 5.48708 11.2043 5.45909C11.2688 5.43111 11.3269 5.39017 11.3749 5.33882C12.0546 4.57514 12.9508 4.03656 13.9442 3.79478C14.9376 3.55299 15.9811 3.61946 16.9357 3.98534C17.8904 4.35122 18.711 4.99915 19.2883 5.8429C19.8656 6.68666 20.1722 7.68621 20.1673 8.70852C20.1673 10.8077 18.7922 12.3752 17.4171 13.7502L12.3824 18.6205C12.2116 18.8167 12.001 18.9743 11.7646 19.0828C11.5282 19.1913 11.2714 19.2483 11.0112 19.25C10.7511 19.2516 10.4936 19.1979 10.2558 19.0924C10.0181 18.9869 9.80549 18.8319 9.6322 18.638L4.58281 13.7502C3.20771 12.3752 1.8326 10.8169 1.8326 8.70852Z" stroke-width="1.7" stroke-linecap="round" stroke="#78817F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 114.3 52">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #5e5f5f;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #ec1f2d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<path fill="#5e5f5f" d="M15.74,25.46H3.99c-2.53,0-3.99-1.42-3.99-3.78v-11.01h3.31v9.33c0,1.56.4,2.39,2.84,2.39h9.59v3.07h0Z"/>
|
||||
<path fill="#5e5f5f" d="M17.2,29.26h-2.93v-2.66h2.93v2.66ZM21.88,21.03c0,1.21-.35,2.22-1.04,3.03-.78.94-1.87,1.4-3.24,1.4h-2.81v-3.07h2.1c1.11,0,1.68-.76,1.68-2.25v-5.07h3.31v5.96ZM21.67,29.26h-2.95v-2.66h2.95v2.66Z"/>
|
||||
<path fill="#5e5f5f" d="M27.6,25.46h-3.31v-14.79h3.31v14.79Z"/>
|
||||
<path fill="#5e5f5f" d="M39.98,25.46h-3.66c-.54,2.52-2.67,3.78-6.43,3.78v-2.59c2.17,0,3.26-1.01,3.26-3.03v-8.55h3.33v7.32h3.5v3.07h0ZM36.27,13.32h-2.93v-2.64h2.93v2.64Z"/>
|
||||
<path fill="#5e5f5f" d="M53.17,22.2c0,3.07-2.13,5.23-5.44,5.23-2.01,0-3.52-.67-4.49-1.97h-4.25v-3.07h3.28c0-3.28,1.94-5.37,5.34-5.37s5.55,2.04,5.55,5.18ZM49.89,22.31c0-1.81-.71-2.73-2.1-2.73h-.24c-1.21,0-2.03,1.08-2.03,2.59s.8,2.64,2.22,2.64c1.3,0,2.15-1.01,2.15-2.5h0Z"/>
|
||||
<path fill="#ec1f2d" d="M104.69,0h-34.38c-5.31,0-9.61,4.17-9.61,9.32v33.36c0,5.15,4.3,9.32,9.61,9.32h34.38c5.31,0,9.61-4.17,9.61-9.32V9.32c0-5.15-4.3-9.32-9.61-9.32Z"/>
|
||||
<path fill="#ffffff" d="M107.74,34.57l-.03,10.61-14.28-.04c-1.28,0-2.16-1.23-2.22-3.11,0-2.95-.9-5.86-2.69-8.25-3.37-4.51-8.12-8.12-14.29-10.8-1.52-.66-3.18-1-4.85-.99-1.32-.08-2.2-1.54-2.2-3.37v-10.57s14.09,0,14.09,0c1.27,0,2.28,1.27,2.34,3.15,0,2.05.58,4.07,1.72,5.79,3.85,5.77,8.76,10.4,15.33,13.26,1.52.66,3.18.99,4.85,1,1.32.08,2.22,1.5,2.22,3.32Z"/>
|
||||
<path fill="#ffffff" d="M83.66,32.91c-.01.15-.02.3-.02.46v7.34s-.01,1.27-.01,1.27c-.07,1.88-1.12,3.19-2.39,3.19l-10.64-.04c-1.4,0-3.39.01-3.39.01l-.02-10.65c0-1.82.89-3.27,2.21-3.36h3.95s1.51,0,1.51,0h6.83c.92,0,1.7.75,1.98,1.78h0Z"/>
|
||||
<path fill="#ffffff" d="M107.81,5.37l-.02,13.27c0,1.83-.85,3.33-2.17,3.41h-4s-1.34-.01-1.34-.01c-.09-.02-.19-.02-.28-.02h-6.81c-.9,0-1.67-.72-1.96-1.72.02-.16.03-.33.04-.51v-8.51c.06-1.89,1.12-3.22,2.39-3.22h9.73c2.94-.14,4.43-2.68,4.43-2.68h0Z"/>
|
||||
<path fill="#5e5f5f" d="M8.84,38.19c-.04-.99-.08-2.18-.08-3.37h-.04c-.26,1.05-.61,2.22-.94,3.18l-1.03,3.2h-1.49l-.9-3.18c-.28-.96-.57-2.13-.77-3.2h-.03c-.05,1.11-.09,2.38-.15,3.4l-.15,3.12h-1.77l.54-8.21h2.55l.83,2.74c.26.95.53,1.97.72,2.94h.04c.24-.95.53-2.03.8-2.95l.9-2.73h2.5l.46,8.21h-1.87l-.12-3.14h0Z"/>
|
||||
<path fill="#5e5f5f" d="M14.49,39.23l-.6,2.11h-1.98l2.59-8.21h2.51l2.62,8.21h-2.06l-.65-2.11h-2.42ZM16.63,37.84l-.53-1.74c-.15-.49-.3-1.1-.43-1.58h-.03c-.13.49-.25,1.11-.39,1.58l-.5,1.74h1.87Z"/>
|
||||
<path fill="#5e5f5f" d="M21.45,40.34l4.13-5.63v-.05h-4.13v-1.53h6.67v1.07l-4.04,5.55v.05h4.1v1.53h-6.74v-1h0Z"/>
|
||||
<path fill="#5e5f5f" d="M32.58,39.23l-.6,2.11h-1.98l2.59-8.21h2.51l2.62,8.21h-2.06l-.65-2.11h-2.42ZM34.73,37.84l-.53-1.74c-.15-.49-.3-1.1-.43-1.58h-.03c-.13.49-.25,1.11-.39,1.58l-.5,1.74h1.87Z"/>
|
||||
<path fill="#5e5f5f" d="M39.86,41.34v-3.36l-2.67-4.85h2.18l.87,1.97c.26.6.44,1.04.65,1.57h.03c.19-.51.39-.99.64-1.57l.85-1.97h2.15l-2.79,4.79v3.42h-1.91,0Z"/>
|
||||
<path fill="#5e5f5f" d="M46.54,39.23l-.6,2.11h-1.98l2.59-8.21h2.51l2.62,8.21h-2.06l-.65-2.11h-2.42ZM48.69,37.84l-.53-1.74c-.15-.49-.3-1.1-.43-1.58h-.03c-.13.49-.25,1.11-.39,1.58l-.5,1.74h1.87Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 55.02 94.61">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #5e5f5f;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #ec222e;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#5e5f5f" d="M16.59,77.8H4.53c-2.6,0-4.1-1.5-4.1-4v-11.65h3.4v9.88c0,1.65.41,2.52,2.91,2.52h9.85v3.25Z"/>
|
||||
<path fill="#5e5f5f" d="M18.09,81.83h-3.01v-2.81h3.01v2.81ZM22.89,73.12c0,1.29-.36,2.35-1.07,3.2-.8.99-1.92,1.48-3.32,1.48h-2.89v-3.25h2.16c1.14,0,1.72-.8,1.72-2.38v-5.36h3.4v6.31ZM22.67,81.83h-3.03v-2.81h3.03v2.81Z"/>
|
||||
<path fill="#5e5f5f" d="M28.76,77.8h-3.4v-15.65h3.4v15.65Z"/>
|
||||
<path fill="#5e5f5f" d="M41.48,77.8h-3.76c-.56,2.67-2.74,4-6.6,4v-2.74c2.23,0,3.35-1.07,3.35-3.2v-9.05h3.42v7.74h3.59v3.25ZM37.67,64.97h-3.01v-2.79h3.01v2.79Z"/>
|
||||
<path fill="#5e5f5f" d="M55.02,74.36c0,3.25-2.18,5.53-5.58,5.53-2.06,0-3.62-.7-4.61-2.09h-4.37v-3.25h3.37c0-3.47,1.99-5.68,5.48-5.68s5.7,2.16,5.7,5.48ZM51.64,74.48c0-1.92-.73-2.89-2.16-2.89h-.24c-1.24,0-2.09,1.14-2.09,2.74s.82,2.79,2.28,2.79c1.33,0,2.21-1.07,2.21-2.64Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect fill="#ffffff" y="0" width="55.02" height="55.02" rx="9.86" ry="9.86"/>
|
||||
<g>
|
||||
<path fill="#ec1f2d" d="M48.28,36.58l-.03,11.22-14.66-.04c-1.31,0-2.21-1.3-2.27-3.29h0c0-3.13-.93-6.2-2.76-8.73-3.46-4.77-8.33-8.59-14.67-11.43-1.56-.7-3.26-1.06-4.98-1.05h0c-1.35-.09-2.26-1.63-2.26-3.56v-11.19s14.46,0,14.46,0c1.31,0,2.34,1.34,2.4,3.33h0c0,2.17.59,4.31,1.77,6.13,3.95,6.11,9,11,15.74,14.03,1.56.7,3.26,1.05,4.98,1.06h0c1.35.09,2.28,1.58,2.28,3.52Z"/>
|
||||
<path fill="#ec1f2d" d="M23.56,34.82c-.01.16-.02.32-.02.48v7.77s-.02,1.34-.02,1.34c-.07,1.99-1.15,3.37-2.45,3.37l-10.92-.05c-1.44,0-3.48.01-3.48.01l-.02-11.27c0-1.93.91-3.46,2.26-3.55h4.05s.26,0,.26,0h1.3s7.01,0,7.01,0c.94,0,1.75.79,2.03,1.88Z"/>
|
||||
<path fill="#ec1f2d" d="M48.36,5.69l-.02,14.04c0,1.93-.88,3.52-2.23,3.61h-4.1s-1.38-.01-1.38-.01c-.1-.02-.19-.02-.29-.02h-6.99c-.93,0-1.72-.76-2.01-1.82.02-.17.03-.35.04-.54v-9.01c.06-2,1.15-3.41,2.45-3.41h9.99c3.02-.15,4.55-2.83,4.55-2.83Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#5e5f5f" d="M9.5,91.28c-.04-1.04-.08-2.31-.08-3.57h-.04c-.27,1.11-.63,2.35-.97,3.36l-1.06,3.39h-1.53l-.93-3.36c-.28-1.02-.58-2.26-.79-3.39h-.03c-.05,1.17-.09,2.51-.15,3.6l-.15,3.3h-1.82l.55-8.69h2.62l.85,2.9c.27,1.01.54,2.09.73,3.11h.04c.24-1.01.54-2.15.82-3.12l.93-2.89h2.56l.48,8.69h-1.92l-.13-3.32Z"/>
|
||||
<path fill="#5e5f5f" d="M15.3,92.38l-.62,2.23h-2.04l2.66-8.69h2.58l2.69,8.69h-2.11l-.67-2.23h-2.49ZM17.51,90.91l-.54-1.84c-.15-.52-.31-1.16-.44-1.68h-.03c-.13.52-.26,1.17-.4,1.68l-.52,1.84h1.92Z"/>
|
||||
<path fill="#5e5f5f" d="M22.45,93.55l4.24-5.95v-.05h-4.24v-1.62h6.85v1.13l-4.15,5.88v.05h4.21v1.62h-6.92v-1.06Z"/>
|
||||
<path fill="#5e5f5f" d="M33.88,92.38l-.62,2.23h-2.04l2.66-8.69h2.58l2.69,8.69h-2.11l-.67-2.23h-2.49ZM36.08,90.91l-.54-1.84c-.15-.52-.31-1.16-.44-1.68h-.03c-.13.52-.26,1.17-.4,1.68l-.52,1.84h1.92Z"/>
|
||||
<path fill="#5e5f5f" d="M41.35,94.61v-3.56l-2.75-5.13h2.24l.89,2.09c.27.63.45,1.1.67,1.66h.03c.19-.54.4-1.04.66-1.66l.88-2.09h2.2l-2.86,5.07v3.62h-1.96Z"/>
|
||||
<path fill="#5e5f5f" d="M48.21,92.38l-.62,2.23h-2.04l2.66-8.69h2.58l2.69,8.69h-2.11l-.67-2.23h-2.49ZM50.41,90.91l-.54-1.84c-.15-.52-.31-1.16-.44-1.68h-.03c-.13.52-.26,1.17-.4,1.68l-.52,1.84h1.92Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="map-pin">
|
||||
<path id="Vector" d="M9.9996 4.99997C9.9996 7.4967 7.23038 10.0969 6.30047 10.9C6.21384 10.9652 6.10839 11.0004 6 11.0004C5.89161 11.0004 5.78616 10.9652 5.69953 10.9C4.76962 10.0969 2.0004 7.4967 2.0004 4.99997C2.0004 3.93901 2.42179 2.9215 3.17186 2.17128C3.92193 1.42107 4.93924 0.9996 6 0.9996C7.06076 0.9996 8.07807 1.42107 8.82814 2.17128C9.57822 2.9215 9.9996 3.93901 9.9996 4.99997ZM7.49985 4.99997C7.49985 5.82847 6.82834 6.50011 6 6.50011C5.17166 6.50011 4.50015 5.82847 4.50015 4.99997C4.50015 4.17147 5.17166 3.49983 6 3.49983C6.82834 3.49983 7.49985 4.17147 7.49985 4.99997Z" stroke-linecap="round" stroke="#147D6B"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 826 B |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="search">
|
||||
<path id="Vector" d="M15.7501 15.7501L12.4951 12.4951M14.25 8.25C14.25 11.5637 11.5637 14.25 8.25 14.25C4.93629 14.25 2.25 11.5637 2.25 8.25C2.25 4.93629 4.93629 2.25 8.25 2.25C11.5637 2.25 14.25 4.93629 14.25 8.25Z" stroke-width="1.7" stroke-linecap="round" stroke="#78817F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="user-round">
|
||||
<path id="Vector" d="M11 11.9167C13.5311 11.9167 15.5829 9.86464 15.5829 7.33333C15.5829 4.80203 13.5311 2.75 11 2.75C8.46895 2.75 6.41712 4.80203 6.41712 7.33333C6.41712 9.86464 8.46895 11.9167 11 11.9167ZM11 11.9167C12.9447 11.9167 14.8098 12.6893 16.1849 14.0646C17.5601 15.4398 18.3326 17.3051 18.3326 19.25M11 11.9167C9.05527 11.9167 7.1902 12.6893 5.81507 14.0646C4.43994 15.4398 3.6674 17.3051 3.6674 19.25" stroke-width="1.7" stroke-linecap="round" stroke="#78817F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 674 B |
@ -0,0 +1,12 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_120_1007)">
|
||||
<path d="M13.125 5C13.125 3.27411 11.7259 1.875 10 1.875C8.27411 1.875 6.875 3.27411 6.875 5V10C6.875 11.7259 8.27411 13.125 10 13.125C11.7259 13.125 13.125 11.7259 13.125 10V5Z" stroke="#697586" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 15.625V18.75" stroke="#697586" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.625 10C15.625 11.4918 15.0324 12.9226 13.9775 13.9775C12.9226 15.0324 11.4918 15.625 10 15.625C8.50816 15.625 7.07742 15.0324 6.02252 13.9775C4.96763 12.9226 4.375 11.4918 4.375 10" stroke="#697586" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_120_1007">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.8744 6.13098L7.58175 10.4694L2.69958 7.41575C2.00006 6.97811 2.14558 5.91558 2.93679 5.6842L14.5284 2.28958C15.253 2.07722 15.9244 2.75462 15.7092 3.4815L12.2798 15.0651C12.0448 15.8574 10.9884 15.999 10.5549 15.2965L7.5795 10.4701" stroke="#0D121C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
|
Before Width: | Height: | Size: 778 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 794 B |
|
After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,51 @@
|
||||
class WalaPlusCityModel {
|
||||
int? cityId;
|
||||
String? cityNameAr;
|
||||
String? cityNameEn;
|
||||
String? countryNameAr;
|
||||
String? countryNameEn;
|
||||
|
||||
WalaPlusCityModel({
|
||||
this.cityId,
|
||||
this.cityNameAr,
|
||||
this.cityNameEn,
|
||||
this.countryNameAr,
|
||||
this.countryNameEn,
|
||||
});
|
||||
|
||||
factory WalaPlusCityModel.fromJson(Map<String, dynamic> json) {
|
||||
return WalaPlusCityModel(
|
||||
cityId:
|
||||
json['city_id'] is int
|
||||
? json['city_id']
|
||||
: int.tryParse(json['city_id']?.toString() ?? ''),
|
||||
cityNameAr: json['city_name_ar']?.toString(),
|
||||
cityNameEn: json['city_name_en']?.toString(),
|
||||
countryNameAr: json['country_name_ar']?.toString(),
|
||||
countryNameEn: json['country_name_en']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'city_id': cityId,
|
||||
'city_name_ar': cityNameAr,
|
||||
'city_name_en': cityNameEn,
|
||||
'country_name_ar': countryNameAr,
|
||||
'country_name_en': countryNameEn,
|
||||
};
|
||||
}
|
||||
|
||||
String getDisplayName(bool isAr) {
|
||||
String? primary = isAr ? cityNameAr : cityNameEn;
|
||||
if (primary != null && primary.trim().isNotEmpty) return primary.trim();
|
||||
String? fallback = isAr ? cityNameEn : cityNameAr;
|
||||
return fallback?.trim() ?? '';
|
||||
}
|
||||
|
||||
bool get isAllCities {
|
||||
String en = (cityNameEn ?? '').trim().toLowerCase();
|
||||
String ar = (cityNameAr ?? '').trim();
|
||||
return en == 'all cities' || ar == 'جميع المدن';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,499 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:mohem_flutter_app/api/dashboard_api_client.dart';
|
||||
import 'package:mohem_flutter_app/app_state/app_state.dart';
|
||||
import 'package:mohem_flutter_app/classes/colors.dart';
|
||||
import 'package:mohem_flutter_app/classes/utils.dart';
|
||||
import 'package:mohem_flutter_app/extensions/widget_extensions.dart';
|
||||
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
|
||||
import 'package:mohem_flutter_app/models/generic_response_model.dart';
|
||||
import 'package:speech_to_text/speech_recognition_result.dart';
|
||||
import 'package:speech_to_text/speech_to_text.dart';
|
||||
|
||||
|
||||
class MessagesHolder{
|
||||
static List<ChatMessage> messages = [];
|
||||
}
|
||||
|
||||
enum ChatMode { home, chatActive }
|
||||
|
||||
class ChatMessage {
|
||||
final String text;
|
||||
final bool isUser;
|
||||
final DateTime timestamp;
|
||||
|
||||
ChatMessage({required this.text, required this.isUser, required this.timestamp});
|
||||
}
|
||||
|
||||
class ChatBotScreen extends StatefulWidget {
|
||||
const ChatBotScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ChatBotScreen> createState() => _ChatBotScreenState();
|
||||
}
|
||||
|
||||
class _ChatBotScreenState extends State<ChatBotScreen> {
|
||||
ChatMode _currentMode = ChatMode.home;
|
||||
final List<ChatMessage> _messages = <ChatMessage>[];
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
AtlasInitiateChatResponse? atlasChatTokenResponse;
|
||||
bool _isThinking = false;
|
||||
|
||||
// Speech to text
|
||||
final SpeechToText _speechToText = SpeechToText();
|
||||
bool _speechEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// TODO: implement initState
|
||||
super.initState();
|
||||
getChatSession();
|
||||
_initSpeech();
|
||||
}
|
||||
|
||||
/// Initialize speech to text
|
||||
void _initSpeech() async {
|
||||
_speechEnabled = await _speechToText.initialize();
|
||||
|
||||
if(MessagesHolder.messages.isNotEmpty){
|
||||
setState(() {
|
||||
_messages.addAll(MessagesHolder.messages);
|
||||
_currentMode = ChatMode.chatActive;
|
||||
});
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Start listening for speech
|
||||
void _startListening() async {
|
||||
await _speechToText.listen(
|
||||
onResult: _onSpeechResult,
|
||||
localeId: AppState().isArabic(context) ? 'ar_SA' : 'en_US',
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Stop listening for speech
|
||||
void _stopListening() async {
|
||||
await _speechToText.stop();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Callback when speech is recognized
|
||||
void _onSpeechResult(SpeechRecognitionResult result) {
|
||||
setState(() {
|
||||
_messageController.text = result.recognizedWords;
|
||||
// Move cursor to end of text
|
||||
_messageController.selection = TextSelection.fromPosition(TextPosition(offset: _messageController.text.length));
|
||||
});
|
||||
}
|
||||
|
||||
void getChatSession() async {
|
||||
try {
|
||||
GenericResponseModel? res = await DashboardApiClient().getChatBotSession();
|
||||
if (res != null) {
|
||||
setState(() {
|
||||
atlasChatTokenResponse = res.atlasInitiateChatResponse;
|
||||
});
|
||||
}
|
||||
} catch (ex) {
|
||||
Utils.handleException(ex, context, null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
atlasChatTokenResponse = null;
|
||||
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendMessage({String? suggestionText}) async {
|
||||
String text = suggestionText ?? _messageController.text.trim();
|
||||
if (text.isEmpty || atlasChatTokenResponse == null) return;
|
||||
try {
|
||||
setState(() {
|
||||
_messages.add(ChatMessage(text: text, isUser: true, timestamp: DateTime.now()));
|
||||
_currentMode = ChatMode.chatActive;
|
||||
_messageController.clear();
|
||||
_isThinking = true;
|
||||
});
|
||||
_scrollToBottom();
|
||||
GenericResponseModel? res = await DashboardApiClient().sendChatBotMessage(atlasChatTokenResponse: atlasChatTokenResponse!, atlasChatText: text);
|
||||
if (res != null) {
|
||||
res.atlasContinueChatResponseDetails;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isThinking = false;
|
||||
_messages.add(
|
||||
ChatMessage(
|
||||
text: res.atlasContinueChatResponseDetails!.aiResponse!.structuredData!.answer ?? "",
|
||||
isUser: false,
|
||||
timestamp: res.atlasContinueChatResponseDetails!.aiResponse!.createdAt ?? DateTime.now(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
_scrollToBottom();
|
||||
}
|
||||
} catch (ex) {
|
||||
setState(() {
|
||||
_isThinking = false;
|
||||
});
|
||||
Utils.handleException(ex, context, null);
|
||||
}
|
||||
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
Future<void>.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _sendSuggestion(String text) {
|
||||
if (atlasChatTokenResponse == null) {
|
||||
Utils.showToast(LocaleKeys.initializingChatPleaseWait.tr());
|
||||
return;
|
||||
}
|
||||
_sendMessage(suggestionText: text);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: MyColors.backgroundColor,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(LocaleKeys.chatBot.tr(), style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF2B353E))),
|
||||
backgroundColor: MyColors.backgroundColor,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.minimize, color: Color(0xFF2B353E)),
|
||||
onPressed: () {
|
||||
MessagesHolder.messages = _messages;
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Color(0xFF2B353E)),
|
||||
onPressed: () {
|
||||
MessagesHolder.messages.clear();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(children: <Widget>[Expanded(child: _currentMode == ChatMode.home ? _buildHomeMode() : _buildChatMode()), _buildBottomInputBar()]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomeMode() {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(LocaleKeys.helloAskMe.tr(), textAlign: TextAlign.center, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF2E303A))),
|
||||
Text(LocaleKeys.anything.tr(), textAlign: TextAlign.center, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF2E303A))),
|
||||
const SizedBox(height: 50),
|
||||
const Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 12,
|
||||
children: <Widget>[
|
||||
// _buildSuggestionChip("Nostalgia Perfume", () => _sendSuggestion("Nostalgia Perfume")),
|
||||
// _buildSuggestionChip("Al Nafoura", () => _sendSuggestion("Al Nafoura")),
|
||||
// _buildSuggestionChip("Al Nafoura", () => _sendSuggestion("Al Nafoura")),
|
||||
// _buildSuggestionChip("Al Jadi", () => _sendSuggestion("Al Jadi")),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatMode() {
|
||||
return ListView.separated(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
itemCount: _messages.length + (_isThinking ? 1 : 0),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (index == _messages.length && _isThinking) {
|
||||
return _buildThinkingIndicator();
|
||||
}
|
||||
ChatMessage message = _messages[index];
|
||||
return _buildMessageBubble(message);
|
||||
},
|
||||
separatorBuilder: (BuildContext context, int index) {
|
||||
return const SizedBox(height: 15);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThinkingIndicator() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: <Color>[Color(0xFF32D892), Color(0xFF259CB8)], begin: Alignment.centerLeft, end: Alignment.centerRight),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
boxShadow: <BoxShadow>[BoxShadow(color: const Color(0xFF000000).withValues(alpha: 0.0784), offset: const Offset(0, 0), blurRadius: 13, spreadRadius: 0)],
|
||||
),
|
||||
child: const ThinkingDotsAnimation(),
|
||||
),
|
||||
const Spacer(flex: 2),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ChatMessage message) {
|
||||
bool isUser = message.isUser;
|
||||
Widget bubbleContent = Row(
|
||||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (isUser) const Spacer(flex: 2),
|
||||
Flexible(
|
||||
flex: 7,
|
||||
child: Column(
|
||||
crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? Colors.white : null,
|
||||
gradient: isUser ? null : const LinearGradient(colors: <Color>[Color(0xFF32D892), Color(0xFF259CB8)], begin: Alignment.centerLeft, end: Alignment.centerRight),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
boxShadow: isUser ? null : <BoxShadow>[BoxShadow(color: const Color(0xFF000000).withValues(alpha: 0.0784), offset: const Offset(0, 0), blurRadius: 13, spreadRadius: 0)],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(message.text, softWrap: true, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: isUser ? const Color(0xFF2B353E) : MyColors.white)),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
_formatTimestamp(message.timestamp),
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: -0.4, color: isUser ? const Color(0xFF2B353E) : MyColors.whiteColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isUser) const Spacer(flex: 2),
|
||||
],
|
||||
);
|
||||
|
||||
if (!isUser) {
|
||||
return AnimatedMessageBubble(child: bubbleContent);
|
||||
}
|
||||
return bubbleContent;
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
int hourInt = timestamp.hour;
|
||||
int minute = timestamp.minute;
|
||||
String period = hourInt >= 12 ? LocaleKeys.pm.tr() : LocaleKeys.am.tr();
|
||||
int formattedHour = hourInt % 12 == 0 ? 12 : hourInt % 12;
|
||||
String hour = formattedHour.toString().padLeft(2, '0');
|
||||
String min = minute.toString().padLeft(2, '0');
|
||||
String month = timestamp.month.toString().padLeft(2, '0');
|
||||
String day = timestamp.day.toString().padLeft(2, '0');
|
||||
int year = timestamp.year;
|
||||
return "$month/$day/$year $hour:$min $period";
|
||||
}
|
||||
|
||||
Widget _buildSuggestionChip(String label, VoidCallback onTap) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
elevation: 3,
|
||||
shadowColor: const Color(0xFF000015).withValues(alpha: 0.0784),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: MyColors.darkTextColor, letterSpacing: 0.2)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomInputBar() {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(color: MyColors.backgroundColor),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
// Plus button
|
||||
// IconButton(padding: EdgeInsets.zero, icon: const Icon(Icons.add, color: MyColors.darkTextColor, size: 34), onPressed: () {}),
|
||||
// const SizedBox(width: 12),
|
||||
// Text field
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(color: Colors.transparent, borderRadius: BorderRadius.circular(10), border: BoxBorder.all(width: 1, color: const Color(0xFFE5E5E5))),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
onSubmitted: _isThinking ? null : (_) => _sendMessage(),
|
||||
enabled: !_isThinking,
|
||||
maxLines: 1,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: _isThinking ? LocaleKeys.waitingForResponse.tr() : LocaleKeys.typeAMessage.tr(),
|
||||
hintStyle: const TextStyle(color: MyColors.hintTextColor, fontSize: 14, fontWeight: FontWeight.normal),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _isThinking ? null : (_speechEnabled ? (_speechToText.isNotListening ? _startListening : _stopListening) : null),
|
||||
child: SvgPicture.asset(
|
||||
"assets/icons/microphone.svg",
|
||||
colorFilter: ColorFilter.mode(_isThinking ? MyColors.hintTextColor : (_speechToText.isListening ? Colors.red : MyColors.darkTextColor), BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// Send button
|
||||
GestureDetector(
|
||||
onTap: _isThinking ? null : _sendMessage,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(color: _isThinking ? MyColors.hintTextColor.withValues(alpha: 0.3) : MyColors.white, shape: BoxShape.circle),
|
||||
child: Center(
|
||||
child: SvgPicture.asset(
|
||||
"assets/icons/send.svg",
|
||||
matchTextDirection: true,
|
||||
colorFilter: _isThinking ? const ColorFilter.mode(MyColors.hintTextColor, BlendMode.srcIn) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).paddingOnly(left: 16, right: 16, bottom: 16);
|
||||
}
|
||||
}
|
||||
|
||||
class ThinkingDotsAnimation extends StatefulWidget {
|
||||
const ThinkingDotsAnimation({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ThinkingDotsAnimation> createState() => _ThinkingDotsAnimationState();
|
||||
}
|
||||
|
||||
class _ThinkingDotsAnimationState extends State<ThinkingDotsAnimation> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(LocaleKeys.thinking.tr(), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
const SizedBox(width: 2),
|
||||
AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
int dotCount = (_controller.value * 4).floor() % 4;
|
||||
String dots = '.' * dotCount;
|
||||
return SizedBox(width: 18, child: Text(dots, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white)));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedMessageBubble extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const AnimatedMessageBubble({Key? key, required this.child}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AnimatedMessageBubble> createState() => _AnimatedMessageBubbleState();
|
||||
}
|
||||
|
||||
class _AnimatedMessageBubbleState extends State<AnimatedMessageBubble> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
|
||||
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
|
||||
_slideAnimation = Tween<Offset>(begin: const Offset(-0.3, 0.0), end: Offset.zero).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
|
||||
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(opacity: _fadeAnimation, child: ScaleTransition(scale: _scaleAnimation, alignment: Alignment.centerLeft, child: widget.child)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,970 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:mohem_flutter_app/api/login_api_client.dart';
|
||||
import 'package:mohem_flutter_app/api/offers_and_discounts_api_client.dart';
|
||||
import 'package:mohem_flutter_app/app_state/app_state.dart';
|
||||
import 'package:mohem_flutter_app/classes/colors.dart';
|
||||
import 'package:mohem_flutter_app/classes/consts.dart';
|
||||
import 'package:mohem_flutter_app/classes/utils.dart';
|
||||
import 'package:mohem_flutter_app/config/routes.dart';
|
||||
import 'package:mohem_flutter_app/extensions/int_extensions.dart';
|
||||
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
|
||||
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
|
||||
import 'package:mohem_flutter_app/main.dart';
|
||||
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_category_model.dart';
|
||||
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart';
|
||||
import 'package:mohem_flutter_app/widgets/info_chip_widget.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class DashboardScreenForDependet extends StatefulWidget {
|
||||
const DashboardScreenForDependet({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DashboardScreenForDependet> createState() => _DashboardScreenForDependetState();
|
||||
}
|
||||
|
||||
class _DashboardScreenForDependetState extends State<DashboardScreenForDependet> {
|
||||
List<WalaPlusRetailerModel> retailersList = [];
|
||||
List<WalaPlusRetailerModel> _filteredRetailersList = [];
|
||||
List<WalaPlusCategoryModel> categoriesList = [];
|
||||
String currentCategoryId = "";
|
||||
String selectedTypeFilter = "all";
|
||||
bool isNearestOffers = true;
|
||||
bool isLoading = true;
|
||||
bool isLoadingMore = false;
|
||||
bool isCategoriesLoading = true;
|
||||
bool hasMore = true;
|
||||
bool _isFetchingPage = false;
|
||||
int currentPage = 1;
|
||||
int totalPages = 1;
|
||||
final int pageSize = 10;
|
||||
String searchQuery = "";
|
||||
Timer? _debounceTimer;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _hasRegisteredMobileLoginInfo = false;
|
||||
|
||||
List<String> get _availableTypeFilters {
|
||||
Set<String> types = <String>{};
|
||||
for (WalaPlusRetailerModel r in retailersList) {
|
||||
String? t = r.normalizedType;
|
||||
if (t != null && t.isNotEmpty) {
|
||||
types.add(t);
|
||||
}
|
||||
}
|
||||
List<String> list = <String>['all', 'instore', 'online'];
|
||||
for (String t in types) {
|
||||
if (!list.contains(t)) {
|
||||
list.add(t);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
String _getTypeFilterTitle(String type) {
|
||||
if (type == 'all') {
|
||||
return LocaleKeys.allCategories.tr();
|
||||
} else if (type == 'instore') {
|
||||
return LocaleKeys.inStore.tr();
|
||||
} else if (type == 'online') {
|
||||
return LocaleKeys.online.tr();
|
||||
}
|
||||
return type.isNotEmpty ? (type[0].toUpperCase() + type.substring(1)) : type;
|
||||
}
|
||||
|
||||
IconData _getTypeFilterIcon(String type) {
|
||||
if (type == 'instore') {
|
||||
return Icons.storefront_rounded;
|
||||
} else if (type == 'online') {
|
||||
return Icons.language_rounded;
|
||||
}
|
||||
return Icons.tune_rounded;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_scrollListener);
|
||||
_loadDependentName();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (mounted) {
|
||||
await _registerMobileLoginInfo();
|
||||
await fetchCategories();
|
||||
await fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _registerMobileLoginInfo() async {
|
||||
if (_hasRegisteredMobileLoginInfo) return;
|
||||
if (!ApiConsts.isDependetLogin) return;
|
||||
_hasRegisteredMobileLoginInfo = true;
|
||||
|
||||
try {
|
||||
int loginType = AppState().dependentLoginType ?? 1;
|
||||
String nationalId = AppState().getUserName ?? "";
|
||||
String dob = AppState().dependentDob ?? "";
|
||||
String name = AppState().dependentName ?? "";
|
||||
String mobile = AppState().postParamsObject?.mobileNumber ?? "";
|
||||
String tokenID =
|
||||
(AppState().postParamsObject?.tokenID != null && AppState().postParamsObject!.tokenID!.isNotEmpty) ? AppState().postParamsObject!.tokenID! : (AppState().authenticationTokenID ?? "");
|
||||
if (tokenID.isEmpty) {
|
||||
tokenID = await Utils.getStringFromPrefs("P_DEP_TOKEN_ID");
|
||||
}
|
||||
int sessionId = AppState().postParamsObject?.pSessionId ?? 0;
|
||||
String email = AppState().postParamsObject?.pEmailAddress ?? "";
|
||||
String deviceToken = AppState().getIsHuawei ? AppState().getHuaweiPushToken : (AppState().getDeviceToken ?? "");
|
||||
|
||||
await LoginApiClient().insertMobileLoginInfoDependent(
|
||||
loginType: loginType,
|
||||
nationalId: nationalId,
|
||||
dob: dob,
|
||||
dependentName: name,
|
||||
mobileNumber: mobile,
|
||||
tokenID: tokenID,
|
||||
sessionId: sessionId,
|
||||
email: email,
|
||||
deviceToken: deviceToken,
|
||||
context: context,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print("Error calling insertMobileLoginInfoDependent: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _loadDependentName() {
|
||||
if (AppState().dependentName != null && AppState().dependentName!.isNotEmpty && mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_scrollController.removeListener(_scrollListener);
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollListener() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
double maxScroll = _scrollController.position.maxScrollExtent;
|
||||
double currentScroll = _scrollController.position.pixels;
|
||||
// Trigger only when user scrolls to within 35px of the very bottom
|
||||
if (currentScroll >= maxScroll - 35) {
|
||||
if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchCategories() async {
|
||||
setState(() {
|
||||
isCategoriesLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
List<WalaPlusCategoryModel> list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context);
|
||||
if (list.isEmpty) {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
List<WalaPlusCategoryModel> fullList = [];
|
||||
bool hasAll = list.any((c) => (c.id == "all" || c.name?.toLowerCase() == "all" || c.name == "الجميع" || c.name == "الكل"));
|
||||
if (!hasAll) {
|
||||
fullList.add(WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr()));
|
||||
}
|
||||
fullList.addAll(list);
|
||||
|
||||
setState(() {
|
||||
categoriesList = fullList;
|
||||
if (currentCategoryId.isEmpty) {
|
||||
currentCategoryId = "all";
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error fetching WalaPlus categories (attempt 1): $e");
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
List<WalaPlusCategoryModel> retryList = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context);
|
||||
if (mounted && retryList.isNotEmpty) {
|
||||
List<WalaPlusCategoryModel> fullList = [];
|
||||
bool hasAll = retryList.any((c) => (c.id == "all" || c.name?.toLowerCase() == "all" || c.name == "الجميع" || c.name == "الكل"));
|
||||
if (!hasAll) {
|
||||
fullList.add(WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr()));
|
||||
}
|
||||
fullList.addAll(retryList);
|
||||
setState(() {
|
||||
categoriesList = fullList;
|
||||
if (currentCategoryId.isEmpty) {
|
||||
currentCategoryId = "all";
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (retryEx) {
|
||||
debugPrint("Error fetching WalaPlus categories (attempt 2): $retryEx");
|
||||
}
|
||||
|
||||
if (mounted && categoriesList.isEmpty) {
|
||||
setState(() {
|
||||
categoriesList = [WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr())];
|
||||
if (currentCategoryId.isEmpty) {
|
||||
currentCategoryId = "all";
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isCategoriesLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchOffers({String? categoryId, String? query}) async {
|
||||
String targetCategoryId = categoryId ?? (currentCategoryId.isEmpty ? "all" : currentCategoryId);
|
||||
if (query != null) {
|
||||
searchQuery = query;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
});
|
||||
|
||||
try {
|
||||
String catParam = (targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId;
|
||||
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
|
||||
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
|
||||
print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', query='$queryParam', page=1, per_page=$pageSize");
|
||||
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
|
||||
categoryId: catParam,
|
||||
page: 1,
|
||||
perPage: pageSize,
|
||||
offerType: offerTypeParam,
|
||||
query: queryParam,
|
||||
context: isNearestOffers ? context : null,
|
||||
);
|
||||
|
||||
retailersList = response?.retailers ?? <WalaPlusRetailerModel>[];
|
||||
_filteredRetailersList = retailersList;
|
||||
currentPage = 1;
|
||||
totalPages = response?.pagesCount ?? 1;
|
||||
hasMore = currentPage < totalPages && (response?.retailers?.isNotEmpty ?? false);
|
||||
} catch (e) {
|
||||
print("Error fetching WalaPlus retailers: $e");
|
||||
retailersList = <WalaPlusRetailerModel>[];
|
||||
_filteredRetailersList = <WalaPlusRetailerModel>[];
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchNextPage() async {
|
||||
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
|
||||
|
||||
_isFetchingPage = true;
|
||||
setState(() {
|
||||
isLoadingMore = true;
|
||||
});
|
||||
|
||||
try {
|
||||
int nextPage = currentPage + 1;
|
||||
String catParam = (currentCategoryId == "all" || currentCategoryId == "0") ? "" : currentCategoryId;
|
||||
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
|
||||
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
|
||||
|
||||
print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam'");
|
||||
|
||||
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
|
||||
categoryId: catParam,
|
||||
page: nextPage,
|
||||
perPage: pageSize,
|
||||
offerType: offerTypeParam,
|
||||
query: queryParam,
|
||||
context: isNearestOffers ? context : null,
|
||||
);
|
||||
|
||||
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? [];
|
||||
if (newRetailers.isNotEmpty) {
|
||||
currentPage = nextPage;
|
||||
totalPages = response?.pagesCount ?? totalPages;
|
||||
hasMore = currentPage < totalPages;
|
||||
retailersList.addAll(newRetailers);
|
||||
_filteredRetailersList = retailersList;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error fetching next page of WalaPlus retailers: $e");
|
||||
} finally {
|
||||
_isFetchingPage = false;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
await Future.wait<dynamic>(<Future<void>>[fetchCategories(), fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId, query: _searchController.text)]);
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
_debounceTimer?.cancel();
|
||||
searchQuery = query;
|
||||
if (query.trim().isEmpty) {
|
||||
fetchOffers(query: "");
|
||||
} else {
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 1000), () {
|
||||
fetchOffers(query: query.trim());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _openRetailer(WalaPlusRetailerModel item) {
|
||||
Navigator.pushNamed(context, AppRoutes.walaPlusRetailerDetails, arguments: item);
|
||||
}
|
||||
|
||||
void _performLogout() {
|
||||
Utils.confirmDialog(
|
||||
context,
|
||||
LocaleKeys.areYouSureWantToLogOut.tr(),
|
||||
onTap: () {
|
||||
ApiConsts.selectedMemberType = MemberType.employee;
|
||||
ApiConsts.ISDependetLogin = false;
|
||||
AppState().setPostParamsInitConfig();
|
||||
Navigator.pushNamedAndRemoveUntil(context, AppRoutes.login, (Route<dynamic> route) => false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isAr = AppState().isArabic(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () {
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
_performLogout();
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Icon(isAr ? Icons.arrow_forward_ios_rounded : Icons.arrow_back_ios_new_rounded, size: 20, color: const Color(0xFF1E293B)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SvgPicture.asset("assets/icons/mazaya_brand.svg", height: 48, fit: BoxFit.contain),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!ApiConsts.isDependetLogin) ...[
|
||||
IconButton(
|
||||
icon: Image.asset(
|
||||
'assets/icons/ic_add_dependents.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: const Color(0xFF1E293B),
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) =>
|
||||
SvgPicture.asset('assets/icons/ic_dependents_top.svg', width: 24, height: 24, colorFilter: const ColorFilter.mode(Color(0xFF1E293B), BlendMode.srcIn)),
|
||||
),
|
||||
tooltip: LocaleKeys.addDependents.tr(),
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, AppRoutes.familyMembers, arguments: {'fromMazaya': true});
|
||||
},
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_rounded, size: 26, color: Color(0xFF1E293B)),
|
||||
onPressed: () {
|
||||
if (ApiConsts.isDependetLogin) {
|
||||
_onRefresh();
|
||||
} else {
|
||||
Navigator.popUntil(context, ModalRoute.withName(AppRoutes.dashboard));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: MyColors.textMixColor,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Search Input Card
|
||||
_buildSearchBar(isAr),
|
||||
|
||||
// "Browse Categories" Header
|
||||
_buildBrowseCategoriesHeader(isAr),
|
||||
|
||||
// Categories Section
|
||||
if (categoriesList.isNotEmpty) _buildCategoriesList(isAr) else _buildCategoriesShimmer(),
|
||||
|
||||
// Type Filter Chips (All, In Store, Online)
|
||||
_buildTypeFilterChips(isAr),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Offers Grid
|
||||
if (isLoading)
|
||||
_buildRetailersShimmer()
|
||||
else if (_filteredRetailersList.isNotEmpty) ...[
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, childAspectRatio: 0.7, crossAxisSpacing: 12, mainAxisSpacing: 16),
|
||||
itemCount: _filteredRetailersList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildOfferCard(_filteredRetailersList[index], isAr);
|
||||
},
|
||||
),
|
||||
if (isLoadingMore) ...[
|
||||
16.height,
|
||||
Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 12), child: Image.asset("assets/images/logos/loading_mohemm_logo.gif", height: 48.0, width: 48.0))),
|
||||
],
|
||||
] else
|
||||
Padding(padding: const EdgeInsets.only(top: 40), child: Center(child: LocaleKeys.noOffersAvailable.tr().toText14(color: const Color(0xFF64748B)))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(bool isAr) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 70,
|
||||
padding: const EdgeInsets.only(top: 14, right: 16, bottom: 14, left: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFFFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE8E8E8), width: 1.0),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF000000).withOpacity(0.0314), offset: const Offset(0, 2), blurRadius: 5, spreadRadius: 0)],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
LocaleKeys.search.tr().toText12(isBold: true, color: const Color(0xFF2A333C)),
|
||||
const SizedBox(height: 2),
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (String val) {
|
||||
_debounceTimer?.cancel();
|
||||
fetchOffers(query: val.trim());
|
||||
},
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: LocaleKeys.searchItems.tr(),
|
||||
hintStyle: const TextStyle(color: Color(0xFFACAFB1), fontSize: 14, fontWeight: FontWeight.w400, height: 1.2),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
style: const TextStyle(color: Color(0xFF2A333C), fontSize: 14, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBrowseCategoriesHeader(bool isAr) {
|
||||
return Padding(padding: const EdgeInsets.only(top: 22, bottom: 14), child: LocaleKeys.browseCategories.tr().toText18(isBold: true, color: const Color(0xFF1E293B)));
|
||||
}
|
||||
|
||||
Widget _buildCategoriesList(bool isAr) {
|
||||
return SizedBox(
|
||||
height: 136.0,
|
||||
width: double.infinity,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: categoriesList.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 14),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
WalaPlusCategoryModel item = categoriesList[index];
|
||||
bool isSelected = (item.id == "all" && (currentCategoryId == "all" || currentCategoryId.isEmpty)) || (currentCategoryId.isNotEmpty && currentCategoryId == item.id);
|
||||
String title = (item.id == "all") ? LocaleKeys.allCategories.tr() : (item.name ?? "");
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () {
|
||||
if (currentCategoryId != item.id) {
|
||||
setState(() {
|
||||
currentCategoryId = item.id ?? "";
|
||||
});
|
||||
fetchOffers(categoryId: currentCategoryId);
|
||||
}
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 84,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
_buildCategoryCard(item, isSelected),
|
||||
const SizedBox(height: 8),
|
||||
title.toText13(
|
||||
// isBold: true,
|
||||
color: const Color(0xFF1E293B),
|
||||
maxlines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeFilterChips(bool isAr) {
|
||||
List<String> types = _availableTypeFilters;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 14, bottom: 2),
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: types.length,
|
||||
separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 8),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
String typeKey = types[index];
|
||||
bool isSelected = selectedTypeFilter == typeKey;
|
||||
String title = _getTypeFilterTitle(typeKey);
|
||||
IconData icon = _getTypeFilterIcon(typeKey);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () {
|
||||
if (selectedTypeFilter != typeKey) {
|
||||
setState(() {
|
||||
selectedTypeFilter = typeKey;
|
||||
});
|
||||
fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: isSelected ? MyColors.darkIconColor : MyColors.lightGreyEAColor),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 14, color: isSelected ? MyColors.white : MyColors.black),
|
||||
const SizedBox(width: 5),
|
||||
title.toText12(color: isSelected ? MyColors.white : MyColors.black),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryCard(WalaPlusCategoryModel item, bool isSelected) {
|
||||
const double cardSize = 84.0;
|
||||
const double borderRadiusVal = 18.0;
|
||||
const double borderWidthVal = 2.8;
|
||||
const Color activeBorderColor = Color(0xFF00A86B);
|
||||
|
||||
bool isAll = (item.id == "all" || item.id == "0" || (item.name ?? "").toLowerCase() == "all" || item.name == "الجميع" || item.name == "الكل");
|
||||
|
||||
Widget imageContent;
|
||||
|
||||
if (isAll) {
|
||||
imageContent = Image.asset('assets/images/cat_all.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _getFallbackCategoryAsset(item));
|
||||
} else {
|
||||
String categoryImageUrl = item.categoryImageUrl.isNotEmpty ? item.categoryImageUrl : (item.image?.trim() ?? "");
|
||||
|
||||
if (categoryImageUrl.isNotEmpty) {
|
||||
if (categoryImageUrl.toLowerCase().endsWith('.svg') || item.isSvg) {
|
||||
imageContent = SvgPicture.network(
|
||||
categoryImageUrl,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholderBuilder: (_) => const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00A86B)))),
|
||||
);
|
||||
} else {
|
||||
imageContent = Image.network(categoryImageUrl, width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _getFallbackCategoryAsset(item));
|
||||
}
|
||||
} else {
|
||||
imageContent = _getFallbackCategoryAsset(item);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(borderRadiusVal),
|
||||
border: isSelected ? Border.all(color: activeBorderColor, width: borderWidthVal) : null,
|
||||
),
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(isSelected ? (borderRadiusVal - borderWidthVal) : borderRadiusVal), child: imageContent),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getFallbackCategoryAsset(WalaPlusCategoryModel item) {
|
||||
String name = (item.name ?? "").toLowerCase();
|
||||
|
||||
if (item.id == "all" || item.id == "0" || name == "all" || name == "الكل" || name == "الجميع") {
|
||||
return Image.asset('assets/images/cat_all.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
|
||||
} else if (name.contains("shop") || name.contains("retail") || name.contains("تسوق") || name.contains("تجزئة")) {
|
||||
return Image.asset('assets/images/cat_retail.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
|
||||
} else if (name.contains("food") || name.contains("restaurant") || name.contains("مطاعم") || name.contains("أغذية") || name.contains("cafe") || name.contains("مقهى")) {
|
||||
return Image.asset('assets/images/cat_food.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
|
||||
} else if (name.contains("fitness") || name.contains("wellness") || name.contains("sport") || name.contains("gym") || name.contains("رياضة") || name.contains("لياقة") || name.contains("صحة")) {
|
||||
return Image.asset('assets/images/cat_fitness.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
|
||||
}
|
||||
|
||||
return _buildFallbackCategoryIconWidget(item);
|
||||
}
|
||||
|
||||
Widget _buildFallbackCategoryIconWidget(WalaPlusCategoryModel item) {
|
||||
return Container(color: Colors.transparent, alignment: Alignment.center, child: _getFallbackCategoryIcon(item, const Color(0xFF168A89)));
|
||||
}
|
||||
|
||||
Widget _getFallbackCategoryIcon(WalaPlusCategoryModel item, Color iconColor) {
|
||||
String name = (item.name ?? "").toLowerCase();
|
||||
IconData iconData = Icons.grid_view_rounded;
|
||||
Color color = iconColor;
|
||||
if (item.id == "all" || name == "all" || name == "الكل" || name == "الجميع") {
|
||||
iconData = Icons.confirmation_number_outlined;
|
||||
color = const Color(0xFFE52332);
|
||||
} else if (name.contains("finance") || name.contains("بنوك") || name.contains("مالي")) {
|
||||
iconData = Icons.account_balance_rounded;
|
||||
} else if (name.contains("entertain") || name.contains("activities") || name.contains("أنشطة") || name.contains("ترفيه")) {
|
||||
iconData = Icons.theater_comedy_rounded;
|
||||
} else if (name.contains("educat") || name.contains("تعليم")) {
|
||||
iconData = Icons.menu_book_rounded;
|
||||
} else if (name.contains("shop") || name.contains("retail") || name.contains("تسوق") || name.contains("تجزئة")) {
|
||||
iconData = Icons.shopping_bag_outlined;
|
||||
} else if (name.contains("health") || name.contains("medical") || name.contains("صحة") || name.contains("طبي")) {
|
||||
iconData = Icons.health_and_safety_outlined;
|
||||
} else if (name.contains("beauty") || name.contains("جمال") || name.contains("عناية")) {
|
||||
iconData = Icons.face_retouching_natural_rounded;
|
||||
} else if (name.contains("fitness") || name.contains("sport") || name.contains("gym") || name.contains("رياضة") || name.contains("لياقة")) {
|
||||
iconData = Icons.fitness_center_rounded;
|
||||
} else if (name.contains("hotel") || name.contains("travel") || name.contains("فندق") || name.contains("فنادق") || name.contains("سفر")) {
|
||||
iconData = Icons.hotel_rounded;
|
||||
} else if (name.contains("service") || name.contains("خدمات")) {
|
||||
iconData = Icons.build_circle_outlined;
|
||||
} else if (name.contains("food") || name.contains("restaurant") || name.contains("مطاعم") || name.contains("أغذية")) {
|
||||
iconData = Icons.restaurant_rounded;
|
||||
} else if (name.contains("home") || name.contains("منزل")) {
|
||||
iconData = Icons.home_rounded;
|
||||
}
|
||||
|
||||
return Icon(iconData, color: color, size: 26);
|
||||
}
|
||||
|
||||
String _getDiscountBadge(WalaPlusRetailerModel item) {
|
||||
if (item.offerTitle != null) {
|
||||
RegExp percentRegex = RegExp(r'(\d+%)');
|
||||
Match? match = percentRegex.firstMatch(item.offerTitle!);
|
||||
if (match != null) {
|
||||
return match.group(1)!;
|
||||
}
|
||||
}
|
||||
if (item.name != null) {
|
||||
RegExp percentRegex = RegExp(r'(\d+%)');
|
||||
Match? match = percentRegex.firstMatch(item.name!);
|
||||
if (match != null) {
|
||||
return match.group(1)!;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
Widget _buildMerchantMIcon() {
|
||||
return Container(color: const Color(0xFFED1C24), alignment: Alignment.center, child: "M".toText10(isBold: true, color: Colors.white));
|
||||
}
|
||||
|
||||
Widget _buildOfferCard(WalaPlusRetailerModel item, bool isAr) {
|
||||
String? bannerUrl = (item.imageThumb != null && item.imageThumb!.isNotEmpty) ? item.imageThumb : item.image;
|
||||
String? logoUrl = (item.logoThumb != null && item.logoThumb!.isNotEmpty) ? item.logoThumb : item.logo;
|
||||
|
||||
String discountText = _getDiscountBadge(item);
|
||||
|
||||
String offerSubtitle = "";
|
||||
if (discountText.isNotEmpty) {
|
||||
if (item.offerTitle != null && item.offerTitle!.toLowerCase().contains("off")) {
|
||||
offerSubtitle = item.offerTitle!;
|
||||
} else {
|
||||
offerSubtitle = LocaleKeys.offDiscount.tr(args: [discountText]);
|
||||
}
|
||||
} else if (item.offerTitle != null && item.offerTitle!.trim().isNotEmpty) {
|
||||
offerSubtitle = item.offerTitle!.trim();
|
||||
}
|
||||
|
||||
String? typeText = item.getDisplayType();
|
||||
bool isOnline = item.isOnlineStore;
|
||||
String validityText = item.getDisplayValidity();
|
||||
String usageText = "";
|
||||
if (item.displayUsage.isNotEmpty) {
|
||||
usageText = item.displayUsage.replaceAll('usage:', LocaleKeys.usageLabel.tr()).replaceAll('Usage :', LocaleKeys.usageLabel.tr());
|
||||
}
|
||||
String cityText = item.getDisplayCity(isAr: isAr);
|
||||
if (cityText.trim().isEmpty) {
|
||||
cityText = item.displayCity;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _openRetailer(item),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF0D1720).withOpacity(0.0392), offset: const Offset(0, 5), blurRadius: 18, spreadRadius: 0)],
|
||||
),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Inset image with rounded corners
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Banner Image
|
||||
(bannerUrl != null && bannerUrl.isNotEmpty)
|
||||
? Image.network(
|
||||
bannerUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: const Color(0xFFF1F5F9), child: const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 32)),
|
||||
)
|
||||
: Container(color: const Color(0xFFF1F5F9), child: const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 32)),
|
||||
|
||||
// Gradient overlay at bottom of image
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 50,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black.withOpacity(0.75), Colors.transparent])),
|
||||
),
|
||||
),
|
||||
|
||||
// Discount badge at top right (flush with edge, pill on start side)
|
||||
if (discountText.isNotEmpty)
|
||||
PositionedDirectional(
|
||||
top: 20,
|
||||
end: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsetsDirectional.only(start: 14, end: 10, top: 4, bottom: 4),
|
||||
decoration: const BoxDecoration(color: Color(0xFFED1C24), borderRadius: BorderRadiusDirectional.only(topStart: Radius.circular(20), bottomStart: Radius.circular(20))),
|
||||
child: discountText.toText14(isBold: true, color: Colors.white),
|
||||
),
|
||||
),
|
||||
|
||||
// Brand logo & name at bottom left
|
||||
PositionedDirectional(
|
||||
bottom: 8,
|
||||
start: 8,
|
||||
end: 8,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: (logoUrl != null && logoUrl.isNotEmpty) ? Image.network(logoUrl, fit: BoxFit.contain, errorBuilder: (_, __, ___) => _buildMerchantMIcon()) : _buildMerchantMIcon(),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(child: (item.name ?? "").toText11(isBold: true, maxLine: 1, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Retailer Name / Bundle Title
|
||||
(item.name ?? "").toText14(isBold: true, maxlines: 1, overflow: TextOverflow.ellipsis, color: const Color(0xFF1E293B)),
|
||||
if (offerSubtitle.isNotEmpty) ...[const SizedBox(height: 2), offerSubtitle.toText14(maxlines: 1, overflow: TextOverflow.ellipsis, isBold: true, color: const Color(0xFF1E293B))],
|
||||
|
||||
// Badges Row: Type, Validity, Location & Usage
|
||||
if ((typeText != null && typeText.trim().isNotEmpty) || validityText.trim().isNotEmpty || cityText.trim().isNotEmpty || usageText.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 5,
|
||||
runSpacing: 4,
|
||||
children: <Widget>[
|
||||
// Type Chip (Online or In Store)
|
||||
if (typeText != null && typeText.trim().isNotEmpty)
|
||||
InfoChipWidget(
|
||||
text: typeText,
|
||||
icon: isOnline ? Icons.language_rounded : Icons.storefront_rounded,
|
||||
backgroundColor: isOnline ? const Color(0xFFEFF6FF) : const Color(0xFFF5F3FF),
|
||||
textColor: isOnline ? const Color(0xFF2563EB) : const Color(0xFF7C3AED),
|
||||
),
|
||||
|
||||
// Validity badge
|
||||
if (validityText.trim().isNotEmpty) InfoChipWidget(text: validityText, backgroundColor: const Color(0xFFE6F7F0), textColor: const Color(0xFF00A651)),
|
||||
|
||||
// Location Badge
|
||||
if (cityText.trim().isNotEmpty)
|
||||
InfoChipWidget(
|
||||
text: cityText,
|
||||
svgIcon: "assets/icons/location.svg",
|
||||
isBold: false,
|
||||
backgroundColor: const Color(0xFFF3F4F6),
|
||||
textColor: const Color(0xFF4B5563),
|
||||
iconColor: const Color(0xFF64748B),
|
||||
iconTextGap: 2.5,
|
||||
),
|
||||
|
||||
// Usage badge
|
||||
if (usageText.trim().isNotEmpty) InfoChipWidget(text: usageText, backgroundColor: const Color(0xFFFEF3C7), textColor: const Color(0xFFB45309)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRetailersShimmer() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: const Color(0xFFE2E8F0),
|
||||
highlightColor: const Color(0xFFF8FAFC),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, childAspectRatio: 0.7, crossAxisSpacing: 12, mainAxisSpacing: 16),
|
||||
itemCount: 4,
|
||||
itemBuilder: (_, __) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Container(height: 105, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14))),
|
||||
const SizedBox(height: 8),
|
||||
Container(height: 14, width: 110, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
|
||||
const SizedBox(height: 4),
|
||||
Container(height: 12, width: 80, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 5,
|
||||
runSpacing: 4,
|
||||
children: <Widget>[
|
||||
Container(height: 16, width: 50, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10))),
|
||||
Container(height: 16, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10))),
|
||||
Container(height: 16, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoriesShimmer() {
|
||||
return SizedBox(
|
||||
height: 136.0,
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: const Color(0xFFE2E8F0),
|
||||
highlightColor: const Color(0xFFF8FAFC),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: 5,
|
||||
separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 14),
|
||||
itemBuilder: (BuildContext _, int index) {
|
||||
return SizedBox(
|
||||
width: 84,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(width: 84, height: 84, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18))),
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 65, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
|
||||
const SizedBox(height: 4),
|
||||
Container(width: 42, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,390 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mohem_flutter_app/api/offers_and_discounts_api_client.dart';
|
||||
import 'package:mohem_flutter_app/app_state/app_state.dart';
|
||||
import 'package:mohem_flutter_app/classes/colors.dart';
|
||||
import 'package:mohem_flutter_app/classes/consts.dart';
|
||||
import 'package:mohem_flutter_app/classes/utils.dart';
|
||||
import 'package:mohem_flutter_app/config/routes.dart';
|
||||
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
|
||||
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
|
||||
import 'package:mohem_flutter_app/models/generic_response_model.dart';
|
||||
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart';
|
||||
import 'package:mohem_flutter_app/ui/mazaya/widgets/mazaya_bottom_nav.dart';
|
||||
import 'package:mohem_flutter_app/ui/mazaya/widgets/wala_plus_retailer_grid.dart';
|
||||
import 'package:mohem_flutter_app/widgets/app_bar_widget.dart';
|
||||
|
||||
// Mazaya Saved tab - full-screen version of what was FavoriteMerchantsBottomSheet (a modal
|
||||
// bottom sheet). Same fetch/search/pagination/toggle-favorite logic, now its own pushed route
|
||||
// with the shared MazayaBottomNav instead of a dismissible sheet - matches Discover's "own
|
||||
// route, fetches fresh on mount" pattern.
|
||||
class SavedRetailersScreen extends StatefulWidget {
|
||||
const SavedRetailersScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SavedRetailersScreen> createState() => _SavedRetailersScreenState();
|
||||
}
|
||||
|
||||
class _SavedRetailersScreenState extends State<SavedRetailersScreen> {
|
||||
List<WalaPlusRetailerModel> favoriteRetailers = <WalaPlusRetailerModel>[];
|
||||
bool isLoading = true;
|
||||
bool isLoadingMore = false;
|
||||
bool hasMore = true;
|
||||
bool _isFetchingPage = false;
|
||||
int currentPage = 1;
|
||||
int totalPages = 1;
|
||||
final int pageSize = 10;
|
||||
String searchQuery = "";
|
||||
Timer? _debounceTimer;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_scrollListener);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) fetchFavorites();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_scrollController.removeListener(_scrollListener);
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollListener() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
double maxScroll = _scrollController.position.maxScrollExtent;
|
||||
double currentScroll = _scrollController.position.pixels;
|
||||
if (currentScroll >= maxScroll - 35) {
|
||||
if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _getUserReferenceForFavorites() {
|
||||
String ref = OffersAndDiscountsApiClient().resolveUserReference();
|
||||
if (ref.trim().isNotEmpty) return ref.trim();
|
||||
String? userName = AppState().getUserName;
|
||||
if (userName != null && userName.trim().isNotEmpty) return userName.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> fetchFavorites({String? query}) async {
|
||||
if (query != null) searchQuery = query;
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
});
|
||||
try {
|
||||
String? queryParam =
|
||||
searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
|
||||
String? userRef = _getUserReferenceForFavorites();
|
||||
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient()
|
||||
.getWalaPlusRetailers(
|
||||
categoryId: "",
|
||||
page: 1,
|
||||
perPage: pageSize,
|
||||
offerType: null,
|
||||
query: queryParam,
|
||||
sortBy: null,
|
||||
radius: null,
|
||||
minDiscount: null,
|
||||
context: null,
|
||||
userReference: userRef,
|
||||
);
|
||||
favoriteRetailers = response?.retailers ?? <WalaPlusRetailerModel>[];
|
||||
for (WalaPlusRetailerModel r in favoriteRetailers) {
|
||||
r.isFavorite = true;
|
||||
}
|
||||
currentPage = 1;
|
||||
totalPages = response?.pagesCount ?? 1;
|
||||
hasMore =
|
||||
currentPage < totalPages &&
|
||||
(response?.retailers?.isNotEmpty ?? false);
|
||||
} catch (e) {
|
||||
debugPrint("MazayaSavedScreen: error fetching favorites: $e");
|
||||
favoriteRetailers = <WalaPlusRetailerModel>[];
|
||||
} finally {
|
||||
if (mounted) setState(() => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchNextPage() async {
|
||||
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
|
||||
_isFetchingPage = true;
|
||||
setState(() => isLoadingMore = true);
|
||||
try {
|
||||
int nextPage = currentPage + 1;
|
||||
String? queryParam =
|
||||
searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
|
||||
String? userRef = _getUserReferenceForFavorites();
|
||||
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient()
|
||||
.getWalaPlusRetailers(
|
||||
categoryId: "",
|
||||
page: nextPage,
|
||||
perPage: pageSize,
|
||||
offerType: null,
|
||||
query: queryParam,
|
||||
sortBy: null,
|
||||
radius: null,
|
||||
minDiscount: null,
|
||||
context: null,
|
||||
userReference: userRef,
|
||||
);
|
||||
List<WalaPlusRetailerModel> newRetailers =
|
||||
response?.retailers ?? <WalaPlusRetailerModel>[];
|
||||
if (newRetailers.isNotEmpty) {
|
||||
for (WalaPlusRetailerModel r in newRetailers) {
|
||||
r.isFavorite = true;
|
||||
}
|
||||
currentPage = nextPage;
|
||||
totalPages = response?.pagesCount ?? totalPages;
|
||||
hasMore = currentPage < totalPages;
|
||||
favoriteRetailers.addAll(newRetailers);
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("MazayaSavedScreen: error fetching next page: $e");
|
||||
} finally {
|
||||
_isFetchingPage = false;
|
||||
if (mounted) setState(() => isLoadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
_debounceTimer?.cancel();
|
||||
searchQuery = query;
|
||||
if (query.trim().isEmpty) {
|
||||
fetchFavorites(query: "");
|
||||
} else {
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
fetchFavorites(query: query.trim());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(WalaPlusRetailerModel item) async {
|
||||
String retailerId = item.id ?? "";
|
||||
if (retailerId.isEmpty) return;
|
||||
|
||||
bool currentFav = item.isFavorite ?? true;
|
||||
bool targetFav = !currentFav;
|
||||
|
||||
setState(() {
|
||||
item.isFavorite = targetFav;
|
||||
if (!targetFav) {
|
||||
favoriteRetailers.removeWhere(
|
||||
(WalaPlusRetailerModel r) => r.id == item.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
Utils.showLoading(context);
|
||||
|
||||
try {
|
||||
GenericResponseModel? response;
|
||||
String? userRef = _getUserReferenceForFavorites();
|
||||
if (targetFav) {
|
||||
response = await OffersAndDiscountsApiClient().addWalaPlusFavorite(
|
||||
retailerId: retailerId,
|
||||
context: context,
|
||||
userReference: userRef,
|
||||
);
|
||||
} else {
|
||||
response = await OffersAndDiscountsApiClient().removeWalaPlusFavorite(
|
||||
retailerId: retailerId,
|
||||
context: context,
|
||||
userReference: userRef,
|
||||
);
|
||||
}
|
||||
|
||||
Utils.hideLoading(context);
|
||||
|
||||
if (response != null && response.errorMessage == null) {
|
||||
bool isAr = AppState().isArabic(context);
|
||||
String msg =
|
||||
isAr
|
||||
? (targetFav
|
||||
? LocaleKeys.favoriteAddedSuccess.tr()
|
||||
: LocaleKeys.favoriteRemovedSuccess.tr())
|
||||
: (response.wpFavoritesAddRemoveMessage ??
|
||||
(targetFav
|
||||
? LocaleKeys.favoriteAddedSuccess.tr()
|
||||
: LocaleKeys.favoriteRemovedSuccess.tr()));
|
||||
Utils.showToast(msg, isSuccess: true);
|
||||
} else {
|
||||
Utils.showToast(
|
||||
response?.errorEndUserMessage ??
|
||||
response?.errorMessage ??
|
||||
LocaleKeys.favoriteError.tr(),
|
||||
);
|
||||
await fetchFavorites(query: _searchController.text);
|
||||
}
|
||||
} catch (e) {
|
||||
Utils.hideLoading(context);
|
||||
debugPrint("MazayaSavedScreen: error toggling favorite: $e");
|
||||
Utils.showToast(LocaleKeys.favoriteError.tr());
|
||||
await fetchFavorites(query: _searchController.text);
|
||||
}
|
||||
}
|
||||
|
||||
void _openRetailer(WalaPlusRetailerModel item) async {
|
||||
await Navigator.pushNamed(
|
||||
context,
|
||||
AppRoutes.retailerDetailsScreen,
|
||||
arguments: item,
|
||||
);
|
||||
if (mounted) {
|
||||
await fetchFavorites(query: _searchController.text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isAr = AppState().isArabic(context);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBarWidget(
|
||||
context,
|
||||
title: LocaleKeys.favoriteOffers.tr(),
|
||||
showHomeButton: false,
|
||||
// Reached from the Mazaya bottom nav - same as Discover, no back button for a
|
||||
// dependent-login session since this is one of their root tabs, not a pushed screen.
|
||||
showBackButton: false,
|
||||
),
|
||||
bottomNavigationBar: const MazayaBottomNav(currentTab: MazayaTab.saved),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 70,
|
||||
padding: const EdgeInsets.only(
|
||||
top: 14,
|
||||
right: 16,
|
||||
bottom: 14,
|
||||
left: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE8E8E8), width: 1.0),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withOpacity(0.0314),
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 5,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
LocaleKeys.search.tr().toText12(
|
||||
isBold: true,
|
||||
color: const Color(0xFF2A333C),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (String val) {
|
||||
_debounceTimer?.cancel();
|
||||
fetchFavorites(query: val.trim());
|
||||
},
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: LocaleKeys.searchItems.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
color: Color(0xFFACAFB1),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
suffixIcon: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _searchController,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
TextEditingValue value,
|
||||
Widget? child,
|
||||
) {
|
||||
if (value.text.isEmpty)
|
||||
return const SizedBox.shrink();
|
||||
return InkWell(
|
||||
child: const Icon(
|
||||
Icons.clear,
|
||||
size: 18,
|
||||
color: Color(0xFF848486),
|
||||
),
|
||||
onTap: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged("");
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
suffixIconConstraints: const BoxConstraints(
|
||||
maxWidth: 24,
|
||||
maxHeight: 28,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF2A333C),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => fetchFavorites(query: _searchController.text),
|
||||
color: MyColors.textMixColor,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
WalaPlusRetailerGrid(
|
||||
isLoading: isLoading,
|
||||
retailers: favoriteRetailers,
|
||||
isAr: isAr,
|
||||
isLoadingMore: isLoadingMore,
|
||||
onTapRetailer: _openRetailer,
|
||||
onFavoriteTap: _toggleFavorite,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:mohem_flutter_app/classes/consts.dart';
|
||||
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
|
||||
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
|
||||
import 'package:mohem_flutter_app/ui/mazaya/mazaya_discover_screen.dart';
|
||||
import 'package:mohem_flutter_app/ui/mazaya/saved_retailers_screen.dart';
|
||||
import 'package:mohem_flutter_app/ui/profile/family_members.dart';
|
||||
|
||||
enum MazayaTab { discover, saved, myFamily }
|
||||
|
||||
// Shared bottom nav for the Mazaya module (Discover / Saved / My Family). Each tab is its own
|
||||
// screen, swapped in with zero-duration transition (see _onTabTapped) so switching tabs feels
|
||||
// instant rather than like a new screen opening - not an IndexedStack shell, since each tab still
|
||||
// needs to reload its own data fresh on every switch, not preserve state across tabs.
|
||||
class MazayaBottomNav extends StatelessWidget {
|
||||
final MazayaTab currentTab;
|
||||
|
||||
const MazayaBottomNav({Key? key, required this.currentTab}) : super(key: key);
|
||||
|
||||
static const Color _activeColor = Color(0xFF147D6B);
|
||||
static const Color _inactiveColor = Color(0xFF78817F);
|
||||
|
||||
void _onTabTapped(BuildContext context, MazayaTab tab) {
|
||||
if (tab == currentTab) return;
|
||||
Widget targetScreen;
|
||||
RouteSettings? settings;
|
||||
switch (tab) {
|
||||
case MazayaTab.discover:
|
||||
targetScreen = const MazayaDiscoverScreen();
|
||||
break;
|
||||
case MazayaTab.saved:
|
||||
targetScreen = const SavedRetailersScreen();
|
||||
break;
|
||||
case MazayaTab.myFamily:
|
||||
targetScreen = const FamilyMembers();
|
||||
settings = const RouteSettings(
|
||||
arguments: <String, dynamic>{'fromMazaya': true},
|
||||
);
|
||||
break;
|
||||
}
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
PageRouteBuilder<void>(
|
||||
settings: settings,
|
||||
pageBuilder:
|
||||
(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) => targetScreen,
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(
|
||||
BuildContext context, {
|
||||
required MazayaTab tab,
|
||||
required String svgAsset,
|
||||
required String label,
|
||||
}) {
|
||||
bool isActive = tab == currentTab;
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _onTabTapped(context, tab),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
SvgPicture.asset(
|
||||
svgAsset,
|
||||
width: 22,
|
||||
height: 22,
|
||||
colorFilter: ColorFilter.mode(
|
||||
isActive ? _activeColor : _inactiveColor,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
label.toText11(
|
||||
weight: isActive ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isActive ? _activeColor : _inactiveColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Color(0xFFE8EAE4))),
|
||||
),
|
||||
padding: const EdgeInsets.only(top: 13, bottom: 9),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_buildItem(
|
||||
context,
|
||||
tab: MazayaTab.discover,
|
||||
svgAsset: "assets/icons/mazaya_compass.svg",
|
||||
label: LocaleKeys.discover.tr(),
|
||||
),
|
||||
_buildItem(
|
||||
context,
|
||||
tab: MazayaTab.saved,
|
||||
svgAsset: "assets/icons/mazaya_heart_outline.svg",
|
||||
label: LocaleKeys.saved.tr(),
|
||||
),
|
||||
// My Family lets the account holder manage their dependents' Mazaya access - not
|
||||
// applicable (and not shown) when a dependent is the one logged in.
|
||||
if (!ApiConsts.isDependetLogin)
|
||||
_buildItem(
|
||||
context,
|
||||
tab: MazayaTab.myFamily,
|
||||
svgAsset: "assets/icons/mazaya_user_round.svg",
|
||||
label: LocaleKeys.myFamily.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:mohem_flutter_app/api/offers_and_discounts_api_client.dart';
|
||||
import 'package:mohem_flutter_app/app_state/app_state.dart';
|
||||
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
|
||||
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
|
||||
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_city_model.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
// Shared by MazayaDiscoverScreen and RetailersListScreen so both city pickers stay
|
||||
// visually/behaviorally identical - change the sheet design here only. Fetched fresh from
|
||||
// getWalaPlusCities every time the sheet opens (no caching), per the confirmed plan.
|
||||
void showMazayaCitySelectionBottomSheet(BuildContext context, {required int? selectedCityId, required ValueChanged<WalaPlusCityModel> onCitySelected}) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext sheetContext) {
|
||||
return _MazayaCitySelectionSheet(selectedCityId: selectedCityId, onCitySelected: onCitySelected);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _MazayaCitySelectionSheet extends StatefulWidget {
|
||||
final int? selectedCityId;
|
||||
final ValueChanged<WalaPlusCityModel> onCitySelected;
|
||||
|
||||
const _MazayaCitySelectionSheet({required this.selectedCityId, required this.onCitySelected});
|
||||
|
||||
@override
|
||||
State<_MazayaCitySelectionSheet> createState() => _MazayaCitySelectionSheetState();
|
||||
}
|
||||
|
||||
class _MazayaCitySelectionSheetState extends State<_MazayaCitySelectionSheet> {
|
||||
bool _isLoading = true;
|
||||
List<WalaPlusCityModel> _cities = <WalaPlusCityModel>[];
|
||||
List<WalaPlusCityModel> _filteredCities = <WalaPlusCityModel>[];
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchCities();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchCities() async {
|
||||
try {
|
||||
List<WalaPlusCityModel> cities = await OffersAndDiscountsApiClient().getWalaPlusCities();
|
||||
// "All Cities" always first - it's the default selection when nothing else was picked yet.
|
||||
cities.sort((a, b) {
|
||||
if (a.isAllCities == b.isAllCities) return 0;
|
||||
return a.isAllCities ? -1 : 1;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cities = cities;
|
||||
_filteredCities = cities;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("MazayaCitySelector: error fetching cities: $e");
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// Local/client-side only - the already-fetched list is small enough that there's no need to
|
||||
// re-hit getWalaPlusCities per keystroke.
|
||||
void _onSearchChanged(String query) {
|
||||
String q = query.trim().toLowerCase();
|
||||
setState(() {
|
||||
_filteredCities =
|
||||
q.isEmpty
|
||||
? _cities
|
||||
: _cities.where((WalaPlusCityModel c) {
|
||||
return (c.cityNameEn ?? '').toLowerCase().contains(q) || (c.cityNameAr ?? '').toLowerCase().contains(q);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isAr = AppState().isArabic(context);
|
||||
// Fixed height regardless of result count - a search that narrows the list down to one or
|
||||
// two cities (or the full unfiltered list) should never make the sheet itself grow/shrink,
|
||||
// only the list inside it scrolls.
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(width: 40, height: 4, margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2))),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
LocaleKeys.selectCity.tr().toText16(isBold: true, color: const Color(0xFF1E293B)),
|
||||
InkWell(
|
||||
onTap: () => Navigator.pop(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: const Padding(padding: EdgeInsets.all(4), child: Icon(Icons.close_rounded, size: 20, color: Color(0xFF64748B))),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildSearchField(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(child: _isLoading ? _buildShimmer() : _buildList(isAr)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Same design as MazayaDiscoverScreen's own search field - filters the already-fetched city
|
||||
// list locally as the user types (see _onSearchChanged).
|
||||
Widget _buildSearchField() {
|
||||
return Container(
|
||||
height: 47,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border.all(color: const Color(0xFFE8EAE4)), borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
SvgPicture.asset("assets/icons/mazaya_search.svg", width: 18, height: 18, colorFilter: const ColorFilter.mode(Color(0xFF78817F), BlendMode.srcIn)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(border: InputBorder.none, isDense: true, hintText: LocaleKeys.searchCities.tr(), hintStyle: const TextStyle(color: Color(0xFF78817F), fontSize: 12)),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF202F32)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Matches ListTile's own default one-line height (56) and contentPadding (16 horizontal) so
|
||||
// there's no layout jump between the shimmer and the real list underneath it.
|
||||
static const double _rowHeight = 56;
|
||||
|
||||
Widget _buildShimmer() {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: const Color(0xFFE2E8F0),
|
||||
highlightColor: const Color(0xFFF8FAFC),
|
||||
child: ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: 10,
|
||||
itemBuilder:
|
||||
(BuildContext _, int __) => Container(
|
||||
height: _rowHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Container(height: 28, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(bool isAr) {
|
||||
if (_filteredCities.isEmpty) {
|
||||
return Center(child: LocaleKeys.noResultsFound.tr().toText13(color: const Color(0xFF7A8180)));
|
||||
}
|
||||
// ListTile paints its background/ink splashes on the nearest Material ancestor - without
|
||||
// this, the opaque DecoratedBox the whole sheet sits in (see build()) hides them.
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _filteredCities.length,
|
||||
itemBuilder: (BuildContext _, int index) {
|
||||
WalaPlusCityModel city = _filteredCities[index];
|
||||
bool isSelected = widget.selectedCityId != null ? city.cityId == widget.selectedCityId : city.isAllCities;
|
||||
return ListTile(
|
||||
title: city.getDisplayName(isAr).toText14(isBold: isSelected, color: isSelected ? const Color(0xFF0D9488) : const Color(0xFF1E293B)),
|
||||
trailing: isSelected ? const Icon(Icons.check_rounded, color: Color(0xFF0D9488), size: 20) : null,
|
||||
onTap: () {
|
||||
widget.onCitySelected(city);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mohem_flutter_app/extensions/int_extensions.dart';
|
||||
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
|
||||
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
|
||||
import 'package:mohem_flutter_app/main.dart';
|
||||
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart';
|
||||
import 'package:mohem_flutter_app/ui/mazaya/retailers_list_screen.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class WalaPlusRetailerGrid extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
final List<WalaPlusRetailerModel> retailers;
|
||||
final bool isAr;
|
||||
final void Function(WalaPlusRetailerModel item) onTapRetailer;
|
||||
final void Function(WalaPlusRetailerModel item) onFavoriteTap;
|
||||
final bool isLoadingMore;
|
||||
final int shimmerItemCount;
|
||||
final double? mainAxisExtent;
|
||||
|
||||
const WalaPlusRetailerGrid({
|
||||
Key? key,
|
||||
required this.isLoading,
|
||||
required this.retailers,
|
||||
required this.isAr,
|
||||
required this.onTapRetailer,
|
||||
required this.onFavoriteTap,
|
||||
this.isLoadingMore = false,
|
||||
this.shimmerItemCount = 4,
|
||||
this.mainAxisExtent,
|
||||
}) : super(key: key);
|
||||
|
||||
bool get _hasAnyChips {
|
||||
for (WalaPlusRetailerModel r in retailers) {
|
||||
if (r.normalizedTypes.isNotEmpty ||
|
||||
r.displayCity.trim().isNotEmpty ||
|
||||
r.displayUsage.trim().isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
double get _effectiveMainAxisExtent {
|
||||
if (mainAxisExtent != null) return mainAxisExtent!;
|
||||
if (retailers.isNotEmpty && !_hasAnyChips) return 190;
|
||||
return 235;
|
||||
}
|
||||
|
||||
SliverGridDelegateWithFixedCrossAxisCount get _gridDelegate =>
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isTablet ? 4 : 2,
|
||||
mainAxisExtent: _effectiveMainAxisExtent,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 16,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: const Color(0xFFE2E8F0),
|
||||
highlightColor: const Color(0xFFF8FAFC),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: _gridDelegate,
|
||||
itemCount: shimmerItemCount,
|
||||
itemBuilder: (BuildContext _, int __) => _buildShimmerCard(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (retailers.isEmpty) {
|
||||
return Padding(padding: const EdgeInsets.only(top: 40), child: Center(child: LocaleKeys.noOffersAvailable.tr().toText14(color: const Color(0xFF64748B))));
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: _gridDelegate,
|
||||
itemCount: retailers.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return WalaPlusRetailerCard(item: retailers[index], isAr: isAr, onTap: () => onTapRetailer(retailers[index]), onFavoriteTap: () => onFavoriteTap(retailers[index]));
|
||||
},
|
||||
),
|
||||
if (isLoadingMore) ...<Widget>[
|
||||
16.height,
|
||||
Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 12), child: Image.asset("assets/images/logos/loading_mohemm_logo.gif", height: 48.0, width: 48.0))),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShimmerCard() {
|
||||
bool showChips = _effectiveMainAxisExtent > 200;
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Container(height: 110, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14))),
|
||||
const SizedBox(height: 8),
|
||||
Container(height: 14, width: 110, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
|
||||
const SizedBox(height: 6),
|
||||
Container(height: 14, width: 80, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
|
||||
if (showChips) ...<Widget>[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(height: 28, width: 50, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
|
||||
const SizedBox(width: 5),
|
||||
Container(height: 28, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||