Compare commits
13 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
6344d78302 | 1 week ago |
|
|
4baf5f9389 | 1 week ago |
|
|
cd9f8e0b48 | 1 week ago |
|
|
c49872f798 | 1 week ago |
|
|
f503dca074 | 1 week ago |
|
|
9dcbb0d855 | 1 week ago |
|
|
a6be594b3b | 2 weeks ago |
|
|
9284d0e100 | 2 weeks ago |
|
|
0175c958ef | 2 weeks ago |
|
|
3cfe5355ef | 3 weeks 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
|
#Tue Jul 08 11:08:34 AST 2025
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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 |
@ -1,4 +0,0 @@
|
|||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M10.3315 5.33146C10.5145 5.1484 10.5145 4.8516 10.3315 4.66854C10.1484 4.48549 9.8516 4.48549 9.66854 4.66854L7.50009 6.83699L5.95644 5.29352C5.77337 5.11048 5.47657 5.11049 5.29352 5.29356C5.11048 5.47663 5.11049 5.77343 5.29356 5.95648L6.83718 7.49991L6.54354 7.79354C6.36049 7.9766 6.36049 8.2734 6.54354 8.45646C6.7266 8.63951 7.0234 8.63951 7.20646 8.45646L7.50013 8.16278L7.79364 8.45625C7.97671 8.6393 8.27351 8.63928 8.45655 8.45621C8.6396 8.27314 8.63958 7.97634 8.45651 7.79329L8.16305 7.49987L10.3315 5.33146Z" fill="#047857"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.53573 1.09375H7.46427C6.09508 1.09374 5.01965 1.09374 4.18001 1.20662C3.3196 1.3223 2.63694 1.5642 2.10057 2.10057C1.5642 2.63694 1.3223 3.3196 1.20662 4.18001C1.09374 5.01965 1.09374 6.09508 1.09375 7.46427V7.53573C1.09374 8.90492 1.09374 9.98035 1.20662 10.82C1.3223 11.6804 1.5642 12.3631 2.10057 12.8994C2.63694 13.4358 3.3196 13.6777 4.18001 13.7934C5.01965 13.9063 6.09507 13.9063 7.46425 13.9062H7.53573C8.90491 13.9063 9.98035 13.9063 10.82 13.7934C11.6804 13.6777 12.3631 13.4358 12.8994 12.8994C13.4358 12.3631 13.6777 11.6804 13.7934 10.82C13.9063 9.98035 13.9063 8.90493 13.9062 7.53575V7.46427C13.9063 6.09509 13.9063 5.01965 13.7934 4.18001C13.6777 3.3196 13.4358 2.63694 12.8994 2.10057C12.3631 1.5642 11.6804 1.3223 10.82 1.20662C9.98035 1.09374 8.90492 1.09374 7.53573 1.09375ZM2.76348 2.76348C3.09664 2.43033 3.54848 2.23746 4.30493 2.13576C5.07487 2.03225 6.08727 2.03125 7.5 2.03125C8.91273 2.03125 9.92513 2.03225 10.6951 2.13576C11.4515 2.23746 11.9034 2.43033 12.2365 2.76348C12.5697 3.09664 12.7625 3.54848 12.8642 4.30493C12.9678 5.07487 12.9688 6.08727 12.9688 7.5C12.9688 8.91273 12.9678 9.92513 12.8642 10.6951C12.7625 11.4515 12.5697 11.9034 12.2365 12.2365C11.9034 12.5697 11.4515 12.7625 10.6951 12.8642C9.92513 12.9678 8.91273 12.9688 7.5 12.9688C6.08727 12.9688 5.07487 12.9678 4.30493 12.8642C3.54848 12.7625 3.09664 12.5697 2.76348 12.2365C2.43033 11.9034 2.23746 11.4515 2.13576 10.6951C2.03225 9.92513 2.03125 8.91273 2.03125 7.5C2.03125 6.08727 2.03225 5.07487 2.13576 4.30493C2.23746 3.54848 2.43033 3.09664 2.76348 2.76348Z" fill="#047857"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 190 B |
@ -1,5 +0,0 @@
|
|||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 9.1875C7.656 9.1875 6.5625 8.094 6.5625 6.75C6.5625 5.406 7.656 4.3125 9 4.3125C10.344 4.3125 11.4375 5.406 11.4375 6.75C11.4375 8.094 10.344 9.1875 9 9.1875ZM9 5.4375C8.27625 5.4375 7.6875 6.02625 7.6875 6.75C7.6875 7.47375 8.27625 8.0625 9 8.0625C9.72375 8.0625 10.3125 7.47375 10.3125 6.75C10.3125 6.02625 9.72375 5.4375 9 5.4375Z" fill="#737373"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.66722 13.5263C8.02722 13.872 8.50047 14.0625 8.99997 14.0625C9.49947 14.0625 9.97272 13.872 10.3327 13.5255C10.5082 13.3568 10.6882 13.1858 10.8705 13.0133C13.1174 10.8818 15.9149 8.22811 14.3692 4.51199C13.4805 2.37374 11.322 0.9375 8.99997 0.9375C6.67797 0.9375 4.52021 2.37374 3.63146 4.51199C2.09096 8.21474 4.86521 10.8555 7.09421 12.9773L7.11578 12.9978C7.30192 13.1753 7.48597 13.3508 7.66722 13.5263ZM4.67022 4.94399C5.38647 3.22049 7.12647 2.0625 8.99997 2.0625C10.8735 2.0625 12.6142 3.22049 13.3312 4.94399C14.5792 7.94549 12.3007 10.107 10.0965 12.1972C10.0547 12.237 10.0129 12.2766 9.97134 12.3162C9.83004 12.4504 9.69004 12.5834 9.55273 12.7155C9.40348 12.8588 9.20697 12.9375 8.99997 12.9375C8.79297 12.9375 8.59646 12.858 8.44871 12.7163C8.25971 12.5333 8.06623 12.3487 7.86973 12.162C5.68581 10.0831 3.42677 7.93263 4.67022 4.94399Z" fill="#737373"/>
|
|
||||||
<path d="M11.0385 17.0625H6.96142C4.53817 17.0625 3.32169 17.0625 2.72319 16.1745C2.66544 16.0882 2.61517 15.999 2.57392 15.909C2.11666 14.93 2.85295 13.8806 3.87139 12.429L3.87295 12.4267C4.05145 12.1725 4.40243 12.111 4.65668 12.2895C4.91093 12.468 4.97243 12.819 4.79393 13.0733C3.98468 14.2268 3.42443 15.072 3.59543 15.4373C3.61343 15.4763 3.63367 15.5115 3.65692 15.5468C3.92017 15.9375 5.12991 15.9375 6.96138 15.9375H11.0385C12.8699 15.9375 14.0797 15.9375 14.3437 15.546C14.3669 15.5115 14.3872 15.4762 14.4029 15.441C14.5754 15.072 14.0152 14.2268 13.2059 13.0733C13.0274 12.819 13.0889 12.468 13.3432 12.2895C13.5974 12.111 13.9484 12.1725 14.1269 12.4267C15.1462 13.8795 15.8827 14.9295 15.4244 15.9127C15.3847 15.999 15.3352 16.0882 15.2775 16.1737C14.6782 17.0625 13.4617 17.0625 11.0385 17.0625Z" fill="#737373"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
@ -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 |
@ -1,6 +0,0 @@
|
|||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.8848 0.782465C11.6672 0.777969 11.077 0.780422 10.3952 0.783256L10.074 0.784571C9.25489 0.787826 8.36176 0.790261 7.8034 0.782363L3.41832 0.782363C3.30775 0.782363 3.09409 0.784794 2.86663 0.826585C2.65209 0.866 2.34887 0.953285 2.12257 1.17828C2.01295 1.28728 1.92531 1.4256 1.85771 1.5488C1.78696 1.67774 1.72248 1.81951 1.66613 1.95532C1.55744 2.2173 1.46792 2.48443 1.41557 2.64068L1.41079 2.65493L0.986568 3.88476C0.919741 4.06317 0.849387 4.33704 0.811103 4.61007C0.791433 4.75035 0.778628 4.90141 0.781624 5.04747C0.784495 5.18744 0.802226 5.35457 0.864472 5.50929C0.963954 5.75657 1.18158 6.01838 1.36007 6.20866C1.36856 6.21771 1.3771 6.22675 1.38569 6.23576C1.38552 6.24088 1.38544 6.24602 1.38544 6.25118L1.38544 9.41047C1.38543 10.2652 1.38541 10.9542 1.45827 11.496C1.5339 12.0586 1.69571 12.5322 2.0719 12.9084C2.4481 13.2846 2.92177 13.4465 3.48434 13.5221C4.0262 13.595 4.71515 13.5949 5.5699 13.5949L7.47919 13.5949C7.73807 13.5949 7.94794 13.3851 7.94794 13.1262C7.94794 12.8673 7.73807 12.6574 7.47919 12.6574L5.60419 12.6574C4.70705 12.6574 4.08136 12.6564 3.60926 12.593C3.15066 12.5313 2.9078 12.4185 2.73482 12.2455C2.56184 12.0726 2.44906 11.8297 2.38741 11.3711C2.32393 10.899 2.32294 10.2733 2.32294 9.37618L2.32294 6.97328C2.45051 7.03849 2.59529 7.10093 2.75359 7.15501C3.17457 7.29882 3.7203 7.39381 4.3406 7.31265C4.89853 7.23967 5.351 7.00851 5.70918 6.71013C6.09419 7.00458 6.70331 7.30587 7.47811 7.31657C8.04917 7.32446 8.56216 7.12979 8.95868 6.90312C9.08184 6.83271 9.19674 6.75753 9.30188 6.68132C9.77763 7.08237 10.6727 7.56818 11.9991 7.20432C13.5774 6.77137 14.083 5.75144 14.209 5.1425C14.2154 5.11127 14.2187 5.07946 14.2187 5.04757C14.2187 4.75732 14.1289 4.33991 13.9659 3.80861C13.7986 3.26346 13.5405 2.55589 13.1738 1.66657C13.1701 1.65753 13.166 1.6486 13.1618 1.63981C13.0956 1.50407 12.9647 1.3046 12.7653 1.13192C12.5616 0.95548 12.2655 0.790329 11.8848 0.782465ZM7.49106 6.37916C6.97962 6.3721 6.58006 6.18008 6.33319 6.00595C6.55391 5.67199 6.6951 5.33553 6.76663 5.07693C6.83564 4.82742 6.68931 4.5692 6.4398 4.50019C6.19028 4.43117 5.93206 4.5775 5.86305 4.82702C5.70947 5.3823 5.16544 6.25926 4.21899 6.38308C3.76402 6.4426 3.36434 6.37295 3.05665 6.26784C2.73842 6.15914 2.54601 6.02235 2.50231 5.98113C2.48528 5.96506 2.46707 5.9503 2.44784 5.93696C2.37021 5.88311 2.20722 5.74146 2.04383 5.56728C1.87113 5.38316 1.76292 5.23071 1.73421 5.15935C1.73421 5.15935 1.73024 5.14811 1.72656 5.12505C1.72269 5.10085 1.71976 5.0687 1.71893 5.02824C1.71725 4.94644 1.72447 4.84757 1.73952 4.74025C1.77015 4.52178 1.82659 4.31346 1.86579 4.21019C1.86748 4.20572 1.86911 4.20123 1.87067 4.19671L2.29773 2.95872L2.30261 2.94414C2.3555 2.78638 2.43617 2.5457 2.53206 2.3146C2.58103 2.19657 2.63099 2.0884 2.67962 1.99977C2.73141 1.90538 2.7681 1.85849 2.78357 1.8431C2.80747 1.81934 2.88262 1.77684 3.03603 1.74865C3.17652 1.72284 3.32038 1.71986 3.41832 1.71986L7.79347 1.71986C8.36231 1.72785 9.26287 1.7253 10.0778 1.72206L10.3974 1.72076C11.0847 1.71792 11.6606 1.71553 11.8655 1.71977C11.9719 1.72196 12.0657 1.76626 12.1515 1.84057C12.2347 1.91263 12.2907 1.99706 12.314 2.0407C12.6704 2.90597 12.9148 3.57905 13.0696 4.08359C13.214 4.55415 13.2701 4.84632 13.2797 5.00173C13.1969 5.33137 12.8878 5.98842 11.7511 6.30022C10.8759 6.54033 10.2955 6.26948 9.96995 6.01784C10.0887 5.82356 10.2297 5.52285 10.3667 5.09485C10.4456 4.84828 10.3097 4.58443 10.0631 4.50553C9.81653 4.42662 9.55268 4.56254 9.47378 4.8091C9.30754 5.32855 9.16081 5.54992 9.1304 5.58829C9.02102 5.71208 8.79354 5.91764 8.4934 6.08922C8.18993 6.2627 7.84298 6.38402 7.49106 6.37916Z" fill="#C2410C"/>
|
|
||||||
<path d="M3.88544 10.6262C3.88544 10.3673 4.0953 10.1574 4.35419 10.1574L6.85419 10.1574C7.11307 10.1574 7.32294 10.3673 7.32294 10.6262C7.32294 10.8851 7.11307 11.0949 6.85419 11.0949L4.35419 11.0949C4.0953 11.0949 3.88544 10.8851 3.88544 10.6262Z" fill="#C2410C"/>
|
|
||||||
<path d="M10.9167 10.6262C10.9167 10.281 11.1965 10.0012 11.5417 10.0012L11.5473 10.0012C11.8925 10.0012 12.1723 10.281 12.1723 10.6262C12.1723 10.9714 11.8925 11.2512 11.5473 11.2512L11.5417 11.2512C11.1965 11.2512 10.9167 10.9714 10.9167 10.6262Z" fill="#C2410C"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5624 7.96748C10.1037 7.96748 8.90619 9.13055 8.90619 10.5835C8.90619 11.4113 9.32257 12.0297 9.75433 12.5247C9.9082 12.7011 10.0773 12.876 10.2353 13.0394C10.2951 13.1013 10.3536 13.1618 10.4088 13.2199C10.619 13.4408 10.7995 13.6441 10.9391 13.8516C11.0558 14.0251 11.2645 14.2195 11.5661 14.2195C11.8728 14.2195 12.0808 14.0193 12.1939 13.8391C12.3264 13.6278 12.5016 13.425 12.7082 13.2072C12.7757 13.1361 12.85 13.0599 12.9271 12.981C13.0706 12.8339 13.2237 12.677 13.3595 12.525C13.7946 12.038 14.2187 11.4281 14.2187 10.5835C14.2187 9.13055 13.0212 7.96748 11.5624 7.96748ZM9.84369 10.5835C9.84369 9.66465 10.605 8.90498 11.5624 8.90498C12.5199 8.90498 13.2812 9.66465 13.2812 10.5835C13.2812 11.0803 13.0441 11.4709 12.6604 11.9004C12.5358 12.0398 12.4117 12.1667 12.28 12.3015L12.2796 12.302C12.1992 12.3842 12.1159 12.4694 12.028 12.562C11.869 12.7297 11.7058 12.9111 11.5583 13.1097C11.4095 12.9183 11.2464 12.7402 11.0881 12.5738C11.0212 12.5034 10.956 12.436 10.8923 12.3701C10.7408 12.2134 10.5979 12.0656 10.4608 11.9084C10.0786 11.4703 9.84369 11.0692 9.84369 10.5835Z" fill="#C2410C"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 778 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@ -1,92 +0,0 @@
|
|||||||
class GetMobileLoginInfoDependentModel {
|
|
||||||
int? iD;
|
|
||||||
String? dependentID;
|
|
||||||
String? depedentName;
|
|
||||||
String? dob;
|
|
||||||
int? channelID;
|
|
||||||
int? companyID;
|
|
||||||
String? deviceType;
|
|
||||||
String? deviceToken;
|
|
||||||
int? language;
|
|
||||||
int? gender;
|
|
||||||
int? loginType;
|
|
||||||
String? createdOn;
|
|
||||||
String? editedOn;
|
|
||||||
bool? businessCardPrivilege;
|
|
||||||
|
|
||||||
GetMobileLoginInfoDependentModel({
|
|
||||||
this.iD,
|
|
||||||
this.dependentID,
|
|
||||||
this.depedentName,
|
|
||||||
this.dob,
|
|
||||||
this.channelID,
|
|
||||||
this.companyID,
|
|
||||||
this.deviceType,
|
|
||||||
this.deviceToken,
|
|
||||||
this.language,
|
|
||||||
this.gender,
|
|
||||||
this.loginType,
|
|
||||||
this.createdOn,
|
|
||||||
this.editedOn,
|
|
||||||
this.businessCardPrivilege,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convenience aliases for existing logic
|
|
||||||
String? get nationalId => dependentID;
|
|
||||||
String? get birthDate => dob;
|
|
||||||
String? get employeeName => depedentName;
|
|
||||||
String? get dependentName => depedentName;
|
|
||||||
|
|
||||||
GetMobileLoginInfoDependentModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
iD = json['ID'];
|
|
||||||
dependentID = (json['DependentID'] ??
|
|
||||||
json['DEPENDENT_ID'] ??
|
|
||||||
json['P_DEP_NATIONAL_ID'] ??
|
|
||||||
json['NationalID'] ??
|
|
||||||
json['NationalId'] ??
|
|
||||||
json['EmployeeID'] ??
|
|
||||||
json['UserName'])
|
|
||||||
?.toString();
|
|
||||||
depedentName = (json['DepedentName'] ??
|
|
||||||
json['DependentName'] ??
|
|
||||||
json['P_DEPENDENT_NAME'] ??
|
|
||||||
json['EmployeeName'])
|
|
||||||
?.toString();
|
|
||||||
dob = (json['DOB'] ??
|
|
||||||
json['BirthDate'] ??
|
|
||||||
json['DateOfBirth'] ??
|
|
||||||
json['P_DEP_BIRTH_DATE'] ??
|
|
||||||
json['P_BIRTH_DATE'] ??
|
|
||||||
json['Birth_Date'])
|
|
||||||
?.toString();
|
|
||||||
channelID = json['ChannelID'] != null ? int.tryParse(json['ChannelID'].toString()) : null;
|
|
||||||
companyID = json['CompanyID'] != null ? int.tryParse(json['CompanyID'].toString()) : null;
|
|
||||||
deviceType = json['DeviceType']?.toString();
|
|
||||||
deviceToken = json['DeviceToken']?.toString();
|
|
||||||
language = json['Language'] != null ? int.tryParse(json['Language'].toString()) : null;
|
|
||||||
gender = json['Gender'] != null ? int.tryParse(json['Gender'].toString()) : null;
|
|
||||||
loginType = json['LoginType'] != null ? int.tryParse(json['LoginType'].toString()) : null;
|
|
||||||
createdOn = json['CreatedOn']?.toString();
|
|
||||||
editedOn = json['EditedOn']?.toString();
|
|
||||||
businessCardPrivilege = json['BusinessCardPrivilege'];
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
Map<String, dynamic> data = <String, dynamic>{};
|
|
||||||
data['ID'] = iD;
|
|
||||||
data['DependentID'] = dependentID;
|
|
||||||
data['DepedentName'] = depedentName;
|
|
||||||
data['DOB'] = dob;
|
|
||||||
data['ChannelID'] = channelID;
|
|
||||||
data['CompanyID'] = companyID;
|
|
||||||
data['DeviceType'] = deviceType;
|
|
||||||
data['DeviceToken'] = deviceToken;
|
|
||||||
data['Language'] = language;
|
|
||||||
data['Gender'] = gender;
|
|
||||||
data['LoginType'] = loginType;
|
|
||||||
data['CreatedOn'] = createdOn;
|
|
||||||
data['EditedOn'] = editedOn;
|
|
||||||
data['BusinessCardPrivilege'] = businessCardPrivilege;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
class WalaPlusCategoryModel {
|
|
||||||
String? id;
|
|
||||||
String? image;
|
|
||||||
String? imageThumb;
|
|
||||||
String? name;
|
|
||||||
String? parentId;
|
|
||||||
|
|
||||||
WalaPlusCategoryModel({
|
|
||||||
this.id,
|
|
||||||
this.image,
|
|
||||||
this.imageThumb,
|
|
||||||
this.name,
|
|
||||||
this.parentId,
|
|
||||||
}) {
|
|
||||||
if ((image == null || image!.isEmpty) && id != null && id!.trim().isNotEmpty && id != "all" && id != "0") {
|
|
||||||
String cleanId = id!.trim();
|
|
||||||
if (cleanId.toLowerCase().endsWith('.png')) {
|
|
||||||
cleanId = cleanId.substring(0, cleanId.length - 4);
|
|
||||||
}
|
|
||||||
image = Uri.encodeFull("https://epharmacy.hmg.com/middleware/images/category/$cleanId.png");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WalaPlusCategoryModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
id = (json['id'] ?? json['Id'] ?? json['ID'])?.toString();
|
|
||||||
name = (json['name'] ?? json['Name'])?.toString();
|
|
||||||
parentId = (json['parent_id'] ?? json['Parent_Id'] ?? json['parentId'])?.toString();
|
|
||||||
imageThumb = (json['image_thumb'] ?? json['Image_Thumb'] ?? json['imageThumb'])?.toString();
|
|
||||||
|
|
||||||
// Use category image URL from HMG middleware using ID from WalaPlus_Categories API:
|
|
||||||
// https://epharmacy.hmg.com/middleware/images/category/{id}.png
|
|
||||||
if (id != null && id!.trim().isNotEmpty && id != "all" && id != "0") {
|
|
||||||
String cleanId = id!.trim();
|
|
||||||
if (cleanId.toLowerCase().endsWith('.png')) {
|
|
||||||
cleanId = cleanId.substring(0, cleanId.length - 4);
|
|
||||||
}
|
|
||||||
image = Uri.encodeFull("https://epharmacy.hmg.com/middleware/images/category/$cleanId.png");
|
|
||||||
} else {
|
|
||||||
image = (json['image'] ?? json['Image'])?.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
Map<String, dynamic> data = <String, dynamic>{};
|
|
||||||
data['id'] = id;
|
|
||||||
data['image'] = image;
|
|
||||||
data['image_thumb'] = imageThumb;
|
|
||||||
data['name'] = name;
|
|
||||||
data['parent_id'] = parentId;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isMainCategory => parentId == null || parentId!.isEmpty;
|
|
||||||
bool get isSvg => (image ?? '').toLowerCase().contains('.svg');
|
|
||||||
|
|
||||||
String get categoryImageUrl {
|
|
||||||
if (id != null && id!.trim().isNotEmpty && id != "all" && id != "0") {
|
|
||||||
String cleanId = id!.trim();
|
|
||||||
if (cleanId.toLowerCase().endsWith('.png')) {
|
|
||||||
cleanId = cleanId.substring(0, cleanId.length - 4);
|
|
||||||
}
|
|
||||||
return Uri.encodeFull("https://epharmacy.hmg.com/middleware/images/category/$cleanId.png");
|
|
||||||
}
|
|
||||||
return image ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
class PatientInfoModel {
|
|
||||||
int? age;
|
|
||||||
String? dateOfBirth;
|
|
||||||
String? firstNameAr;
|
|
||||||
String? firstNameEn;
|
|
||||||
String? secondNameAr;
|
|
||||||
String? secondNameEn;
|
|
||||||
String? thirdNameAr;
|
|
||||||
String? thirdNameEn;
|
|
||||||
String? lastNameAr;
|
|
||||||
String? lastNameEn;
|
|
||||||
String? gender;
|
|
||||||
String? healthId;
|
|
||||||
String? idNumber;
|
|
||||||
String? idType;
|
|
||||||
bool? isHijri;
|
|
||||||
String? maritalStatus;
|
|
||||||
String? maritalStatusCode;
|
|
||||||
String? nationalDateOfBirth;
|
|
||||||
String? nationality;
|
|
||||||
String? nationalityCode;
|
|
||||||
String? occupation;
|
|
||||||
String? patientStatus;
|
|
||||||
String? placeOfBirth;
|
|
||||||
int? messageStatus;
|
|
||||||
String? errorEndUserMessage;
|
|
||||||
String? errorMessage;
|
|
||||||
|
|
||||||
PatientInfoModel({
|
|
||||||
this.age,
|
|
||||||
this.dateOfBirth,
|
|
||||||
this.firstNameAr,
|
|
||||||
this.firstNameEn,
|
|
||||||
this.secondNameAr,
|
|
||||||
this.secondNameEn,
|
|
||||||
this.thirdNameAr,
|
|
||||||
this.thirdNameEn,
|
|
||||||
this.lastNameAr,
|
|
||||||
this.lastNameEn,
|
|
||||||
this.gender,
|
|
||||||
this.healthId,
|
|
||||||
this.idNumber,
|
|
||||||
this.idType,
|
|
||||||
this.isHijri,
|
|
||||||
this.maritalStatus,
|
|
||||||
this.maritalStatusCode,
|
|
||||||
this.nationalDateOfBirth,
|
|
||||||
this.nationality,
|
|
||||||
this.nationalityCode,
|
|
||||||
this.occupation,
|
|
||||||
this.patientStatus,
|
|
||||||
this.placeOfBirth,
|
|
||||||
this.messageStatus,
|
|
||||||
this.errorEndUserMessage,
|
|
||||||
this.errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory PatientInfoModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return PatientInfoModel(
|
|
||||||
age: json['Age'],
|
|
||||||
dateOfBirth: json['DateOfBirth'],
|
|
||||||
firstNameAr: json['FirstNameAr'],
|
|
||||||
firstNameEn: json['FirstNameEn'],
|
|
||||||
secondNameAr: json['SecondNameAr'],
|
|
||||||
secondNameEn: json['SecondNameEn'],
|
|
||||||
thirdNameAr: json['ThirdNameAr'],
|
|
||||||
thirdNameEn: json['ThirdNameEn'],
|
|
||||||
lastNameAr: json['LastNameAr'],
|
|
||||||
lastNameEn: json['LastNameEn'],
|
|
||||||
gender: json['Gender'],
|
|
||||||
healthId: json['HealthId'],
|
|
||||||
idNumber: json['IdNumber'],
|
|
||||||
idType: json['IdType'],
|
|
||||||
isHijri: json['IsHijri'],
|
|
||||||
maritalStatus: json['MaritalStatus'],
|
|
||||||
maritalStatusCode: json['MaritalStatusCode'],
|
|
||||||
nationalDateOfBirth: json['NationalDateOfBirth'],
|
|
||||||
nationality: json['Nationality'],
|
|
||||||
nationalityCode: json['NationalityCode'],
|
|
||||||
occupation: json['Occupation'],
|
|
||||||
patientStatus: json['PatientStatus']?.toString(),
|
|
||||||
placeOfBirth: json['PlaceofBirth'],
|
|
||||||
messageStatus: json['MessageStatus'],
|
|
||||||
errorEndUserMessage: json['ErrorEndUserMessage'],
|
|
||||||
errorMessage: json['ErrorMessage'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'Age': age,
|
|
||||||
'DateOfBirth': dateOfBirth,
|
|
||||||
'FirstNameAr': firstNameAr,
|
|
||||||
'FirstNameEn': firstNameEn,
|
|
||||||
'SecondNameAr': secondNameAr,
|
|
||||||
'SecondNameEn': secondNameEn,
|
|
||||||
'ThirdNameAr': thirdNameAr,
|
|
||||||
'ThirdNameEn': thirdNameEn,
|
|
||||||
'LastNameAr': lastNameAr,
|
|
||||||
'LastNameEn': lastNameEn,
|
|
||||||
'Gender': gender,
|
|
||||||
'HealthId': healthId,
|
|
||||||
'IdNumber': idNumber,
|
|
||||||
'IdType': idType,
|
|
||||||
'IsHijri': isHijri,
|
|
||||||
'MaritalStatus': maritalStatus,
|
|
||||||
'MaritalStatusCode': maritalStatusCode,
|
|
||||||
'NationalDateOfBirth': nationalDateOfBirth,
|
|
||||||
'Nationality': nationality,
|
|
||||||
'NationalityCode': nationalityCode,
|
|
||||||
'Occupation': occupation,
|
|
||||||
'PatientStatus': patientStatus,
|
|
||||||
'PlaceofBirth': placeOfBirth,
|
|
||||||
'MessageStatus': messageStatus,
|
|
||||||
'ErrorEndUserMessage': errorEndUserMessage,
|
|
||||||
'ErrorMessage': errorMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
String get fullNameAr {
|
|
||||||
List<String> parts = [
|
|
||||||
firstNameAr ?? '',
|
|
||||||
secondNameAr ?? '',
|
|
||||||
(thirdNameAr != null && thirdNameAr != '-') ? thirdNameAr! : '',
|
|
||||||
lastNameAr ?? '',
|
|
||||||
].where((s) => s.trim().isNotEmpty && s != '-').toList();
|
|
||||||
return parts.join(' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
String get fullNameEn {
|
|
||||||
List<String> parts = [
|
|
||||||
firstNameEn ?? '',
|
|
||||||
secondNameEn ?? '',
|
|
||||||
(thirdNameEn != null && thirdNameEn != '-') ? thirdNameEn! : '',
|
|
||||||
lastNameEn ?? '',
|
|
||||||
].where((s) => s.trim().isNotEmpty && s != '-').toList();
|
|
||||||
return parts.join(' ').trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,967 +0,0 @@
|
|||||||
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 = "";
|
|
||||||
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() {
|
|
||||||
_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}) async {
|
|
||||||
String targetCategoryId = categoryId ?? (currentCategoryId.isEmpty ? "all" : currentCategoryId);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
isLoading = true;
|
|
||||||
currentPage = 1;
|
|
||||||
hasMore = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
String catParam = (targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId;
|
|
||||||
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
|
|
||||||
print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', page=1, per_page=$pageSize");
|
|
||||||
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
|
|
||||||
categoryId: catParam,
|
|
||||||
page: 1,
|
|
||||||
perPage: pageSize,
|
|
||||||
offerType: offerTypeParam,
|
|
||||||
context: isNearestOffers ? context : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
retailersList = response?.retailers ?? [];
|
|
||||||
currentPage = 1;
|
|
||||||
totalPages = response?.pagesCount ?? 1;
|
|
||||||
hasMore = currentPage < totalPages && (response?.retailers?.isNotEmpty ?? false);
|
|
||||||
|
|
||||||
_runFilter(_searchController.text);
|
|
||||||
} catch (e) {
|
|
||||||
print("Error fetching WalaPlus retailers: $e");
|
|
||||||
retailersList = [];
|
|
||||||
_runFilter(_searchController.text);
|
|
||||||
} 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);
|
|
||||||
|
|
||||||
print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam'");
|
|
||||||
|
|
||||||
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
|
|
||||||
categoryId: catParam,
|
|
||||||
page: nextPage,
|
|
||||||
perPage: pageSize,
|
|
||||||
offerType: offerTypeParam,
|
|
||||||
context: isNearestOffers ? context : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? [];
|
|
||||||
if (newRetailers.isNotEmpty) {
|
|
||||||
currentPage = nextPage;
|
|
||||||
totalPages = response?.pagesCount ?? totalPages;
|
|
||||||
hasMore = currentPage < totalPages;
|
|
||||||
retailersList.addAll(newRetailers);
|
|
||||||
_runFilter(_searchController.text);
|
|
||||||
} 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([fetchCategories(), fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _runFilter([String? query]) {
|
|
||||||
if (query != null) {
|
|
||||||
searchQuery = query;
|
|
||||||
}
|
|
||||||
// Type filtering (online/in store) is done server-side via WP_Offer_Type (see fetchOffers),
|
|
||||||
// so retailersList already reflects the selected type - only search is applied locally here.
|
|
||||||
List<WalaPlusRetailerModel> temp = retailersList;
|
|
||||||
|
|
||||||
if (searchQuery.trim().isNotEmpty) {
|
|
||||||
String q = searchQuery.trim().toLowerCase();
|
|
||||||
temp =
|
|
||||||
temp.where((WalaPlusRetailerModel item) {
|
|
||||||
String name = (item.name ?? "").toLowerCase();
|
|
||||||
String title = (item.offerTitle ?? "").toLowerCase();
|
|
||||||
String desc = (item.description ?? "").toLowerCase();
|
|
||||||
String city = item.displayCity.toLowerCase();
|
|
||||||
return name.contains(q) || title.contains(q) || desc.contains(q) || city.contains(q);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
_filteredRetailersList = temp;
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
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: _runFilter,
|
|
||||||
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))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,551 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:mohem_flutter_app/api/profile_api_client.dart';
|
|
||||||
import 'package:mohem_flutter_app/app_state/app_state.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/get_employee_contacts.model.dart';
|
|
||||||
import 'package:mohem_flutter_app/ui/profile/mazaya_access_result_screen.dart';
|
|
||||||
import 'package:mohem_flutter_app/widgets/bottom_sheets/confirm_bottom_sheet.dart';
|
|
||||||
import 'package:mohem_flutter_app/widgets/button/default_button.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
|
|
||||||
class _ShieldIconPainter extends CustomPainter {
|
|
||||||
final Color color;
|
|
||||||
final bool isGranted;
|
|
||||||
|
|
||||||
_ShieldIconPainter({required this.color, required this.isGranted});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
Paint paint =
|
|
||||||
Paint()
|
|
||||||
..color = color
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 2.0
|
|
||||||
..strokeCap = StrokeCap.round
|
|
||||||
..strokeJoin = StrokeJoin.round;
|
|
||||||
|
|
||||||
double w = size.width;
|
|
||||||
double h = size.height;
|
|
||||||
|
|
||||||
Path path = Path();
|
|
||||||
path.moveTo(w * 0.5, h * 0.1);
|
|
||||||
path.cubicTo(w * 0.72, h * 0.1, w * 0.88, h * 0.18, w * 0.88, h * 0.38);
|
|
||||||
path.cubicTo(w * 0.88, h * 0.65, w * 0.68, h * 0.84, w * 0.5, h * 0.92);
|
|
||||||
path.cubicTo(w * 0.32, h * 0.84, w * 0.12, h * 0.65, w * 0.12, h * 0.38);
|
|
||||||
path.cubicTo(w * 0.12, h * 0.18, w * 0.28, h * 0.1, w * 0.5, h * 0.1);
|
|
||||||
path.close();
|
|
||||||
|
|
||||||
canvas.drawPath(path, paint);
|
|
||||||
|
|
||||||
if (isGranted) {
|
|
||||||
Path checkPath = Path();
|
|
||||||
checkPath.moveTo(w * 0.34, h * 0.49);
|
|
||||||
checkPath.lineTo(w * 0.46, h * 0.61);
|
|
||||||
checkPath.lineTo(w * 0.66, h * 0.39);
|
|
||||||
canvas.drawPath(checkPath, paint);
|
|
||||||
} else {
|
|
||||||
canvas.drawLine(Offset(w * 0.38, h * 0.38), Offset(w * 0.62, h * 0.62), paint);
|
|
||||||
canvas.drawLine(Offset(w * 0.62, h * 0.38), Offset(w * 0.38, h * 0.62), paint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _ShieldIconPainter oldDelegate) => color != oldDelegate.color || isGranted != oldDelegate.isGranted;
|
|
||||||
}
|
|
||||||
|
|
||||||
class DependentDetailsScreen extends StatefulWidget {
|
|
||||||
final GetEmployeeContactsList? contact;
|
|
||||||
|
|
||||||
// Null = just viewing (arrived by tapping the card, no top badge). True/false = this screen
|
|
||||||
// is being opened right after a successful grant/revoke performed elsewhere (e.g. the
|
|
||||||
// dependents list's inline "Allow/Revoke Mazaya Access" link), so the matching badge shows
|
|
||||||
// on first build. Toggling from within this screen itself (_performToggleAccess) sets the
|
|
||||||
// equivalent local state directly instead of needing this.
|
|
||||||
final bool? recentAction;
|
|
||||||
|
|
||||||
const DependentDetailsScreen({Key? key, this.contact, this.recentAction}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_DependentDetailsScreenState createState() => _DependentDetailsScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DependentDetailsScreenState extends State<DependentDetailsScreen> {
|
|
||||||
GetEmployeeContactsList? _contact;
|
|
||||||
bool _argsLoaded = false;
|
|
||||||
|
|
||||||
// null = just viewing (arrived by tapping the card, no badge) - true = just granted here,
|
|
||||||
// false = just revoked here. Set once per toggle in _performToggleAccess; a tap-only visit
|
|
||||||
// never sets it, so the top badge only ever reflects an action just taken on this screen.
|
|
||||||
bool? _recentAction;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
if (!_argsLoaded) {
|
|
||||||
if (widget.contact != null) {
|
|
||||||
_contact = widget.contact;
|
|
||||||
} else {
|
|
||||||
Object? args = ModalRoute.of(context)?.settings.arguments;
|
|
||||||
if (args is GetEmployeeContactsList) {
|
|
||||||
_contact = args;
|
|
||||||
} else if (args is Map && args['contact'] is GetEmployeeContactsList) {
|
|
||||||
_contact = args['contact'] as GetEmployeeContactsList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_recentAction = widget.recentAction;
|
|
||||||
_argsLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isAccessAllowed {
|
|
||||||
if (_contact == null) return false;
|
|
||||||
String? flag = _contact!.mAZAYAACCESSFLG ?? _contact!.mazayaAccess?.toString();
|
|
||||||
return flag?.trim().toUpperCase() == 'Y';
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isNhicVerified {
|
|
||||||
if (_contact == null) return false;
|
|
||||||
return _contact!.pNHICVERIFIEDFLAG?.trim().toUpperCase() == 'Y';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getInitials(String? name) {
|
|
||||||
if (name == null || name.trim().isEmpty) return "D";
|
|
||||||
List<String> parts = name.trim().split(RegExp(r'\s+')).where((String p) => p.isNotEmpty).toList();
|
|
||||||
if (parts.isEmpty) return "D";
|
|
||||||
if (parts.length == 1) {
|
|
||||||
return parts.first.length >= 2 ? parts.first.substring(0, 2).toUpperCase() : parts.first.toUpperCase();
|
|
||||||
}
|
|
||||||
return "${parts.first[0]}${parts.last[0]}".toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getFirstName(String? name) {
|
|
||||||
if (name == null || name.trim().isEmpty) return "";
|
|
||||||
List<String> parts = name.trim().split(RegExp(r'\s+')).where((String p) => p.isNotEmpty).toList();
|
|
||||||
if (parts.isEmpty) return "";
|
|
||||||
return parts.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? _parseBirthDate(String? dateStr) {
|
|
||||||
if (dateStr == null || dateStr.trim().isEmpty) return null;
|
|
||||||
String s = dateStr.trim();
|
|
||||||
|
|
||||||
RegExp dateRegex = RegExp(r'/?Date\((-?\d+)(?:[+-]\d+)?\)/?');
|
|
||||||
Match? match = dateRegex.firstMatch(s);
|
|
||||||
if (match != null) {
|
|
||||||
try {
|
|
||||||
int millis = int.parse(match.group(1)!);
|
|
||||||
DateTime dtUtc = DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
|
|
||||||
return DateTime(dtUtc.year, dtUtc.month, dtUtc.day);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RegExp(r'^-?\d{10,14}$').hasMatch(s)) {
|
|
||||||
try {
|
|
||||||
int millis = int.parse(s);
|
|
||||||
DateTime dtUtc = DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
|
|
||||||
return DateTime(dtUtc.year, dtUtc.month, dtUtc.day);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? dt = DateTime.tryParse(s);
|
|
||||||
if (dt != null) return DateTime(dt.year, dt.month, dt.day);
|
|
||||||
|
|
||||||
List<String> formats = <String>['yyyy-MM-dd', 'dd/MM/yyyy', 'MM/dd/yyyy', 'dd-MM-yyyy', 'yyyy/MM/dd', 'dd-MMM-yyyy', 'dd MMM yyyy'];
|
|
||||||
for (String fmt in formats) {
|
|
||||||
try {
|
|
||||||
DateTime parsed = DateFormat(fmt, 'en_US').parseStrict(s);
|
|
||||||
return DateTime(parsed.year, parsed.month, parsed.day);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDob(DateTime? dob, String? raw) {
|
|
||||||
if (dob != null) {
|
|
||||||
return DateFormat('dd MMM yyyy', 'en_US').format(dob);
|
|
||||||
}
|
|
||||||
if (raw != null && raw.trim().isNotEmpty) {
|
|
||||||
return raw.split(' ').first;
|
|
||||||
}
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatNationalId(String? nationalId) {
|
|
||||||
if (nationalId == null || nationalId.trim().isEmpty) return "-";
|
|
||||||
String clean = nationalId.replaceAll('-', '').trim();
|
|
||||||
String last4 = clean.length >= 4 ? clean.substring(clean.length - 4) : clean;
|
|
||||||
return LocaleKeys.idEnding.tr(args: <String>[last4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onToggleAccess() {
|
|
||||||
if (_contact == null) return;
|
|
||||||
bool isAllowed = _isAccessAllowed;
|
|
||||||
String dependentName = _contact!.cONTACTNAME ?? "";
|
|
||||||
|
|
||||||
ConfirmBottomSheet.show(
|
|
||||||
context,
|
|
||||||
title: isAllowed ? LocaleKeys.revokeMazayaAccess.tr() : LocaleKeys.allowMazayaAccess.tr(),
|
|
||||||
message: isAllowed ? LocaleKeys.confirmRevokeMazayaAccess.tr(args: <String>[dependentName]) : LocaleKeys.confirmGrantMazayaAccess.tr(args: <String>[dependentName]),
|
|
||||||
icon: isAllowed ? Icons.remove_circle_outline : Icons.check_circle_outline,
|
|
||||||
iconColor: isAllowed ? const Color(0xFFDC2626) : const Color(0xFF168A89),
|
|
||||||
confirmText: LocaleKeys.yes.tr(),
|
|
||||||
cancelText: LocaleKeys.no.tr(),
|
|
||||||
onConfirm: _performToggleAccess,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _performToggleAccess() async {
|
|
||||||
if (_contact == null) return;
|
|
||||||
bool isAllowed = _isAccessAllowed;
|
|
||||||
String action = isAllowed ? "Revoke" : "Grant";
|
|
||||||
|
|
||||||
try {
|
|
||||||
Utils.showLoading(context);
|
|
||||||
await ProfileApiClient().processContactMazayaAccess(
|
|
||||||
accessAction: action,
|
|
||||||
contactRelationshipId: _contact!.cONTACTRELATIONSHIPID,
|
|
||||||
contactPersonId: _contact!.cONTACTPERSONID,
|
|
||||||
personId: _contact!.pERSONID,
|
|
||||||
patientIdentificationId: _contact!.nATIONALIDENTIFIER,
|
|
||||||
// DATE_OF_BIRTH, not the separate P_BIRTH_DATE response field - that one stays null
|
|
||||||
// for every contact until NHIC verification happens, so it's never a usable source here.
|
|
||||||
patientBirthDate: _contact!.dATEOFBIRTH,
|
|
||||||
nhicVerifiedFlag: _contact!.pNHICVERIFIEDFLAG,
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_contact!.mAZAYAACCESSFLG = action == "Grant" ? "Y" : "N";
|
|
||||||
_contact!.mazayaAccess = _contact!.mAZAYAACCESSFLG;
|
|
||||||
// Top badge reflects whichever action was just taken on this screen, not just "was
|
|
||||||
// granted at some point" - a later revoke flips it so a stale grant badge never lingers.
|
|
||||||
_recentAction = action == "Grant";
|
|
||||||
});
|
|
||||||
|
|
||||||
Utils.hideLoading(context);
|
|
||||||
|
|
||||||
// Stay on this same screen either way - it already reflects the new access state via
|
|
||||||
// setState above (status badge, top banner, action button, and the share-link section
|
|
||||||
// all update in place), so tapping a dependent card and successfully toggling access
|
|
||||||
// both land on the exact same screen instead of a separate "success" screen. The top
|
|
||||||
// badge itself is the confirmation now, so no toast on top of it.
|
|
||||||
} catch (ex) {
|
|
||||||
Utils.hideLoading(context);
|
|
||||||
Utils.handleException(ex, context, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full (unmasked) DOB for the share message - distinct from _formatDob/_formatNationalId
|
|
||||||
// above, which are for the masked on-screen Key Details display.
|
|
||||||
String _formatDobForShare() {
|
|
||||||
DateTime? dob = _parseBirthDate(_contact?.dATEOFBIRTH);
|
|
||||||
if (dob != null) return DateFormat('dd-MM-yyyy').format(dob);
|
|
||||||
String raw = _contact?.dATEOFBIRTH?.trim() ?? "";
|
|
||||||
if (raw.isEmpty) return "";
|
|
||||||
if (raw.contains('T')) return raw.split('T').first;
|
|
||||||
if (raw.contains(' ')) return raw.split(' ').first;
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same message built for both "Share Download Link" and "Copy Link" - they must always
|
|
||||||
// carry identical content, so this is the single source of truth for it.
|
|
||||||
String _buildMazayaInviteMessage(String linkToShare) {
|
|
||||||
String? nationalId = _contact?.nATIONALIDENTIFIER?.trim();
|
|
||||||
String nationalIdLine = (nationalId == null || nationalId.isEmpty) ? LocaleKeys.nationalIdOrIqamaFallback.tr() : "${LocaleKeys.nationalId.tr()}: $nationalId";
|
|
||||||
return "$linkToShare\n\n${LocaleKeys.mazayaInviteInstructions.tr()}\n\n$nationalIdLine\n${LocaleKeys.dateOfBirth.tr()}: ${_formatDobForShare()}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shares the invite text together with the language-appropriate steps PDF from assets.
|
|
||||||
// Falls back to sharing just the text if the PDF can't be loaded/copied for any reason.
|
|
||||||
Future<void> _shareMazayaInvite(bool isAr, String linkToShare) async {
|
|
||||||
String message = _buildMazayaInviteMessage(linkToShare);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ByteData bytes = await rootBundle.load(MazayaAccessResultScreen.getShareGuidePdfAssetPath(isAr));
|
|
||||||
Directory tempDir = await getTemporaryDirectory();
|
|
||||||
String localFileName = isAr ? "mazaya_access_guide_ar.pdf" : "mazaya_access_guide_en.pdf";
|
|
||||||
File pdfFile = File('${tempDir.path}/$localFileName');
|
|
||||||
await pdfFile.writeAsBytes(bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes));
|
|
||||||
|
|
||||||
await SharePlus.instance.share(ShareParams(text: message, files: [XFile(pdfFile.path)]));
|
|
||||||
} catch (_) {
|
|
||||||
try {
|
|
||||||
await SharePlus.instance.share(ShareParams(text: message));
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
DateTime? dob = _parseBirthDate(_contact?.dATEOFBIRTH);
|
|
||||||
String initials = _getInitials(_contact?.cONTACTNAME);
|
|
||||||
String fullName = _contact?.cONTACTNAME ?? "";
|
|
||||||
String firstName = _getFirstName(_contact?.cONTACTNAME);
|
|
||||||
String relation = (_contact?.rELATIONSHIP != null && _contact!.rELATIONSHIP!.trim().isNotEmpty) ? _contact!.rELATIONSHIP!.trim() : (_contact?.cONTACTTYPE?.trim() ?? "");
|
|
||||||
String formattedId = _formatNationalId(_contact?.nATIONALIDENTIFIER);
|
|
||||||
String formattedDob = _formatDob(dob, _contact?.dATEOFBIRTH);
|
|
||||||
bool isAllowed = _isAccessAllowed;
|
|
||||||
bool isAr = AppState().isArabic(context);
|
|
||||||
String linkToShare = MazayaAccessResultScreen.getDownloadLink();
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: const Color(0xFFF8FAFC),
|
|
||||||
appBar: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(56),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Container(
|
|
||||||
height: 56,
|
|
||||||
color: const Color(0xFFF8FAFC),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
onTap: () => Navigator.pop(context),
|
|
||||||
child: const Padding(padding: EdgeInsets.all(8.0), child: Icon(Icons.arrow_back_ios_new_rounded, size: 20, color: Color(0xFF1E293B))),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
LocaleKeys.dependentDetails.tr().toText20(color: const Color(0xFF1E293B)),
|
|
||||||
const Spacer(),
|
|
||||||
InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
onTap: () => Navigator.popUntil(context, ModalRoute.withName(AppRoutes.dashboard)),
|
|
||||||
child: const Padding(padding: EdgeInsets.all(8.0), child: Icon(Icons.home_outlined, size: 24, color: Color(0xFF1E293B))),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 30),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
// Shown only right after a grant/revoke action taken on this screen - not
|
|
||||||
// when just viewing a dependent by tapping into this screen (_recentAction
|
|
||||||
// is null in that case).
|
|
||||||
if (_recentAction != null) ...<Widget>[
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
decoration: BoxDecoration(color: _recentAction! ? const Color(0xFFE6F7F2) : const Color(0xFFFEECEB), shape: BoxShape.circle),
|
|
||||||
child: Center(
|
|
||||||
child: CustomPaint(
|
|
||||||
size: const Size(36, 36),
|
|
||||||
painter: _ShieldIconPainter(color: _recentAction! ? const Color(0xFF168A89) : const Color(0xFFED1C24), isGranted: _recentAction!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Center(
|
|
||||||
child: (_recentAction! ? LocaleKeys.mazayaAccessGranted.tr() : LocaleKeys.mazayaAccessRevoked.tr()).toText16(
|
|
||||||
weight: FontWeight.w500,
|
|
||||||
color: _recentAction! ? const Color(0xFF168A89) : const Color(0xFFED1C24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Profile Card (Mint Green)
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(color: const Color(0xFFE8F7F2), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFBCE6D7), width: 1.2)),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
// Avatar circle with initials
|
|
||||||
Container(
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
|
||||||
child: Center(child: initials.toText17(color: const Color(0xFF168A89))),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
|
|
||||||
// Dependent Name & Relation
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: <Widget>[
|
|
||||||
fullName.toText16(maxlines: 2, weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
if (relation.isNotEmpty) ...<Widget>[const SizedBox(height: 3), relation.toText12(maxLine: 1, weight: FontWeight.w500, color: const Color(0xFF168A89))],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Verified badge on right - only when NHIC has actually verified this contact
|
|
||||||
if (_isNhicVerified) ...<Widget>[const SizedBox(width: 8), const Icon(Icons.verified_outlined, color: Color(0xFF059669), size: 24)],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Key details Card
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: const Color(0xFFF1F5F9)),
|
|
||||||
boxShadow: const <BoxShadow>[BoxShadow(color: Color(0x08000000), blurRadius: 8, offset: Offset(0, 2))],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.keyDetails.tr().toText16(weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
if (_isNhicVerified) LocaleKeys.verified.tr().toText13(weight: FontWeight.w500, color: const Color(0xFF059669)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
const Divider(height: 1, thickness: 1, color: Color(0xFFF1F5F9)),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.nationalId.tr().toText13(weight: FontWeight.w400, color: const Color(0xFF64748B)),
|
|
||||||
formattedId.toText13(weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.dateOfBirth.tr().toText13(weight: FontWeight.w400, color: const Color(0xFF64748B)),
|
|
||||||
formattedDob.toText13(weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Services & actions section header
|
|
||||||
LocaleKeys.servicesAndActions.tr().toText17(weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
LocaleKeys.startDependentFlowsDesc.tr().toText13(weight: FontWeight.w400, color: const Color(0xFF64748B)),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Mazaya access Card
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: const Color(0xFFF1F5F9)),
|
|
||||||
boxShadow: const <BoxShadow>[BoxShadow(color: Color(0x08000000), blurRadius: 8, offset: Offset(0, 2))],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
// Header Row: Mazaya access + Badge
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.mazayaAccess.tr().toText16(weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
|
||||||
decoration: BoxDecoration(color: isAllowed ? const Color(0xFFECFDF5) : const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: (isAllowed ? LocaleKeys.active.tr() : LocaleKeys.accessOff.tr()).toText12(
|
|
||||||
weight: FontWeight.w500,
|
|
||||||
color: isAllowed ? const Color(0xFF059669) : const Color(0xFFEF4444),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
LocaleKeys.allowDependentMazayaOffersDesc.tr(args: <String>[firstName.isNotEmpty ? firstName : fullName]).toText13(weight: FontWeight.w400, color: const Color(0xFF64748B)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(height: 1, thickness: 1, color: Color(0xFFF1F5F9)),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Login method row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.loginMethod.tr().toText13(weight: FontWeight.w400, color: const Color(0xFF64748B)),
|
|
||||||
LocaleKeys.nationalIdAndDob.tr().toText13(weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Verification row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.verification.tr().toText13(weight: FontWeight.w400, color: const Color(0xFF64748B)),
|
|
||||||
LocaleKeys.sponsorOtp.tr().toText13(weight: FontWeight.w500, color: const Color(0xFF1E293B)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Action Button: Allow Mazaya Access (Teal) or Revoke Mazaya Access (Red Outline)
|
|
||||||
InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
onTap: _onToggleAccess,
|
|
||||||
child: Container(
|
|
||||||
height: 48,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isAllowed ? const Color(0xFFFEF2F2) : const Color(0xFF22B0A6),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: isAllowed ? Border.all(color: const Color(0xFFFECACA), width: 1.2) : null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
SizedBox(width: 18, height: 18, child: CustomPaint(painter: _ShieldIconPainter(color: isAllowed ? const Color(0xFFDC2626) : Colors.white, isGranted: !isAllowed))),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
(isAllowed ? LocaleKeys.revokeMazayaAccess.tr() : LocaleKeys.allowMazayaAccess.tr()).toText16(
|
|
||||||
weight: FontWeight.w500,
|
|
||||||
color: isAllowed ? const Color(0xFFDC2626) : Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (isAllowed) ...<Widget>[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Center(child: LocaleKeys.revokingPreventsAccessDesc.tr().toText11(weight: FontWeight.w400, color: const Color(0xFF64748B), isCenter: true)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Share download link - same bottom-sticky button convention used elsewhere in
|
|
||||||
// the app (e.g. "Join Marathon"), shown once access is granted.
|
|
||||||
if (isAllowed) DefaultButton(LocaleKeys.shareDownloadLink.tr(), () => _shareMazayaInvite(isAr, linkToShare)).insideContainer,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,547 +0,0 @@
|
|||||||
import 'dart:developer';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:mohem_flutter_app/app_state/app_state.dart';
|
|
||||||
import 'package:mohem_flutter_app/classes/utils.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/widgets/button/default_button.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
|
|
||||||
class MazayaAccessResultParams {
|
|
||||||
final bool isGranted;
|
|
||||||
final String dependentName;
|
|
||||||
final String? dependentInitials;
|
|
||||||
final String? downloadUrl;
|
|
||||||
final String? nationalId;
|
|
||||||
final String? dateOfBirth;
|
|
||||||
|
|
||||||
const MazayaAccessResultParams({required this.isGranted, required this.dependentName, this.dependentInitials, this.downloadUrl, this.nationalId, this.dateOfBirth});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ShieldPainter extends CustomPainter {
|
|
||||||
final Color color;
|
|
||||||
final bool isGranted;
|
|
||||||
|
|
||||||
_ShieldPainter({required this.color, required this.isGranted});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
Paint paint =
|
|
||||||
Paint()
|
|
||||||
..color = color
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 2.4
|
|
||||||
..strokeCap = StrokeCap.round
|
|
||||||
..strokeJoin = StrokeJoin.round;
|
|
||||||
|
|
||||||
double w = size.width;
|
|
||||||
double h = size.height;
|
|
||||||
|
|
||||||
// Shield path
|
|
||||||
Path path = Path();
|
|
||||||
path.moveTo(w * 0.5, h * 0.1);
|
|
||||||
path.cubicTo(w * 0.72, h * 0.1, w * 0.88, h * 0.18, w * 0.88, h * 0.38);
|
|
||||||
path.cubicTo(w * 0.88, h * 0.65, w * 0.68, h * 0.84, w * 0.5, h * 0.92);
|
|
||||||
path.cubicTo(w * 0.32, h * 0.84, w * 0.12, h * 0.65, w * 0.12, h * 0.38);
|
|
||||||
path.cubicTo(w * 0.12, h * 0.18, w * 0.28, h * 0.1, w * 0.5, h * 0.1);
|
|
||||||
path.close();
|
|
||||||
|
|
||||||
canvas.drawPath(path, paint);
|
|
||||||
|
|
||||||
if (isGranted) {
|
|
||||||
// Checkmark path
|
|
||||||
Path checkPath = Path();
|
|
||||||
checkPath.moveTo(w * 0.34, h * 0.49);
|
|
||||||
checkPath.lineTo(w * 0.46, h * 0.61);
|
|
||||||
checkPath.lineTo(w * 0.66, h * 0.39);
|
|
||||||
canvas.drawPath(checkPath, paint);
|
|
||||||
} else {
|
|
||||||
// Cross path
|
|
||||||
canvas.drawLine(Offset(w * 0.38, h * 0.38), Offset(w * 0.62, h * 0.62), paint);
|
|
||||||
canvas.drawLine(Offset(w * 0.62, h * 0.38), Offset(w * 0.38, h * 0.62), paint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _ShieldPainter oldDelegate) => color != oldDelegate.color || isGranted != oldDelegate.isGranted;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MazayaAccessResultScreen extends StatelessWidget {
|
|
||||||
final bool isGranted;
|
|
||||||
final String dependentName;
|
|
||||||
final String? dependentInitials;
|
|
||||||
final String? downloadUrl;
|
|
||||||
final String? nationalId;
|
|
||||||
final String? dateOfBirth;
|
|
||||||
|
|
||||||
const MazayaAccessResultScreen({Key? key, this.isGranted = true, this.dependentName = "", this.dependentInitials, this.downloadUrl, this.nationalId, this.dateOfBirth}) : super(key: key);
|
|
||||||
|
|
||||||
static String getDownloadLink() {
|
|
||||||
int channel = AppState().postParamsObject?.channel ?? 31;
|
|
||||||
return channel == 33 ? "https://mohemm.hmg.com/mhmg.html" : "https://mohemm.hmg.com/mohem.html";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel 33 (HMG) gets the HMG-branded guide, everything else gets the CS-branded one -
|
|
||||||
// same channel check as getDownloadLink() - crossed with the current app language.
|
|
||||||
static String getShareGuidePdfAssetPath(bool isAr) {
|
|
||||||
int channel = AppState().postParamsObject?.channel ?? 31;
|
|
||||||
String brand = channel == 33 ? "HMG" : "CS";
|
|
||||||
String lang = isAr ? "ar" : "en";
|
|
||||||
return "assets/pdf/Mazaya Access Guide $brand $lang.pdf";
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getInitials(String? name) {
|
|
||||||
if (name == null || name.trim().isEmpty) return "D";
|
|
||||||
List<String> parts = name.trim().split(RegExp(r'\s+')).where((String p) => p.isNotEmpty).toList();
|
|
||||||
if (parts.isEmpty) return "D";
|
|
||||||
if (parts.length == 1) {
|
|
||||||
return parts.first.length >= 2 ? parts.first.substring(0, 2).toUpperCase() : parts.first.toUpperCase();
|
|
||||||
}
|
|
||||||
return "${parts.first[0]}${parts.last[0]}".toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
bool isAr = AppState().isArabic(context);
|
|
||||||
String fontFamily = isAr ? 'Cairo' : 'Poppins';
|
|
||||||
|
|
||||||
// Resolve arguments if passed via ModalRoute
|
|
||||||
Object? routeArgs = ModalRoute.of(context)?.settings.arguments;
|
|
||||||
bool effectiveGranted = isGranted;
|
|
||||||
String effectiveName = dependentName;
|
|
||||||
String? effectiveInitials = dependentInitials;
|
|
||||||
String? effectiveUrl = downloadUrl;
|
|
||||||
String? effectiveNationalId = nationalId;
|
|
||||||
String? effectiveDateOfBirth = dateOfBirth;
|
|
||||||
|
|
||||||
if (routeArgs is MazayaAccessResultParams) {
|
|
||||||
effectiveGranted = routeArgs.isGranted;
|
|
||||||
effectiveName = routeArgs.dependentName;
|
|
||||||
effectiveInitials = routeArgs.dependentInitials;
|
|
||||||
effectiveUrl = routeArgs.downloadUrl;
|
|
||||||
effectiveNationalId = routeArgs.nationalId;
|
|
||||||
effectiveDateOfBirth = routeArgs.dateOfBirth;
|
|
||||||
} else if (routeArgs is Map<String, dynamic>) {
|
|
||||||
effectiveGranted = routeArgs['isGranted'] ?? isGranted;
|
|
||||||
effectiveName = routeArgs['dependentName'] ?? dependentName;
|
|
||||||
effectiveInitials = routeArgs['dependentInitials'] ?? dependentInitials;
|
|
||||||
effectiveUrl = routeArgs['downloadUrl'] ?? downloadUrl;
|
|
||||||
effectiveNationalId = routeArgs['nationalId'] ?? nationalId;
|
|
||||||
effectiveDateOfBirth = routeArgs['dateOfBirth'] ?? dateOfBirth;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveName.trim().isEmpty) {
|
|
||||||
String fallback = AppState().dependentName ?? "";
|
|
||||||
if (fallback.isNotEmpty) {
|
|
||||||
effectiveName = fallback;
|
|
||||||
} else {
|
|
||||||
effectiveName = isAr ? "التابع" : "Dependent";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String linkToShare = effectiveUrl ?? getDownloadLink();
|
|
||||||
String initials = effectiveInitials ?? _getInitials(effectiveName);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
appBar: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(56),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Container(
|
|
||||||
height: 56,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
onTap: () => Navigator.pop(context),
|
|
||||||
child: const Padding(padding: EdgeInsets.all(8.0), child: Icon(Icons.arrow_back_ios_new_rounded, size: 20, color: Color(0xFF1E293B))),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
LocaleKeys.mazayaTitle.tr().toText20(isBold: true, color: const Color(0xFF1E293B)),
|
|
||||||
const Spacer(),
|
|
||||||
InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
onTap: () => Navigator.pop(context),
|
|
||||||
child: const Padding(padding: EdgeInsets.all(8.0), child: Icon(Icons.close_rounded, size: 24, color: Color(0xFF1E293B))),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
||||||
child:
|
|
||||||
effectiveGranted ? _buildGrantedContent(context, isAr, fontFamily, effectiveName, initials, linkToShare) : _buildRevokedContent(context, isAr, fontFamily, effectiveName, initials),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_buildBottomButtons(context, isAr, fontFamily, effectiveGranted, linkToShare, effectiveNationalId ?? "", effectiveDateOfBirth ?? ""),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildGrantedContent(BuildContext context, bool isAr, String fontFamily, String name, String initials, String linkToShare) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Shield checkmark badge
|
|
||||||
Container(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
decoration: const BoxDecoration(color: Color(0xFFE6F7F2), shape: BoxShape.circle),
|
|
||||||
child: Center(child: CustomPaint(size: const Size(40, 40), painter: _ShieldPainter(color: const Color(0xFF168A89), isGranted: true))),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
|
|
||||||
// Title
|
|
||||||
LocaleKeys.mazayaAccessGranted.tr().toText22(isBold: true, isCentered: true, color: const Color(0xFF1E293B)),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Description
|
|
||||||
LocaleKeys.mazayaAccessGrantedDesc.tr(args: <String>[name]).toText14(isCenter: true, color: const Color(0xFF64748B)),
|
|
||||||
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
|
|
||||||
// Access is now active banner
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(color: const Color(0xFFE8F8F0), borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFCEEFE7), width: 1)),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
const Padding(padding: EdgeInsets.only(top: 2), child: Icon(Icons.check_circle_outline_rounded, color: Color(0xFF168A89), size: 20)),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.accessIsNowActive.tr().toText14(isBold: true, color: const Color(0xFF168A89)),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
LocaleKeys.accessIsNowActiveDesc.tr(args: <String>[name]).toText12(color: const Color(0xFF64748B)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Dependent Card
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(color: const Color(0xFFF1F5F9)),
|
|
||||||
boxShadow: const <BoxShadow>[BoxShadow(color: Color(0x08000000), blurRadius: 10, offset: Offset(0, 3))],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
decoration: const BoxDecoration(color: Color(0xFFE6F7F2), shape: BoxShape.circle),
|
|
||||||
child: Center(child: initials.toText14(isBold: true, color: const Color(0xFF168A89))),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
name.toText14(isBold: true, maxlines: 1, color: const Color(0xFF1E293B)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
LocaleKeys.dependentAccessActive.tr().toText12(isBold: true, color: const Color(0xFF168A89)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.lock_open_rounded, color: Color(0xFF168A89), size: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Shareable App-Download Link Card
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFE2E8F0))),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(color: const Color(0xFFE8F8F0), borderRadius: BorderRadius.circular(10)),
|
|
||||||
child: const Center(child: Icon(Icons.smartphone_rounded, color: Color(0xFF168A89), size: 20)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.shareableAppDownloadLink.tr().toText14(isBold: true, color: const Color(0xFF1E293B)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
LocaleKeys.shareableAppDownloadLinkDesc.tr(args: <String>[name]).toText12(color: const Color(0xFF64748B)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFE2E8F0))),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.downloadLink.tr().toText11(isBold: true, color: const Color(0xFF168A89)),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
linkToShare.toText12(maxLine: 1, color: const Color(0xFF334155)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRevokedContent(BuildContext context, bool isAr, String fontFamily, String name, String initials) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Shield bad badge
|
|
||||||
Container(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
decoration: const BoxDecoration(color: Color(0xFFFEECEB), shape: BoxShape.circle),
|
|
||||||
child: Center(child: CustomPaint(size: const Size(40, 40), painter: _ShieldPainter(color: const Color(0xFFED1C24), isGranted: false))),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
|
|
||||||
// Title
|
|
||||||
LocaleKeys.mazayaAccessRevoked.tr().toText22(isBold: true, isCentered: true, color: const Color(0xFF1E293B)),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Description
|
|
||||||
LocaleKeys.mazayaAccessRevokedDesc.tr().toText14(isCenter: true, color: const Color(0xFF64748B)),
|
|
||||||
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
|
|
||||||
// Contact your sponsor banner
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(color: const Color(0xFFFFF5F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFFFCDD2), width: 1)),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
const Padding(padding: EdgeInsets.only(top: 2), child: Icon(Icons.phone_outlined, color: Color(0xFFED1C24), size: 20)),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
LocaleKeys.contactYourSponsor.tr().toText14(isBold: true, color: const Color(0xFFED1C24)),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
LocaleKeys.contactYourSponsorDesc.tr().toText12(color: const Color(0xFF64748B)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Dependent Card
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(color: const Color(0xFFF1F5F9)),
|
|
||||||
boxShadow: const <BoxShadow>[BoxShadow(color: Color(0x08000000), blurRadius: 10, offset: Offset(0, 3))],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
decoration: const BoxDecoration(color: Color(0xFFFFF1F0), shape: BoxShape.circle),
|
|
||||||
child: Center(child: initials.toText14(isBold: true, color: const Color(0xFFED1C24))),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
name.toText14(isBold: true, maxlines: 1, color: const Color(0xFF1E293B)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
LocaleKeys.dependentAccessBlocked.tr().toText12(isBold: true, color: const Color(0xFFED1C24)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.lock_outline_rounded, color: Color(0xFFED1C24), size: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display-only DOB formatting for the share message: "dd-MM-yyyy", no time component.
|
|
||||||
// The raw value can come in as the API's "/Date(ms+tz)/" wrapper or a plain ISO datetime string
|
|
||||||
// with a time component - either way we only want the date part.
|
|
||||||
String _formatDob(String raw) {
|
|
||||||
String s = raw.trim();
|
|
||||||
if (s.isEmpty) return "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
RegExp aspNetDateRegex = RegExp(r'/Date\((-?\d+)([+-]\d{4})?\)/');
|
|
||||||
Match? match = aspNetDateRegex.firstMatch(s);
|
|
||||||
if (match != null) {
|
|
||||||
int ms = int.parse(match.group(1)!);
|
|
||||||
return DateFormat('dd-MM-yyyy').format(DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true));
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? parsed = DateTime.tryParse(s);
|
|
||||||
if (parsed != null) {
|
|
||||||
return DateFormat('dd-MM-yyyy').format(parsed);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// Fallback: strip a time component off a plain "yyyy-MM-dd HH:mm:ss" / "yyyy-MM-ddTHH:mm:ss"
|
|
||||||
// string we couldn't otherwise parse, rather than showing it as-is with the time attached.
|
|
||||||
if (s.contains('T')) return s.split('T').first;
|
|
||||||
if (s.contains(' ')) return s.split(' ').first;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same message built for both "Share Download Link" and "Copy Link" - they must always
|
|
||||||
// carry identical content, so this is the single source of truth for it.
|
|
||||||
String _buildMazayaInviteMessage(String linkToShare, String nationalId, String dateOfBirth) {
|
|
||||||
String nationalIdLine = nationalId.trim().isEmpty ? LocaleKeys.nationalIdOrIqamaFallback.tr() : "${LocaleKeys.nationalId.tr()}: $nationalId";
|
|
||||||
return "$linkToShare\n\n${LocaleKeys.mazayaInviteInstructions.tr()}\n\n$nationalIdLine\n${LocaleKeys.dateOfBirth.tr()}: ${_formatDob(dateOfBirth)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shares the invite text together with the language-appropriate steps PDF from assets.
|
|
||||||
// Falls back to sharing just the text if the PDF can't be loaded/copied for any reason.
|
|
||||||
Future<void> _shareMazayaInvite(bool isAr, String linkToShare, String nationalId, String dateOfBirth) async {
|
|
||||||
String message = _buildMazayaInviteMessage(linkToShare, nationalId, dateOfBirth);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ByteData bytes = await rootBundle.load(getShareGuidePdfAssetPath(isAr));
|
|
||||||
Directory tempDir = await getTemporaryDirectory();
|
|
||||||
// Write to a space-free filename locally - the asset path above is what actually matters.
|
|
||||||
String localFileName = isAr ? "mazaya_access_guide_ar.pdf" : "mazaya_access_guide_en.pdf";
|
|
||||||
File pdfFile = File('${tempDir.path}/$localFileName');
|
|
||||||
await pdfFile.writeAsBytes(bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes));
|
|
||||||
|
|
||||||
log("final: $message\n\nfile: ${pdfFile.path}");
|
|
||||||
await SharePlus.instance.share(ShareParams(text: message, files: [XFile(pdfFile.path)]));
|
|
||||||
} catch (_) {
|
|
||||||
try {
|
|
||||||
await SharePlus.instance.share(ShareParams(text: message));
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBottomButtons(BuildContext context, bool isAr, String fontFamily, bool granted, String linkToShare, String nationalId, String dateOfBirth) {
|
|
||||||
if (granted) {
|
|
||||||
return Container(
|
|
||||||
color: Colors.white,
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: <Widget>[
|
|
||||||
DefaultButton(
|
|
||||||
LocaleKeys.shareDownloadLink.tr(),
|
|
||||||
() => _shareMazayaInvite(isAr, linkToShare, nationalId, dateOfBirth),
|
|
||||||
trailingIconData: isAr ? Icons.arrow_back_rounded : Icons.arrow_forward_rounded,
|
|
||||||
fontSize: 14,
|
|
||||||
isTextExpanded: false,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
DefaultButton(
|
|
||||||
LocaleKeys.copyLink.tr(),
|
|
||||||
() {
|
|
||||||
Clipboard.setData(ClipboardData(text: _buildMazayaInviteMessage(linkToShare, nationalId, dateOfBirth)));
|
|
||||||
Utils.showToast(LocaleKeys.linkCopied.tr());
|
|
||||||
},
|
|
||||||
colors: const <Color>[Colors.white, Colors.white],
|
|
||||||
border: Border.all(color: const Color(0xFFCBD5E1)),
|
|
||||||
textColor: const Color(0xFF168A89),
|
|
||||||
fontSize: 14,
|
|
||||||
isTextExpanded: false,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
DefaultButton(
|
|
||||||
LocaleKeys.done.tr(),
|
|
||||||
() => Navigator.pop(context),
|
|
||||||
colors: const <Color>[Colors.white, Colors.white],
|
|
||||||
border: Border.all(color: const Color(0xFFCBD5E1)),
|
|
||||||
textColor: const Color(0xFF334155),
|
|
||||||
fontSize: 14,
|
|
||||||
isTextExpanded: false,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Container(
|
|
||||||
color: Colors.white,
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
|
||||||
child: DefaultButton(
|
|
||||||
LocaleKeys.returnToSignIn.tr(),
|
|
||||||
() => Navigator.pop(context),
|
|
||||||
colors: const <Color>[Colors.white, Colors.white],
|
|
||||||
border: Border.all(color: const Color(0xFFCEEFE7), width: 1.2),
|
|
||||||
textColor: const Color(0xFF168A89),
|
|
||||||
iconData: isAr ? Icons.arrow_forward_rounded : Icons.arrow_back_rounded,
|
|
||||||
fontSize: 14,
|
|
||||||
isTextExpanded: false,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import 'package:mohem_flutter_app/classes/consts.dart';
|
|
||||||
import 'package:mohem_flutter_app/config/routes.dart';
|
|
||||||
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
|
|
||||||
import 'package:mohem_flutter_app/widgets/app_bar_widget.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
|
|
||||||
class AppInAppWebViewScreen extends StatefulWidget {
|
|
||||||
final String url;
|
|
||||||
final String? title;
|
|
||||||
final void Function(InAppWebViewController controller)? onPageLoaded;
|
|
||||||
|
|
||||||
const AppInAppWebViewScreen({
|
|
||||||
Key? key,
|
|
||||||
required this.url,
|
|
||||||
this.title,
|
|
||||||
this.onPageLoaded,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AppInAppWebViewScreen> createState() => _AppInAppWebViewScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AppInAppWebViewScreenState extends State<AppInAppWebViewScreen> {
|
|
||||||
InAppWebViewController? _webViewController;
|
|
||||||
double _progress = 0;
|
|
||||||
String _title = "";
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_title = widget.title ?? "";
|
|
||||||
_requestCameraPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _requestCameraPermission() async {
|
|
||||||
try {
|
|
||||||
PermissionStatus cameraStatus = await Permission.camera.status;
|
|
||||||
if (!cameraStatus.isGranted) {
|
|
||||||
await Permission.camera.request();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error requesting camera permission: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onHomeTapped() {
|
|
||||||
if (ApiConsts.isDependetLogin) {
|
|
||||||
Navigator.popUntil(context, ModalRoute.withName(AppRoutes.dashboardScreenForDependet));
|
|
||||||
} else {
|
|
||||||
Navigator.popUntil(context, ModalRoute.withName(AppRoutes.dashboard));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleBack() async {
|
|
||||||
if (_webViewController != null && await _webViewController!.canGoBack()) {
|
|
||||||
await _webViewController!.goBack();
|
|
||||||
} else {
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
String displayTitle = _title.isNotEmpty ? _title : LocaleKeys.offerDetails.tr();
|
|
||||||
|
|
||||||
return PopScope(
|
|
||||||
canPop: false,
|
|
||||||
onPopInvokedWithResult: (bool didPop, Object? result) async {
|
|
||||||
if (didPop) return;
|
|
||||||
await _handleBack();
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
appBar: AppBarWidget(
|
|
||||||
context,
|
|
||||||
title: displayTitle,
|
|
||||||
showHomeButton: true,
|
|
||||||
onHomeTapped: _onHomeTapped,
|
|
||||||
onBackTapped: () async {
|
|
||||||
await _handleBack();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: Stack(
|
|
||||||
children: <Widget>[
|
|
||||||
InAppWebView(
|
|
||||||
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
|
|
||||||
initialSettings: InAppWebViewSettings(
|
|
||||||
javaScriptEnabled: true,
|
|
||||||
domStorageEnabled: true,
|
|
||||||
supportZoom: true,
|
|
||||||
mediaPlaybackRequiresUserGesture: false,
|
|
||||||
allowsInlineMediaPlayback: true,
|
|
||||||
javaScriptCanOpenWindowsAutomatically: true,
|
|
||||||
transparentBackground: false,
|
|
||||||
useShouldOverrideUrlLoading: false,
|
|
||||||
cacheEnabled: true,
|
|
||||||
clearCache: false,
|
|
||||||
),
|
|
||||||
onWebViewCreated: (InAppWebViewController controller) {
|
|
||||||
_webViewController = controller;
|
|
||||||
},
|
|
||||||
onPermissionRequest: (InAppWebViewController controller, PermissionRequest permissionRequest) async {
|
|
||||||
debugPrint("InAppWebView onPermissionRequest: ${permissionRequest.resources}");
|
|
||||||
bool isGranted = false;
|
|
||||||
try {
|
|
||||||
PermissionStatus status = await Permission.camera.status;
|
|
||||||
isGranted = status.isGranted;
|
|
||||||
// IMPORTANT: never await Permission.camera.request() here. The native WebView
|
|
||||||
// is blocked waiting on THIS callback's response to the page's getUserMedia()
|
|
||||||
// call - if we hold it open for however long the human takes to respond to the
|
|
||||||
// OS dialog (can be 30s+), Chromium abandons the original getUserMedia() call
|
|
||||||
// as timed out before we ever answer. So: answer immediately with whatever the
|
|
||||||
// status already is, and if not granted, kick off the slow dialog in the
|
|
||||||
// background. Once that resolves, retry the click trigger so the page makes a
|
|
||||||
// FRESH getUserMedia() call - which resolves instantly next time since the OS
|
|
||||||
// permission is already durably granted by then (no dialog, no timeout risk).
|
|
||||||
if (!isGranted) {
|
|
||||||
unawaited(
|
|
||||||
Permission.camera.request().then((PermissionStatus result) {
|
|
||||||
if (result.isGranted) {
|
|
||||||
widget.onPageLoaded?.call(controller);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error checking camera permission: $e");
|
|
||||||
}
|
|
||||||
return PermissionResponse(
|
|
||||||
resources: permissionRequest.resources,
|
|
||||||
action: isGranted ? PermissionResponseAction.GRANT : PermissionResponseAction.DENY,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onProgressChanged: (InAppWebViewController controller, int progress) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_progress = progress / 100;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTitleChanged: (InAppWebViewController controller, String? title) {
|
|
||||||
if (widget.title == null || widget.title!.isEmpty) {
|
|
||||||
if (title != null && title.isNotEmpty && !title.startsWith("http")) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_title = title;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLoadStop: (InAppWebViewController controller, WebUri? url) async {
|
|
||||||
widget.onPageLoaded?.call(controller);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (_progress < 1.0)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: _progress,
|
|
||||||
color: const Color(0xFF168A89),
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
minHeight: 2.5,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:mohem_flutter_app/classes/colors.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/widgets/button/default_button.dart';
|
|
||||||
|
|
||||||
/// Generic, fully customizable confirmation bottom sheet matching the app's visual theme
|
|
||||||
/// (same handle bar / rounded-top-sheet / gradient button look used across other sheets).
|
|
||||||
/// Everything is driven by the caller - title/message/button text/colors/icon/callbacks -
|
|
||||||
/// with sensible defaults so a minimal call still looks consistent with the rest of the app.
|
|
||||||
class ConfirmBottomSheet extends StatelessWidget {
|
|
||||||
final String? title;
|
|
||||||
final String message;
|
|
||||||
final String? confirmText;
|
|
||||||
final String? cancelText;
|
|
||||||
final VoidCallback onConfirm;
|
|
||||||
final VoidCallback? onCancel;
|
|
||||||
final bool isDestructive;
|
|
||||||
final bool showCancelButton;
|
|
||||||
final IconData? icon;
|
|
||||||
final Color? iconColor;
|
|
||||||
final Color? iconBackgroundColor;
|
|
||||||
final List<Color>? confirmButtonColors;
|
|
||||||
final Color? confirmButtonTextColor;
|
|
||||||
final Color? cancelButtonColor;
|
|
||||||
final Color? cancelButtonTextColor;
|
|
||||||
|
|
||||||
const ConfirmBottomSheet({
|
|
||||||
Key? key,
|
|
||||||
this.title,
|
|
||||||
required this.message,
|
|
||||||
this.confirmText,
|
|
||||||
this.cancelText,
|
|
||||||
required this.onConfirm,
|
|
||||||
this.onCancel,
|
|
||||||
this.isDestructive = false,
|
|
||||||
this.showCancelButton = true,
|
|
||||||
this.icon,
|
|
||||||
this.iconColor,
|
|
||||||
this.iconBackgroundColor,
|
|
||||||
this.confirmButtonColors,
|
|
||||||
this.confirmButtonTextColor,
|
|
||||||
this.cancelButtonColor,
|
|
||||||
this.cancelButtonTextColor,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
/// Convenience launcher - mirrors Utils.confirmDialog's call shape but as a bottom sheet.
|
|
||||||
static Future<void> show(
|
|
||||||
BuildContext context, {
|
|
||||||
String? title,
|
|
||||||
required String message,
|
|
||||||
String? confirmText,
|
|
||||||
String? cancelText,
|
|
||||||
required VoidCallback onConfirm,
|
|
||||||
VoidCallback? onCancel,
|
|
||||||
bool isDestructive = false,
|
|
||||||
bool showCancelButton = true,
|
|
||||||
IconData? icon,
|
|
||||||
Color? iconColor,
|
|
||||||
Color? iconBackgroundColor,
|
|
||||||
List<Color>? confirmButtonColors,
|
|
||||||
Color? confirmButtonTextColor,
|
|
||||||
Color? cancelButtonColor,
|
|
||||||
Color? cancelButtonTextColor,
|
|
||||||
}) {
|
|
||||||
return showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (BuildContext sheetContext) {
|
|
||||||
return ConfirmBottomSheet(
|
|
||||||
title: title,
|
|
||||||
message: message,
|
|
||||||
confirmText: confirmText,
|
|
||||||
cancelText: cancelText,
|
|
||||||
onConfirm: onConfirm,
|
|
||||||
onCancel: onCancel,
|
|
||||||
isDestructive: isDestructive,
|
|
||||||
showCancelButton: showCancelButton,
|
|
||||||
icon: icon,
|
|
||||||
iconColor: iconColor,
|
|
||||||
iconBackgroundColor: iconBackgroundColor,
|
|
||||||
confirmButtonColors: confirmButtonColors,
|
|
||||||
confirmButtonTextColor: confirmButtonTextColor,
|
|
||||||
cancelButtonColor: cancelButtonColor,
|
|
||||||
cancelButtonTextColor: cancelButtonTextColor,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Color effectiveIconColor = iconColor ?? (isDestructive ? MyColors.redColor : MyColors.textMixColor);
|
|
||||||
Color effectiveIconBg = iconBackgroundColor ?? effectiveIconColor.withOpacity(0.1);
|
|
||||||
List<Color> effectiveConfirmColors = confirmButtonColors ?? (isDestructive ? const [MyColors.redA3Color, MyColors.redColor] : const [MyColors.gradiantEndColor, MyColors.gradiantStartColor]);
|
|
||||||
|
|
||||||
return SafeArea(
|
|
||||||
top: false,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
|
||||||
decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24))),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Drag handle
|
|
||||||
Center(
|
|
||||||
child: Container(width: 44, height: 4.5, margin: const EdgeInsets.only(bottom: 18), decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5))),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (icon != null) ...[
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(color: effectiveIconBg, shape: BoxShape.circle),
|
|
||||||
child: Icon(icon, color: effectiveIconColor, size: 28),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
16.height,
|
|
||||||
],
|
|
||||||
|
|
||||||
(title ?? LocaleKeys.confirm.tr()).toText18(isBold: true, isCentered: true, color: MyColors.darkTextColor),
|
|
||||||
8.height,
|
|
||||||
message.toText14(color: MyColors.grey70Color, textAlign: TextAlign.center),
|
|
||||||
24.height,
|
|
||||||
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (showCancelButton) ...[
|
|
||||||
Expanded(
|
|
||||||
child: DefaultButton(
|
|
||||||
cancelText ?? LocaleKeys.cancel.tr(),
|
|
||||||
() {
|
|
||||||
Navigator.pop(context);
|
|
||||||
onCancel?.call();
|
|
||||||
},
|
|
||||||
colors: [cancelButtonColor ?? MyColors.lightGreyEAColor, cancelButtonColor ?? MyColors.lightGreyEAColor],
|
|
||||||
textColor: cancelButtonTextColor ?? MyColors.darkTextColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
12.width,
|
|
||||||
],
|
|
||||||
Expanded(
|
|
||||||
child: DefaultButton(
|
|
||||||
confirmText ?? LocaleKeys.ok.tr(),
|
|
||||||
() {
|
|
||||||
Navigator.pop(context);
|
|
||||||
onConfirm();
|
|
||||||
},
|
|
||||||
colors: effectiveConfirmColors,
|
|
||||||
textColor: confirmButtonTextColor ?? Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||