Compare commits

...

10 Commits

Author SHA1 Message Date
WaseemAbbasi22 84fe851ec8 foreground calling implemented and working fine 4 weeks ago
WaseemAbbasi22 ea0087e6f9 foreground calling implemented and working fine 4 weeks ago
WaseemAbbasi22 71a2be931f foreground calling implemented 4 weeks ago
WaseemAbbasi22 90b0f177af restuctured flow 1 month ago
WaseemAbbasi22 c8674f6bf0 restuctured flow 1 month ago
WaseemAbbasi22 518ab82814 inprogess 1 month ago
WaseemAbbasi22 79b6d4fa2b improvements 1 month ago
WaseemAbbasi22 8aa40f7b8a one to one calling implemented 1 month ago
WaseemAbbasi22 e9df60bd48 audio call in progress 1 month ago
WaseemAbbasi22 a5d129970b calling in progress 1 month ago

File diff suppressed because it is too large Load Diff

@ -18,6 +18,16 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- CallKit/ConnectionService permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

@ -0,0 +1,4 @@
<svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.3018 3.01044C21.3189 3.01044 15.6581 8.67122 15.6581 15.6542C15.6581 22.6371 21.3189 28.2979 28.3018 28.2979C35.2847 28.2979 40.9455 22.6371 40.9455 15.6542C40.9455 8.67122 35.2847 3.01044 28.3018 3.01044Z" fill="white"/>
<path d="M43.2934 36.3107C42.9057 36.0918 42.563 35.8983 42.2852 35.733C33.7257 30.6362 22.8782 30.6362 14.3189 35.733C14.0388 35.8997 13.6927 36.095 13.301 36.3161C11.5843 37.2845 8.9893 38.7485 7.21154 40.4885C6.09968 41.5768 5.04327 43.011 4.8512 44.7681C4.64698 46.6365 5.4621 48.39 7.09741 49.9479C9.91865 52.6356 13.3042 54.7897 17.6833 54.7897H38.9207C43.2998 54.7897 46.6854 52.6356 49.5066 49.9479C51.1418 48.39 51.957 46.6365 51.7528 44.7681C51.5606 43.011 50.5043 41.5768 49.3924 40.4885C47.6146 38.7485 45.0196 37.2845 43.3029 36.3161L43.2934 36.3107Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 918 B

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8065 1.16447C14.2759 1.01134 13.7216 1.31736 13.5685 1.84799C13.4154 2.37863 13.7214 2.93292 14.252 3.08605C17.4591 4.01154 19.9889 6.54122 20.9145 9.74819C21.0676 10.2788 21.6219 10.5848 22.1526 10.4317C22.6832 10.2785 22.9892 9.72421 22.836 9.19358C21.7199 5.32634 18.6739 2.28049 14.8065 1.16447Z" fill="#3B3D4A"/>
<path d="M14.2661 6.03666C13.7578 5.82079 13.1707 6.05789 12.9548 6.56624C12.7389 7.07459 12.976 7.66169 13.4844 7.87756C14.6692 8.38068 15.6199 9.33137 16.123 10.5161C16.3389 11.0245 16.926 11.2616 17.4343 11.0457C17.9427 10.8298 18.1798 10.2427 17.9639 9.73438C17.2582 8.07269 15.9278 6.74231 14.2661 6.03666Z" fill="#3B3D4A"/>
<path d="M6.49064 2.4124C6.21024 1.90937 5.75895 1.52863 5.19226 1.41151C4.61966 1.29316 4.04411 1.46773 3.57108 1.85327C1.85471 3.25218 0.76188 5.49346 1.23618 7.84468C1.52772 9.28994 2.01343 10.7159 3.00255 12.4406C4.98763 15.902 8.0947 19.0108 11.5601 20.9981C13.2848 21.9873 14.7108 22.473 16.156 22.7645C18.5072 23.2388 20.7485 22.146 22.1474 20.4296C22.533 19.9566 22.7075 19.381 22.5892 18.8084C22.4721 18.2417 22.0913 17.7904 21.5883 17.5101L19.9857 16.6168C19.3956 16.2878 18.8989 16.0109 18.4672 15.8301C18.0092 15.6383 17.5575 15.5244 17.0581 15.5765C16.5588 15.6286 16.1403 15.8332 15.7318 16.1154C15.3466 16.3814 14.9177 16.7548 14.4081 17.1985L12.8998 18.5116C12.6296 18.7469 12.4944 18.8645 12.3255 18.8753C12.1566 18.886 12.0124 18.7896 11.7238 18.5967C9.26273 16.9518 7.04759 14.7358 5.404 12.2768C5.21116 11.9882 5.11474 11.844 5.12549 11.6751C5.13624 11.5062 5.25389 11.3711 5.48917 11.1008L6.80218 9.59261C7.24588 9.08298 7.61928 8.65411 7.88529 8.26893C8.16746 7.86034 8.37211 7.4419 8.42419 6.94254C8.47626 6.44318 8.36235 5.99152 8.17054 5.53351C7.98973 5.10174 7.71286 4.60505 7.38385 4.01485L6.49064 2.4124Z" fill="#3B3D4A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.5368 4.99141C18.3737 3.89234 13.6254 3.89106 9.45743 4.99141C7.37923 5.54005 5.91035 6.24306 4.57538 7.10677C2.35643 8.54243 1.45543 11.0949 1.70423 13.473C1.77726 14.1709 2.11459 14.7881 2.69607 15.1604C3.2673 15.5262 3.95202 15.5781 4.5849 15.4029L6.48667 14.8765C7.17775 14.6852 7.7732 14.5204 8.24752 14.3312C8.75523 14.1286 9.21756 13.8638 9.58726 13.4198C9.95867 12.9739 10.1323 12.4724 10.2327 11.9384C10.3257 11.4435 10.3686 10.8386 10.418 10.1428L10.5712 7.98564C10.5919 7.69432 10.6022 7.54866 10.6933 7.44677C10.7843 7.34488 10.9247 7.31887 11.2056 7.26684C14.3271 6.68853 17.6695 6.68902 20.7886 7.26685C21.0694 7.31888 21.2099 7.34489 21.3009 7.44678C21.392 7.54867 21.4023 7.69433 21.423 7.98565L21.5762 10.1428C21.6256 10.8386 21.6685 11.4435 21.7615 11.9384C21.8619 12.4724 22.0355 12.9739 22.4069 13.4198C22.7766 13.8638 23.239 14.1286 23.7467 14.3312C24.221 14.5204 24.8165 14.6852 25.5076 14.8765L27.4093 15.4029C28.0422 15.5781 28.7269 15.5262 29.2981 15.1604C29.8796 14.7881 30.2169 14.1709 30.29 13.473C30.5388 11.0949 29.6378 8.54243 27.4188 7.10677C26.0838 6.24305 24.615 5.54005 22.5368 4.99141Z" fill="white"/>
<path d="M16.0036 14.5C16.74 14.5 17.3369 15.0969 17.3369 15.8333V21.8333L18.1136 21.8333C18.3478 21.8332 18.6354 21.8329 18.8706 21.8624L18.8751 21.8629C19.0437 21.884 19.8117 21.9797 20.1775 22.7339C20.5441 23.4896 20.1416 24.1565 20.0545 24.3008L20.0512 24.3061C19.9283 24.5102 19.7494 24.738 19.6023 24.9254L19.5706 24.9657C19.1778 25.4668 18.6685 26.1125 18.1613 26.6338C17.9082 26.8939 17.6185 27.1623 17.3106 27.3741C17.0369 27.5623 16.57 27.8333 15.9958 27.8333C15.4215 27.8333 14.9546 27.5623 14.6809 27.3741C14.3731 27.1623 14.0834 26.8939 13.8303 26.6338C13.323 26.1125 12.8137 25.4668 12.4209 24.9657L12.3893 24.9254C12.2421 24.738 12.0633 24.5102 11.9403 24.3061L11.9371 24.3008C11.85 24.1565 11.4474 23.4896 11.814 22.7339C12.1798 21.9797 12.9479 21.884 13.1165 21.8629L13.1209 21.8624C13.3562 21.8329 13.6437 21.8332 13.8779 21.8333L14.6702 21.8333V15.8333C14.6702 15.0969 15.2672 14.5 16.0036 14.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.29289 1.29289C1.68342 0.902369 2.31658 0.902369 2.70711 1.29289L22.7071 21.2929C23.0976 21.6834 23.0976 22.3166 22.7071 22.7071C22.3166 23.0976 21.6834 23.0976 21.2929 22.7071L16.9406 18.3549C15.7867 19.137 14.4337 19.6528 12.9722 19.8126V20.8269H14.9167C15.4536 20.8269 15.8889 21.2574 15.8889 21.7885C15.8889 22.3195 15.4536 22.75 14.9167 22.75H9.08333C8.54639 22.75 8.11111 22.3195 8.11111 21.7885C8.11111 21.2574 8.54639 20.8269 9.08333 20.8269H11.0278V19.8126C6.65283 19.3343 3.25 15.6659 3.25 11.2115C3.25 10.6805 3.68528 10.25 4.22222 10.25C4.75917 10.25 5.19444 10.6805 5.19444 11.2115C5.19444 14.9288 8.2414 17.9423 12 17.9423C13.2985 17.9423 14.512 17.5827 15.5444 16.9586L14.6763 16.0905C13.8769 16.5116 12.9663 16.75 12 16.75C8.82436 16.75 6.25 14.1756 6.25 11V7.66421L1.29289 2.70711C0.902369 2.31658 0.902369 1.68342 1.29289 1.29289Z" fill="#F4F6FC"/>
<path d="M12 1.25C15.1756 1.25 17.75 3.82436 17.75 7V11C17.75 11.641 17.6451 12.2575 17.4516 12.8333C17.3374 13.1729 17.2803 13.3428 17.1177 13.3809C16.9551 13.4191 16.819 13.283 16.5467 13.0107L7.62218 4.08618C7.44997 3.91397 7.36387 3.82786 7.35845 3.71186C7.35302 3.59585 7.42553 3.50835 7.57054 3.33337C8.62524 2.06068 10.2179 1.25 12 1.25Z" fill="#F4F6FC"/>
<path d="M19.7778 10.25C19.2408 10.25 18.8056 10.6805 18.8056 11.2115C18.8056 12.2201 18.5813 13.1769 18.1793 14.0357C18.0828 14.2418 18.0346 14.3448 18.051 14.4381C18.0674 14.5314 18.1428 14.6068 18.2935 14.7575L18.8612 15.3252C19.1075 15.5715 19.2306 15.6946 19.3804 15.672C19.5303 15.6493 19.6077 15.5026 19.7626 15.209C20.3934 14.0133 20.75 12.6536 20.75 11.2115C20.75 10.6805 20.3147 10.25 19.7778 10.25Z" fill="#F4F6FC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 1.25C8.82436 1.25 6.25 3.82436 6.25 7V11C6.25 14.1756 8.82436 16.75 12 16.75C15.1756 16.75 17.75 14.1756 17.75 11V7C17.75 3.82436 15.1756 1.25 12 1.25Z" fill="#F4F6FC"/>
<path d="M5.19444 11.2115C5.19444 10.6805 4.75917 10.25 4.22222 10.25C3.68528 10.25 3.25 10.6805 3.25 11.2115C3.25 15.6659 6.65283 19.3343 11.0278 19.8126V20.8269H9.08333C8.54639 20.8269 8.11111 21.2574 8.11111 21.7885C8.11111 22.3195 8.54639 22.75 9.08333 22.75H14.9167C15.4536 22.75 15.8889 22.3195 15.8889 21.7885C15.8889 21.2574 15.4536 20.8269 14.9167 20.8269H12.9722V19.8126C17.3472 19.3343 20.75 15.6659 20.75 11.2115C20.75 10.6805 20.3147 10.25 19.7778 10.25C19.2408 10.25 18.8056 10.6805 18.8056 11.2115C18.8056 14.9288 15.7586 17.9423 12 17.9423C8.2414 17.9423 5.19444 14.9288 5.19444 11.2115Z" fill="#F4F6FC"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9916 3.9756C11.6784 3.44801 12.4957 3.01957 13.367 3.38808C14.2302 3.75318 14.5076 4.63267 14.6274 5.49785C14.7502 6.38459 14.7502 7.60557 14.7502 9.12365V14.8794C14.7502 16.3975 14.7502 17.6185 14.6274 18.5052C14.5076 19.3704 14.2302 20.2499 13.367 20.615C12.4957 20.9835 11.6784 20.5551 10.9916 20.0275C10.2892 19.488 9.3966 18.5765 8.34667 17.5044C7.80721 16.9536 7.44921 16.6873 7.08663 16.5374C6.72221 16.3868 6.27914 16.3229 5.50619 16.3229C4.83768 16.3229 4.23963 16.3229 3.78679 16.2758C3.31184 16.2265 2.87088 16.1191 2.47421 15.8485C1.7184 15.3328 1.42917 14.5777 1.31957 13.8838C1.23785 13.3663 1.24723 12.7981 1.25479 12.3405V11.6626C1.24723 11.205 1.23785 10.6368 1.31957 10.1193C1.42917 9.42536 1.7184 8.67029 2.47421 8.15462C2.87088 7.88398 3.31184 7.77657 3.78679 7.72723C4.23963 7.68019 4.83768 7.68021 5.50619 7.68023C6.27914 7.68023 6.72221 7.61628 7.08663 7.46563C7.44922 7.31574 7.80717 7.04954 8.34663 6.49869C9.39658 5.42656 10.2892 4.51511 10.9916 3.9756Z" fill="#F4F6FC"/>
<path d="M16.3935 8.20504C16.8325 7.87003 17.4601 7.95439 17.7951 8.39347C18.5519 9.38539 19.0001 10.6418 19.0001 12.0001C19.0001 13.3583 18.5519 14.6147 17.7951 15.6066C17.4601 16.0457 16.8325 16.1301 16.3935 15.7951C15.9544 15.4601 15.87 14.8325 16.205 14.3935C16.699 13.746 17.0001 12.9149 17.0001 12.0001C17.0001 11.0852 16.699 10.2541 16.205 9.60664C15.87 9.16756 15.9544 8.54004 16.3935 8.20504Z" fill="#F4F6FC"/>
<path d="M20.7376 6.32472C20.3646 5.91738 19.7321 5.8895 19.3247 6.26245C18.9174 6.63541 18.8895 7.26796 19.2625 7.6753C20.3608 8.87497 21 10.379 21 12C21 13.621 20.3608 15.125 19.2625 16.3247C18.8895 16.7321 18.9174 17.3646 19.3247 17.7376C19.7321 18.1105 20.3646 18.0826 20.7376 17.6753C22.1408 16.1427 23 14.1675 23 12C23 9.83247 22.1408 7.8573 20.7376 6.32472Z" fill="#F4F6FC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.6949 2.4068C18.0224 1.96211 18.6484 1.86712 19.0931 2.19463L19.0969 2.19747C19.1724 2.25451 19.3861 2.41593 19.5059 2.50946C19.7487 2.69891 20.0757 2.96119 20.4057 3.24769C20.7307 3.52987 21.0815 3.85522 21.3588 4.16915C21.4965 4.32497 21.6373 4.50157 21.7491 4.68699C21.8443 4.84496 22.0001 5.13899 22.0001 5.49983C22.0001 5.86068 21.8443 6.15473 21.7491 6.3127C21.6373 6.49811 21.4965 6.67471 21.3588 6.83053C21.0814 7.14446 20.7307 7.46981 20.4057 7.75198C20.0757 8.03847 19.7487 8.30076 19.5059 8.4902C19.3859 8.58389 19.1718 8.74556 19.0966 8.80235L19.0932 8.8049C18.6485 9.13241 18.0224 9.03753 17.6949 8.59283C17.565 8.41644 17.5015 8.21152 17.5 8.00803V6.49988H9.5C6.3286 6.49988 4 8.74755 4 11.9999C4 12.5522 3.55228 12.9999 3 12.9999C2.44772 12.9999 2 12.5522 2 11.9999C2 7.62227 5.24485 4.49988 9.5 4.49988H17.5V2.99982C17.4999 2.79363 17.5632 2.58556 17.6949 2.4068Z" fill="#F4F6FC"/>
<path d="M22 11.9999C22 16.3775 18.7552 19.4999 14.5 19.4999H6.50005V20.9956C6.5011 21.2032 6.43777 21.4129 6.3052 21.5929C5.97769 22.0376 5.35169 22.1326 4.90699 21.8051L4.90343 21.8024C4.82806 21.7454 4.6141 21.5838 4.49415 21.4902C4.2514 21.3008 3.92436 21.0385 3.59439 20.752C3.26939 20.4699 2.91862 20.1445 2.64124 19.8306C2.50356 19.6748 2.36278 19.4982 2.251 19.3127C2.15577 19.1548 2 18.8607 2 18.4999C2 18.139 2.15578 17.845 2.25101 17.687C2.36278 17.5016 2.50356 17.325 2.64124 17.1692C2.91862 16.8553 3.26938 16.5299 3.59438 16.2477C3.92434 15.9612 4.25138 15.699 4.49412 15.5095C4.61412 15.4159 4.82805 15.2543 4.90333 15.1974L4.9068 15.1948C5.35149 14.8673 5.97765 14.9622 6.30517 15.4068C6.43683 15.5856 6.50021 15.7937 6.50005 15.9999V17.4999H14.5C17.6714 17.4999 20 15.2522 20 11.9999C20 11.4476 20.4477 10.9999 21 10.9999C21.5523 10.9999 22 11.4476 22 11.9999Z" fill="#F4F6FC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.072 3H8.92797C7.33935 2.99997 6.04614 2.99995 5.02631 3.13706C3.96232 3.28011 3.04736 3.58869 2.31802 4.31802C1.58869 5.04736 1.28011 5.96232 1.13706 7.02631C0.999949 8.04614 0.999972 9.33928 1 10.9279V13.072C0.999972 14.6607 0.999949 15.9539 1.13706 16.9737C1.28011 18.0377 1.58869 18.9527 2.31802 19.682C3.04736 20.4113 3.96232 20.7199 5.02631 20.8629C6.04616 21.0001 7.33933 21 8.928 21H10.072C11.6607 21 12.9538 21.0001 13.9737 20.8629C15.0377 20.7199 15.9527 20.4113 16.682 19.682C17.3875 18.9764 17.6993 18.0972 17.8484 17.0773C18.3063 17.4374 18.7206 17.7408 19.0944 17.9666C19.7947 18.3896 20.6822 18.7439 21.5929 18.2925C22.4895 17.848 22.7673 16.9371 22.8829 16.1198C22.9991 15.2982 22.9991 14.1926 22.999 12.8645V11.1382C22.9991 9.81011 22.9991 8.70445 22.8829 7.88282C22.7673 7.06553 22.4895 6.15463 21.5929 5.71019C20.6822 5.25874 19.7947 5.61301 19.0944 6.03609C18.7207 6.26185 18.3066 6.56515 17.8488 6.92504C17.6998 5.90419 17.3881 5.02411 16.682 4.31802C15.9527 3.58869 15.0377 3.28011 13.9737 3.13706C12.9539 2.99995 11.6607 2.99997 10.072 3ZM17.9943 9.3825C18 9.86282 18 10.3777 18 10.928V13.072C18 13.6233 18 14.1391 17.9943 14.6201C18.9515 15.4089 19.6126 15.943 20.1285 16.2547C20.3999 16.4186 20.5637 16.4778 20.6531 16.4955C20.6728 16.4994 20.6863 16.5007 20.6942 16.5012L20.6994 16.5013L20.7027 16.5013L20.7047 16.5005C20.7067 16.4995 20.7082 16.4986 20.7092 16.4979C20.7103 16.4971 20.7108 16.4966 20.7108 16.4966C20.7136 16.4936 20.7286 16.4774 20.7499 16.4358C20.7977 16.3422 20.8567 16.1641 20.9026 15.8397C20.9966 15.1754 20.999 14.2135 20.999 12.7832V11.2194C20.999 9.78915 20.9966 8.82725 20.9026 8.16294C20.8567 7.83855 20.7977 7.66044 20.7499 7.56691C20.7286 7.52522 20.714 7.50949 20.7112 7.50653C20.7112 7.50653 20.709 7.50427 20.7047 7.50211L20.7032 7.50139C20.7032 7.50139 20.6982 7.5013 20.6942 7.5015C20.6863 7.50191 20.6728 7.50322 20.6531 7.50713C20.5637 7.52483 20.3999 7.58402 20.1285 7.74796C19.6126 8.05966 18.9515 8.59379 17.9943 9.3825ZM13 9.5C13 10.3284 12.3284 11 11.5 11C10.6716 11 10 10.3284 10 9.5C10 8.67157 10.6716 8 11.5 8C12.3284 8 13 8.67157 13 9.5Z" fill="#3B3D4A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.70711 1.29289C2.31658 0.902369 1.68342 0.902369 1.29289 1.29289C0.902369 1.68342 0.902369 2.31658 1.29289 2.70711L2.80516 4.21938C2.69757 4.30332 2.59413 4.39465 2.4948 4.49398C1.81966 5.16912 1.52415 6.02253 1.38483 7.0588C1.24997 8.06188 1.24998 9.34083 1.25 10.9434V13.0549C1.24998 14.6575 1.24997 15.9365 1.38483 16.9396C1.52415 17.9758 1.81966 18.8293 2.4948 19.5044C3.16994 20.1795 4.02335 20.475 5.05961 20.6144C6.0627 20.7492 7.34165 20.7492 8.94424 20.7492H10.0558C11.6583 20.7492 12.9373 20.7492 13.9404 20.6144C14.9767 20.475 15.8301 20.1795 16.5052 19.5044C16.7838 19.2258 16.9978 18.9168 17.1628 18.5771L21.2929 22.7071C21.6834 23.0976 22.3166 23.0976 22.7071 22.7071C23.0976 22.3166 23.0976 21.6834 22.7071 21.2929L2.70711 1.29289Z" fill="white"/>
<path d="M13.7062 5.11923C12.8353 5.00213 11.6772 5 9.99904 5H8.96752C8.43122 4.9829 8.00115 4.54335 8 4.00265C7.99882 3.45037 8.44558 3.0017 8.99787 3.00052L10.0711 3C11.6597 2.99997 12.9529 2.99994 13.9727 3.13706C15.0367 3.28011 15.9517 3.58869 16.681 4.31802C17.4103 5.04735 17.7189 5.96232 17.862 7.0263C17.8742 7.11687 17.8853 7.20959 17.8954 7.30449C18.3463 6.97574 18.7545 6.69858 19.1231 6.49173C19.8273 6.09659 20.6866 5.78411 21.5681 6.18995C22.4678 6.60419 22.762 7.47353 22.8824 8.26363C23.0002 9.03709 23.0001 10.073 23.0001 11.2982L23.0001 12.8993C23.0001 14.0331 23.0001 14.9981 22.9116 15.7394C22.8712 16.0774 22.8065 16.4355 22.6808 16.7737C22.2465 17.9425 20.3372 17.3376 20.8394 15.9064C20.8777 15.7971 20.9057 15.6698 20.9257 15.5022C20.9983 14.8939 21.0001 14.0493 21.0001 12.8357V11.3832C21.0001 10.0499 20.9972 9.16915 20.9052 8.56474C20.8606 8.27197 20.8048 8.12186 20.7653 8.05005C20.7565 8.03415 20.7496 8.0241 20.7453 8.01834L20.7414 8.01344L20.7384 8.01018L20.7371 8.00939L20.7349 8.00824L20.7316 8.00663C20.7285 8.00465 20.7123 7.99436 20.6408 8.00751C20.5417 8.02573 20.3729 8.08381 20.1018 8.2359C19.5905 8.52282 18.9376 9.01181 17.9965 9.73188C17.9991 10.1102 17.999 10.5087 17.999 10.9278V12C17.999 12.5523 17.5513 13 16.999 13C16.4467 13 15.999 12.5523 15.999 12L15.999 11C15.999 9.32182 15.9969 8.16377 15.8798 7.2928C15.7666 6.45059 15.5626 6.02803 15.2668 5.73223C14.971 5.43644 14.5485 5.23246 13.7062 5.11923Z" fill="white"/>
<path d="M8.96889 5.00004L8.93871 5.00002C8.91327 5 8.90978 5 8.96752 5L8.96889 5.00004Z" fill="white"/>
<path d="M9.00213 5.00052C9.13771 5.00023 9.0365 5.0001 8.96889 5.00004C8.97993 5.00038 8.99101 5.00054 9.00213 5.00052Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.072 3H8.92797C7.33935 2.99997 6.04614 2.99995 5.02631 3.13706C3.96232 3.28011 3.04736 3.58869 2.31802 4.31802C1.58869 5.04736 1.28011 5.96232 1.13706 7.02631C0.999949 8.04614 0.999972 9.33928 1 10.9279V13.072C0.999972 14.6607 0.999949 15.9539 1.13706 16.9737C1.28011 18.0377 1.58869 18.9527 2.31802 19.682C3.04736 20.4113 3.96232 20.7199 5.02631 20.8629C6.04616 21.0001 7.33933 21 8.928 21H10.072C11.6607 21 12.9538 21.0001 13.9737 20.8629C15.0377 20.7199 15.9527 20.4113 16.682 19.682C17.3875 18.9764 17.6993 18.0972 17.8484 17.0773C18.3063 17.4374 18.7206 17.7408 19.0944 17.9666C19.7947 18.3896 20.6822 18.7439 21.5929 18.2925C22.4895 17.848 22.7673 16.9371 22.8829 16.1198C22.9991 15.2982 22.9991 14.1926 22.999 12.8645V11.1382C22.9991 9.81011 22.9991 8.70445 22.8829 7.88282C22.7673 7.06553 22.4895 6.15463 21.5929 5.71019C20.6822 5.25874 19.7947 5.61301 19.0944 6.03609C18.7207 6.26185 18.3066 6.56515 17.8488 6.92504C17.6998 5.90419 17.3881 5.02411 16.682 4.31802C15.9527 3.58869 15.0377 3.28011 13.9737 3.13706C12.9539 2.99995 11.6607 2.99997 10.072 3ZM17.9943 9.3825C18 9.86282 18 10.3777 18 10.928V13.072C18 13.6233 18 14.1391 17.9943 14.6201C18.9515 15.4089 19.6126 15.943 20.1285 16.2547C20.3999 16.4186 20.5637 16.4778 20.6531 16.4955C20.6728 16.4994 20.6863 16.5007 20.6942 16.5012L20.6994 16.5013L20.7027 16.5013L20.7047 16.5005C20.7067 16.4995 20.7082 16.4986 20.7092 16.4979C20.7103 16.4971 20.7108 16.4966 20.7108 16.4966C20.7136 16.4936 20.7286 16.4774 20.7499 16.4358C20.7977 16.3422 20.8567 16.1641 20.9026 15.8397C20.9966 15.1754 20.999 14.2135 20.999 12.7832V11.2194C20.999 9.78915 20.9966 8.82725 20.9026 8.16294C20.8567 7.83855 20.7977 7.66044 20.7499 7.56691C20.7286 7.52522 20.714 7.50949 20.7112 7.50653C20.7112 7.50653 20.709 7.50427 20.7047 7.50211L20.7032 7.50139C20.7032 7.50139 20.6982 7.5013 20.6942 7.5015C20.6863 7.50191 20.6728 7.50322 20.6531 7.50713C20.5637 7.52483 20.3999 7.58402 20.1285 7.74796C19.6126 8.05966 18.9515 8.59379 17.9943 9.3825ZM13 9.5C13 10.3284 12.3284 11 11.5 11C10.6716 11 10 10.3284 10 9.5C10 8.67157 10.6716 8 11.5 8C12.3284 8 13 8.67157 13 9.5Z" fill="#F4F6FC"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -66,6 +66,8 @@
<array>
<string>fetch</string>
<string>remote-notification</string>
<string>voip</string>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

@ -7,8 +7,8 @@ class URLs {
// static const host1 = "https://atomsmdev.hmg.com"; // local DEV url
static const host1 = "https://atomsmuat.hmg.com"; // local UAT url
static final String _baseUrl = "$_host/mobile"; // host local UAT
// static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis
// static final String _baseUrl = "$_host/mobile"; // host local UAT
static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis
// static final String _baseUrl = "$_host/v3/mobile"; // v3 for production CM,PM,TM
// static final String _baseUrl = "$_host/v4/mobile"; // v4 for Demo module
// static final String _baseUrl = "$_host/v5/mobile"; // v5 for data segregation

@ -18,9 +18,53 @@ import 'package:test_sa/modules/tm_module/tasks/task_request_detail_view.dart';
import 'package:test_sa/modules/tm_module/gas_refill/gas_refill_details.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import 'package:test_sa/views/widgets/loaders/no_data_found.dart';
import 'package:test_sa/modules/cx_module/chat/call/services/call_notification_service.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {}
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
try {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Push notification received', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Message ID: ${message.messageId}', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Data payload: ${message.data}', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Notification title: ${message.notification?.title}', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Notification body: ${message.notification?.body}', name: 'FirebaseNotificationManger');
final notificationType = message.data['notificationType'] as String? ??
message.data['type'] as String?; // Backend uses 'type'
final transactionType = message.data['transactionType'] as String?;
log('📱 [BACKGROUND] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Transaction Type: $transactionType', name: 'FirebaseNotificationManger');
// Handle incoming call notifications
if (notificationType == 'incoming_call' || transactionType == 'call') {
log('📞 [BACKGROUND] Incoming call notification detected', name: 'FirebaseNotificationManger');
log('📞 [BACKGROUND] Call data:', name: 'FirebaseNotificationManger');
log(' - callId: ${message.data['callId']}', name: 'FirebaseNotificationManger');
log(' - callerId: ${message.data['callerId']}', name: 'FirebaseNotificationManger');
log(' - calleeEmployeeNumber: ${message.data['calleeEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - callerName: ${message.data['callerName']}', name: 'FirebaseNotificationManger');
log(' - callerUserName: ${message.data['callerUserName']}', name: 'FirebaseNotificationManger');
log(' - callerEmployeeNumber: ${message.data['callerEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - isVideoCall: ${message.data['isVideoCall']}', name: 'FirebaseNotificationManger');
log(' - callType: ${message.data['callType']}', name: 'FirebaseNotificationManger');
log(' - applicationId: ${message.data['applicationId']}', name: 'FirebaseNotificationManger');
await CallNotificationService().handleIncomingCallNotification(
notificationData: message.data,
source: 'push',
);
} else {
log('📱 [BACKGROUND] Non-call notification type', name: 'FirebaseNotificationManger');
}
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
} catch (e, stackTrace) {
log('❌ [BACKGROUND] Error handling background message: $e',
name: 'FirebaseNotificationManger', error: e, stackTrace: stackTrace);
}
}
class FirebaseNotificationManger {
static FirebaseMessaging messaging = FirebaseMessaging.instance;
@ -65,11 +109,14 @@ class FirebaseNotificationManger {
}
static void _onMessageReceived(h_push.RemoteMessage remoteMessage) {
print("onMessageReceivedStream:${remoteMessage.toMap()}");
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI] Message received', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI] Full message data: ${remoteMessage.toMap()}', name: 'FirebaseNotificationManger');
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
}
static void _onMessageReceiveError(Object error) {
print("onMessageReceivedStream:${error.toString()}");
log('❌ [HUAWEI] Message receive error: ${error.toString()}', name: 'FirebaseNotificationManger');
}
static Future<bool> isGoogleServicesAvailable() async {
@ -86,6 +133,48 @@ class FirebaseNotificationManger {
}
static void handleMessage(context, Map<String, dynamic> messageData) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Processing notification', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Full message data: $messageData', name: 'FirebaseNotificationManger');
// FIXED: Check if this is a call notification first - CHECK BOTH 'notificationType' AND 'type'
final notificationType = messageData['notificationType'] as String? ??
messageData['type'] as String?; // Backend uses 'type'
final transactionType = messageData['transactionType'] as String?;
log('📱 [HANDLE_MESSAGE] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Transaction Type: $transactionType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call' || transactionType == 'call') {
log('📞 [FOREGROUND] Incoming call notification detected', name: 'FirebaseNotificationManger');
log('📞 [FOREGROUND] Call data:', name: 'FirebaseNotificationManger');
log(' - callId: ${messageData['callId']}', name: 'FirebaseNotificationManger');
log(' - callerId: ${messageData['callerId']}', name: 'FirebaseNotificationManger');
log(' - calleeEmployeeNumber: ${messageData['calleeEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - callerName: ${messageData['callerName']}', name: 'FirebaseNotificationManger');
log(' - callerUserName: ${messageData['callerUserName']}', name: 'FirebaseNotificationManger');
log(' - callerEmployeeNumber: ${messageData['callerEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - isVideoCall: ${messageData['isVideoCall']}', name: 'FirebaseNotificationManger');
log(' - callType: ${messageData['callType']}', name: 'FirebaseNotificationManger');
log(' - moduleId: ${messageData['moduleId']}', name: 'FirebaseNotificationManger');
log(' - referenceId: ${messageData['referenceId']}', name: 'FirebaseNotificationManger');
log(' - conversationId: ${messageData['conversationId']}', name: 'FirebaseNotificationManger');
log(' - applicationId: ${messageData['applicationId']}', name: 'FirebaseNotificationManger');
// Let CallNotificationService handle it
CallNotificationService().handleIncomingCallNotification(
notificationData: messageData,
source: 'push',
);
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
return;
}
log('📱 [HANDLE_MESSAGE] Non-call notification - processing normally', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Request Type: ${messageData["requestType"]}', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Request Number: ${messageData["requestNumber"]}', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Module ID: ${messageData["moduleId"]}', name: 'FirebaseNotificationManger');
if (messageData["requestType"] != null && messageData["requestNumber"] != null) {
Widget? serviceClass;
@ -194,32 +283,82 @@ class FirebaseNotificationManger {
// }
Navigator.of(context).push(MaterialPageRoute(builder: (_) => serviceClass!));
}
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
}
static initialized(BuildContext context) async {
log('🔧 [INIT] Initializing FirebaseNotificationManger', name: 'FirebaseNotificationManger');
//TOD0 add platform check here also
if (!(await isGoogleServicesAvailable()) && Platform.isAndroid) {
log('📱 [INIT] Using Huawei Push Services', name: 'FirebaseNotificationManger');
// NEW: Handle Huawei initial notification
var initialNotification = await h_push.Push.getInitialNotification();
if (initialNotification != null) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_INITIAL] Initial notification found', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_INITIAL] Full data: $initialNotification', name: 'FirebaseNotificationManger');
Map<String, dynamic> remoteData = Map<String, dynamic>.from(initialNotification["extras"] as Map);
log('📱 [HUAWEI_INITIAL] Extras data: $remoteData', name: 'FirebaseNotificationManger');
// Check if it's a call notification
final notificationType = remoteData['notificationType'] as String?;
log('📱 [HUAWEI_INITIAL] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [HUAWEI_INITIAL] Processing call notification', name: 'FirebaseNotificationManger');
await CallNotificationService().handleIncomingCallNotification(
notificationData: remoteData,
source: 'push',
);
} else {
log('📱 [HUAWEI_INITIAL] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, remoteData);
}
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
} else {
log('📱 [HUAWEI_INITIAL] No initial notification', name: 'FirebaseNotificationManger');
}
h_push.Push.onNotificationOpenedApp.listen((message) {
try {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_TAP] Notification tapped', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_TAP] Message type: ${message.runtimeType}', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_TAP] Full message: $message', name: 'FirebaseNotificationManger');
if (message is Map<String, dynamic>) {
Map<String, dynamic> remoteData = message;
remoteData = remoteData["extras"];
log('📱 [HUAWEI_TAP] Extras data: $remoteData', name: 'FirebaseNotificationManger');
// Check if it's a call notification
final notificationType = remoteData['notificationType'] as String?;
log('📱 [HUAWEI_TAP] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [HUAWEI_TAP] Processing call notification', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: remoteData,
source: 'push',
);
} else {
log('📱 [HUAWEI_TAP] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, remoteData);
}
}
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
} catch (ex) {
print("parsingError:$ex");
log('❌ [HUAWEI_TAP] Parsing error: $ex', name: 'FirebaseNotificationManger', error: ex);
}
}, onError: (e) => print("onNotificationOpenedApp Error${e.toString()}"));
}, onError: (e) => log('❌ [HUAWEI_TAP] Error: ${e.toString()}', name: 'FirebaseNotificationManger'));
return;
}
log('📱 [INIT] Using Firebase Cloud Messaging (FCM)', name: 'FirebaseNotificationManger');
NotificationSettings settings;
try {
@ -232,11 +371,14 @@ class FirebaseNotificationManger {
provisional: false,
sound: true,
);
log('✅ [INIT] Notification permissions granted: ${settings.authorizationStatus}', name: 'FirebaseNotificationManger');
} catch (error) {
log('❌ [INIT] Permission request failed: $error', name: 'FirebaseNotificationManger', error: error);
return;
}
if (settings.authorizationStatus != AuthorizationStatus.authorized) {
log('⚠️ [INIT] Notifications not authorized', name: 'FirebaseNotificationManger');
return;
}
@ -245,22 +387,115 @@ class FirebaseNotificationManger {
FirebaseMessaging.instance.getInitialMessage().then((initialMessage) {
if (initialMessage != null) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Initial message found', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Message ID: ${initialMessage.messageId}', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Data: ${initialMessage.data}', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Title: ${initialMessage.notification?.title}', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Body: ${initialMessage.notification?.body}', name: 'FirebaseNotificationManger');
// NEW: Check if it's a call notification
final notificationType = initialMessage.data['notificationType'] as String?;
log('📱 [FCM_INITIAL] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [FCM_INITIAL] Processing call notification', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: initialMessage.data,
source: 'push',
);
} else {
log('📱 [FCM_INITIAL] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, initialMessage.data);
}
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
} else {
log('📱 [FCM_INITIAL] No initial message', name: 'FirebaseNotificationManger');
}
});
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [FCM_FOREGROUND] Message received', name: 'FirebaseNotificationManger');
log('📱 [FCM_FOREGROUND] Message ID: ${message.messageId}', name: 'FirebaseNotificationManger');
log('📱 [FCM_FOREGROUND] Data: ${message.data}', name: 'FirebaseNotificationManger');
log('📱 [FCM_FOREGROUND] Title: ${message.notification?.title}', name: 'FirebaseNotificationManger');
log('📱 [FCM_FOREGROUND] Body: ${message.notification?.body}', name: 'FirebaseNotificationManger');
// FIXED: Check if it's a call notification - CHECK BOTH 'notificationType' AND 'type'
final notificationType = message.data['notificationType'] as String? ??
message.data['type'] as String?; // Backend uses 'type'
log('📱 [FCM_FOREGROUND] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [FOREGROUND] Incoming call via FCM', name: 'FirebaseNotificationManger');
log('📞 [FCM_FOREGROUND] Call data:', name: 'FirebaseNotificationManger');
log(' - callId: ${message.data['callId']}', name: 'FirebaseNotificationManger');
log(' - callerId: ${message.data['callerId']}', name: 'FirebaseNotificationManger');
log(' - calleeEmployeeNumber: ${message.data['calleeEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - callerName: ${message.data['callerName']}', name: 'FirebaseNotificationManger');
log(' - callerUserName: ${message.data['callerUserName']}', name: 'FirebaseNotificationManger');
log(' - callerEmployeeNumber: ${message.data['callerEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - isVideoCall: ${message.data['isVideoCall']}', name: 'FirebaseNotificationManger');
log(' - callType: ${message.data['callType']}', name: 'FirebaseNotificationManger');
log(' - applicationId: ${message.data['applicationId']}', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: message.data,
source: 'push',
);
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
return;
}
log('📱 [FCM_FOREGROUND] Non-call notification', name: 'FirebaseNotificationManger');
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
if (Platform.isAndroid) {
if (message.data["notificationType"] != 'NurseConfirmArrive') {
NotificationManger.showNotification(
title: message.notification?.title ?? "", subtext: message.notification?.body ?? "", hashcode: int.tryParse("1234" ?? "") ?? 1, payload: json.encode(message.data), context: context);
title: message.notification?.title ?? "",
subtext: message.notification?.body ?? "",
hashcode: int.tryParse("1234" ?? "") ?? 1,
payload: json.encode(message.data),
context: context);
}
}
return;
});
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [FCM_TAP] Notification tapped', name: 'FirebaseNotificationManger');
log('📱 [FCM_TAP] Message ID: ${message.messageId}', name: 'FirebaseNotificationManger');
log('📱 [FCM_TAP] Data: ${message.data}', name: 'FirebaseNotificationManger');
log('📱 [FCM_TAP] Title: ${message.notification?.title}', name: 'FirebaseNotificationManger');
log('📱 [FCM_TAP] Body: ${message.notification?.body}', name: 'FirebaseNotificationManger');
// NEW: Check if it's a call notification
final notificationType = message.data['notificationType'] as String?;
log('📱 [FCM_TAP] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [NOTIFICATION TAP] Incoming call notification tapped', name: 'FirebaseNotificationManger');
log('📞 [FCM_TAP] Call data:', name: 'FirebaseNotificationManger');
log(' - callId: ${message.data['callId']}', name: 'FirebaseNotificationManger');
log(' - callerId: ${message.data['callerId']}', name: 'FirebaseNotificationManger');
log(' - callerName: ${message.data['callerName']}', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: message.data,
source: 'push',
);
} else {
log('📱 [FCM_TAP] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, message.data);
}
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
});
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
log('✅ [INIT] FirebaseNotificationManger initialized successfully', name: 'FirebaseNotificationManger');
}
}

@ -0,0 +1,28 @@
import 'package:get_it/get_it.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
import 'package:test_sa/modules/cx_module/chat/call/services/webrtc_service.dart';
/// Global service locator instance
final getIt = GetIt.instance;
/// Setup dependency injection
Future<void> setupServiceLocator() async {
// SignalR Service - ONLY ONE instance for the entire app
getIt.registerLazySingleton<SignalRService>(
() => SignalRService(),
);
// WebRTC Service - ONLY ONE instance for the entire app
getIt.registerLazySingleton<WebRTCService>(
() => WebRTCService(),
);
print('✅ [DI] Service locator initialized');
print(' - SignalRService registered as singleton (hashCode: ${getIt<SignalRService>().hashCode})');
print(' - WebRTCService registered as singleton (hashCode: ${getIt<WebRTCService>().hashCode})');
}
Future<void> resetServices() async {
await getIt<SignalRService>().reset();
print('✅ [DI] All services reset');
}

@ -114,6 +114,7 @@ extension WidgetExtensions on Widget {
BuildContext context, {
bool showShadow = true,
double borderRadius = 14,
double width = double.infinity,
bool withShadow = true,
Color? backgroundColor,
Color? borderColor,
@ -125,7 +126,7 @@ extension WidgetExtensions on Widget {
? Container(
padding: paddingObject ?? EdgeInsets.all(padding),
margin: margin,
width: double.infinity,
width: width,
decoration: ShapeDecoration(
color: backgroundColor ?? AppColor.background(context),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(borderRadius), side: BorderSide(color: borderColor ?? AppColor.borderColor(context))),

@ -36,6 +36,7 @@ import 'package:test_sa/modules/asset_inventory_module/provider/asset_inventory_
import 'package:test_sa/modules/cm_module/cm_detail_provider.dart';
import 'package:test_sa/modules/cm_module/create_cm_request.dart';
import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
import 'package:test_sa/modules/demo_module/create_demo_request_page.dart';
import 'package:test_sa/modules/demo_module/provider/demo_period_lookup_provider.dart';
import 'package:test_sa/modules/demo_module/provider/demo_provider.dart';
@ -145,6 +146,7 @@ import 'providers/service_request_providers/loan_availability_provider.dart';
import 'providers/service_request_providers/reject_reason_provider.dart';
import 'modules/cleaner_module/pages/cleaner_search_form_view.dart';
import 'modules/cleaner_module/provider/cleaner_provider.dart';
import 'package:test_sa/core/di/service_locator.dart';
// class MyHttpOverrides extends HttpOverrides {
// @override
@ -159,7 +161,7 @@ void main() async {
// HttpOverrides.global = MyHttpOverrides(); // for later use.
_configureLocalTimeZone();
NotificationManger.initialisation((notificationDetails) {}, (id, title, body, payload) async {});
await setupServiceLocator();
if (Platform.isIOS) {
await Firebase.initializeApp(
options: const FirebaseOptions(
@ -172,6 +174,7 @@ void main() async {
} else {
await Firebase.initializeApp();
}
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.white,

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:developer';
import 'package:test_sa/controllers/providers/api/user_provider.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
@ -14,6 +15,8 @@ import 'package:test_sa/modules/cm_module/cm_detail_provider.dart';
import 'package:test_sa/modules/cm_module/views/components/bottom_sheets/service_request_bottomsheet.dart';
import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
import 'package:test_sa/modules/cx_module/chat/chat_widget.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
import 'package:test_sa/core/di/service_locator.dart';
import 'package:test_sa/modules/export_module/provider/export_provider.dart';
import 'package:test_sa/modules/export_module/widgets/file_success_dialog.dart';
import 'package:test_sa/modules/loan_module/pages/create_loan_request_page.dart';
@ -46,7 +49,23 @@ class _CMDetailPageState extends State<CMDetailPage> {
super.initState();
_requestProvider = Provider.of<CMDetailProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<ChatProvider>(context, listen: false).reset();
// CRITICAL FIX: NEVER reset ChatProvider/SignalR connection
// CallManager already initialized it from LandPage and is using it for calls
// Resetting will disconnect SignalR and break incoming calls
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
final signalRService = getIt<SignalRService>();
log('📋 [CMDetailPage] Checking chat state...', name: 'CMDetailPage');
log(' SignalR connected: ${signalRService.isConnected}', name: 'CMDetailPage');
log(' Chat logged in: ${chatProvider.chatLoginResponse != null}', name: 'CMDetailPage');
log(' Current reference: ${chatProvider.referenceID}', name: 'CMDetailPage');
log(' New reference: ${widget.requestId}', name: 'CMDetailPage');
// DON'T RESET - just let ChatWidget handle its own initialization
// ChatWidget will check credentials and update conversation if needed
log('✅ [CMDetailPage] Skipping reset - ChatWidget will handle initialization', name: 'CMDetailPage');
getInitialData();
});
}
@ -152,7 +171,6 @@ class _CMDetailPageState extends State<CMDetailPage> {
),
4.width,
],
),
).onPress(() async {
LoanProvider loanProvider = Provider.of<LoanProvider>(context, listen: false);
Utils.showLoading(context);
@ -164,7 +182,7 @@ class _CMDetailPageState extends State<CMDetailPage> {
} else {
Navigator.push(context, MaterialPageRoute(builder: (context) => LoanEquipmentDetailPage(loanId: data.id!)));
}
});
}));
}
return const SizedBox();
}

@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/app_style/app_themes.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
class AudioCallPage extends StatefulWidget {
const AudioCallPage({Key? key}) : super(key: key);
@override
State<AudioCallPage> createState() => _AudioCallPageState();
}
class _AudioCallPageState extends State<AudioCallPage> {
late CallManager _callManager;
@override
void initState() {
super.initState();
_callManager = CallManager();
// Listen for call end and navigate back
_callManager.addListener(_checkCallStatus);
}
void _checkCallStatus() {
// If call status is idle (call ended), navigate back
if (_callManager.callStatus == CallStatus.idle && mounted) {
Navigator.of(context).pop();
}
}
@override
void dispose() {
_callManager.removeListener(_checkCallStatus);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.backgroundTabBarDark,
appBar: const DefaultAppBar(
backgroundColor: Colors.transparent,
arrowBackColor: AppColor.white10,
),
body: SafeArea(
child: ListenableBuilder(
listenable: _callManager,
builder: (context, _) {
final session = _callManager.currentCall;
if (session == null) {
return const Center(child: Text('No active call'));
}
return Column(
children: [
const Spacer(flex: 2),
_buildContactInfo(context, session),
8.height,
_buildCallStatusOrDuration(context),
const Spacer(flex: 4),
_buildControls(context, session),
32.height,
],
);
},
),
),
);
}
Widget _buildTopBar(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
const Spacer(),
Text(
'Audio Call',
style: AppTextStyles.heading6.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
const SizedBox(width: 48), // Balance the back button
],
),
);
}
Widget _buildContactInfo(BuildContext context, CallSession session) {
final isPeerMuted = _callManager.isPeerMuted;
return Column(
children: [
Stack(
alignment: Alignment.center,
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.whiteF8d,
border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1),
),
child: ClipOval(
child: session.peerAvatar != null
? CachedNetworkImage(
imageUrl: session.peerAvatar!,
fit: BoxFit.cover,
placeholder: (context, url) => Center(
child: CircularProgressIndicator(
color: Colors.white.withOpacity(0.5),
),
),
errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
)
: 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
),
),
// Show mute indicator when peer is muted
if (isPeerMuted)
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.red30,
shape: BoxShape.circle,
border: Border.all(color: AppColor.white10, width: 2),
),
child: 'mic_disable'.toSvgAsset(
width: 16,
height: 16,
color: AppColor.white10,
),
),
),
],
),
24.height,
Text(
session.peerName,
style: AppTextStyles.heading2.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
// Show "Microphone is off" text when peer is muted
if (isPeerMuted)
Column(
children: [
8.height,
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColor.red30.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
'mic_disable'.toSvgAsset(
width: 14,
height: 14,
color: AppColor.white10,
),
6.width,
Text(
'Microphone is off',
style: AppTextStyles.bodyText2.copyWith(
color: AppColor.white10,
fontSize: 12,
),
),
],
),
),
],
),
],
);
}
Widget _buildCallStatusOrDuration(BuildContext context) {
final status = _callManager.callStatus;
final duration = _callManager.callDuration;
String statusText;
if (status == CallStatus.connected) {
// Show call duration
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
statusText = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
// Show call status
statusText = _getStatusText(status);
}
return Text(
statusText,
style: AppTextStyles.bodyText.copyWith(color: Colors.white70),
);
}
String _getStatusText(CallStatus status) {
switch (status) {
case CallStatus.connecting:
return 'Connecting...';
case CallStatus.incomingRinging:
return 'Incoming call...';
case CallStatus.outgoingRinging:
return 'Ringing...';
case CallStatus.connected:
return 'Connected';
default:
return '';
}
}
Widget _buildControls(BuildContext context, CallSession session) {
final isMuted = _callManager.isMuted;
final isSpeakerOn = _callManager.isSpeakerOn;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildControlButton(
icon: isMuted ? Icons.mic_off : Icons.mic,
label: isMuted ? 'Unmute' : 'Mute',
onPressed: () => _callManager.toggleMute(),
backgroundColor: isMuted ? Colors.red : Colors.white24,
),
_buildControlButton(
icon: isSpeakerOn ? Icons.volume_up : Icons.volume_down,
label: isSpeakerOn ? 'Speaker' : 'Earpiece',
onPressed: () => _callManager.toggleSpeaker(),
backgroundColor: isSpeakerOn ? Colors.blue : Colors.white24,
),
_buildControlButton(
icon: Icons.call_end,
label: 'End',
onPressed: () => _callManager.hangUp(),
backgroundColor: Colors.red,
iconColor: Colors.white,
),
],
),
);
}
Widget _buildControlButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
Color? backgroundColor,
Color? iconColor,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: backgroundColor ?? AppColor.black2E,
shape: BoxShape.circle,
border: Border.all(color: AppColor.white10.withOpacity(0.2)),
),
child: Icon(
icon,
size: 24,
color: iconColor ?? AppColor.white10,
),
).onPress(() {
onPressed();
}),
8.height,
Text(
label,
style: AppTextStyles.heading5.copyWith(color: AppColor.neutral100, fontWeight: FontWeight.w400),
),
],
);
}
}

@ -0,0 +1,92 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
/// Helper class to debug call issues
class CallDebugHelper {
static void logCallInitiation({
required String callerEmployeeNumber,
required String calleeEmployeeNumber,
required bool isVideo,
required String hubConnectionState,
}) {
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('📞 INITIATING CALL', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('Caller: $callerEmployeeNumber', name: 'CALL_DEBUG');
log('Callee: $calleeEmployeeNumber', name: 'CALL_DEBUG');
log('Type: ${isVideo ? "VIDEO" : "AUDIO"}', name: 'CALL_DEBUG');
log('Hub State: $hubConnectionState', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
}
static void logConnectionStatus({
required String employeeNumber,
required bool isConnected,
required bool handlersRegistered,
String? additionalInfo,
}) {
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('📡 CONNECTION STATUS CHECK', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('Employee: $employeeNumber', name: 'CALL_DEBUG');
log('Connected: $isConnected', name: 'CALL_DEBUG');
log('Handlers Registered: $handlersRegistered', name: 'CALL_DEBUG');
if (additionalInfo != null) {
log('Info: $additionalInfo', name: 'CALL_DEBUG');
}
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
}
static void logEventReceived({
required String eventName,
required dynamic rawData,
required String currentStatus,
}) {
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('📨 SIGNALR EVENT RECEIVED', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('Event: $eventName', name: 'CALL_DEBUG');
log('Current Status: $currentStatus', name: 'CALL_DEBUG');
log('Raw Data: $rawData', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
}
static void printCallTroubleshooting() {
if (kDebugMode) {
print('\n');
print('╔════════════════════════════════════════════════════════════╗');
print('║ CALL NOT RECEIVED - TROUBLESHOOTING ║');
print('╚════════════════════════════════════════════════════════════╝');
print('');
print('Check the following on BOTH devices:');
print('');
print('1. SignalR Connection:');
print(' Look for: "🔌 SignalR Hub Connection: Started"');
print(' Look for: "✅ All call handlers registered successfully"');
print('');
print('2. Employee Numbers:');
print(' Verify sender and recipient employee numbers are correct');
print(' Look for: "🔵 [CALL] Sender: XXX"');
print(' Look for: " - target: XXX"');
print('');
print('3. Backend Response:');
print(' On receiving device, look for:');
print(' "📞 [CALL EVENT] OnIncomingCallAsync received"');
print('');
print('4. Common Issues:');
print(' ❌ Receiving device not connected to SignalR');
print(' ❌ Call handlers not registered (app not in chat screen)');
print(' ❌ Wrong employee number in backend mapping');
print(' ❌ Backend feature flag disabled for audio/video calls');
print(' ❌ Network connectivity issues');
print('');
print('5. Backend Logs:');
print(' Check backend logs for CallUserAsync invocation');
print(' Verify backend is routing to correct user connection');
print('');
print('╚════════════════════════════════════════════════════════════╝');
print('\n');
}
}
}

@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import 'package:test_sa/new_views/swipe_module/dialoge/single_btn_dialog.dart';
import 'package:test_sa/new_views/swipe_module/dialoge/info_dialog.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:io';
/// Centralized error handler for call-related errors
class CallErrorHandler {
/// Show permission denied dialog
static void showPermissionDenied(
BuildContext context, {
required String permissionName,
VoidCallback? onSettingsTap,
}) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => InfoDialog(
title: 'Permission Required',
message: '$permissionName permission is required to make calls.',
content: [
InfoContent(
title: 'Action needed',
message: 'Please grant $permissionName permission in Settings to continue.',
),
],
okTitle: 'Open Settings',
onTap: () {
Navigator.pop(context);
openAppSettings();
onSettingsTap?.call();
},
),
);
}
/// Show microphone permission denied
static void showMicrophonePermissionDenied(BuildContext context) {
showPermissionDenied(
context,
permissionName: 'Microphone',
);
}
/// Show camera permission denied
static void showCameraPermissionDenied(BuildContext context) {
showPermissionDenied(
context,
permissionName: 'Camera',
);
}
/// Show user is busy dialog
static void showUserBusy(BuildContext context, String userName) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'User Busy',
message: '$userName is currently on another call. Please try again later.',
okTitle: 'OK',
),
);
}
/// Show call declined dialog
static void showCallDeclined(BuildContext context, String userName, String? reason) {
String message;
switch (reason) {
case 'declined':
message = '$userName declined your call.';
break;
case 'busy':
message = '$userName is busy on another call.';
break;
case 'no answer':
message = '$userName didn\'t answer the call.';
break;
default:
message = 'Call was not answered.';
}
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Call Ended',
message: message,
okTitle: 'OK',
),
);
}
/// Show connection failed dialog
static void showConnectionFailed(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Failed',
message: 'Unable to establish call connection. Please check your internet connection and try again.',
okTitle: 'OK',
),
);
}
/// Show WebRTC initialization failed
static void showWebRTCInitFailed(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Call Setup Failed',
message: 'Failed to initialize call. Please check your device settings and try again.',
okTitle: 'OK',
),
);
}
/// Show SignalR not connected
static void showSignalRNotConnected(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Error',
message: 'Not connected to server. Please check your internet connection.',
okTitle: 'OK',
),
);
}
/// Show ICE connection failed
static void showICEConnectionFailed(BuildContext context) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'Connection Issue',
message: 'Unable to establish peer-to-peer connection.',
content: [
InfoContent(
title: 'Possible causes',
message: 'Network firewall, poor internet connection, or restricted network.',
),
InfoContent(
title: 'Suggestion',
message: 'Try switching to a different network (WiFi/Mobile data).',
),
],
okTitle: 'OK',
),
);
}
/// Show call timeout dialog
static void showCallTimeout(BuildContext context, String userName) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'No Answer',
message: '$userName didn\'t answer the call.',
okTitle: 'OK',
),
);
}
/// Show network error during call
static void showNetworkErrorDuringCall(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const SingleBtnDialog(
title: 'Connection Lost',
message: 'Network connection was lost during the call.',
okTitle: 'OK',
),
);
}
/// Show generic error
static void showGenericError(BuildContext context, String message) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Error',
message: message,
okTitle: 'OK',
),
);
}
/// Show call already in progress
static void showCallAlreadyInProgress(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Call In Progress',
message: 'You are already in a call. Please end the current call before starting a new one.',
okTitle: 'OK',
),
);
}
/// Show user offline
static void showUserOffline(BuildContext context, String userName) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'User Offline',
message: '$userName is currently offline and cannot receive calls.',
okTitle: 'OK',
),
);
}
/// Show reconnecting dialog
static void showReconnecting(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const PopScope(
canPop: false,
child: Dialog(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(width: 24),
Text('Reconnecting...'),
],
),
),
),
),
);
}
/// Check internet connectivity
static Future<bool> hasInternetConnection() async {
try {
final result = await InternetAddress.lookup('google.com');
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} on SocketException catch (_) {
return false;
}
}
/// Show no internet connection dialog
static void showNoInternetConnection(BuildContext context) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'No Internet Connection',
message: 'Please check your internet connection and try again.',
content: [
InfoContent(
title: 'Action needed',
message: 'Make sure you are connected to WiFi or mobile data.',
),
],
okTitle: 'OK',
),
);
}
/// Show poor network quality warning
static void showPoorNetworkQuality(BuildContext context) {
showDialog(
context: context,
barrierDismissible: true,
builder: (context) => InfoDialog(
title: 'Poor Network Quality',
message: 'Your network connection is weak. Call quality may be affected.',
content: [
InfoContent(
title: 'Recommendation',
message: 'Try moving to an area with better signal or connect to WiFi.',
),
],
okTitle: 'Continue Anyway',
),
);
}
/// Show server error dialog
static void showServerError(BuildContext context, {String? errorMessage}) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Server Error',
message: errorMessage ?? 'Unable to reach the server. Please try again later.',
okTitle: 'OK',
),
);
}
/// Show call initialization timeout
static void showCallInitializationTimeout(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Timeout',
message: 'Call setup is taking too long. Please check your connection and try again.',
okTitle: 'OK',
),
);
}
/// Show media device error (camera/microphone)
static void showMediaDeviceError(BuildContext context, String deviceName) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'Device Error',
message: 'Unable to access your $deviceName.',
content: [
InfoContent(
title: 'Possible causes',
message: 'Another app may be using the $deviceName, or the device may be unavailable.',
),
InfoContent(
title: 'Solution',
message: 'Close other apps using the $deviceName and try again.',
),
],
okTitle: 'OK',
),
);
}
/// Show call ended unexpectedly
static void showCallEndedUnexpectedly(BuildContext context, {String? reason}) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Call Ended',
message: reason ?? 'The call ended unexpectedly. This may be due to network issues.',
okTitle: 'OK',
),
);
}
/// Show peer connection lost
static void showPeerConnectionLost(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Lost',
message: 'Lost connection to the other participant. The call has ended.',
okTitle: 'OK',
),
);
}
/// Check and handle internet connectivity before call
static Future<bool> checkInternetConnectionForCall(BuildContext context) async {
final hasInternet = await hasInternetConnection();
if (!hasInternet) {
showNoInternetConnection(context);
return false;
}
return true;
}
/// Show backend not reachable error
static void showBackendNotReachable(BuildContext context) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'Server Unreachable',
message: 'Unable to connect to the server.',
content: [
InfoContent(
title: 'Please check',
message: 'Your internet connection and try again. If the problem persists, the server may be down.',
),
],
okTitle: 'OK',
),
);
}
/// Show SignalR disconnected during call
static void showSignalRDisconnected(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const SingleBtnDialog(
title: 'Connection Lost',
message: 'Lost connection to the server. The call has ended.',
okTitle: 'OK',
),
);
}
/// Show call setup timeout error
static void showCallSetupTimeout(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Setup Failed',
message: 'Call setup timed out. Please check your connection and try again.',
okTitle: 'OK',
),
);
}
/// Handle WebRTC media error with specific message
static void handleMediaError(BuildContext context, dynamic error) {
final errorString = error.toString().toLowerCase();
if (errorString.contains('permission') || errorString.contains('denied')) {
if (errorString.contains('camera') || errorString.contains('video')) {
showCameraPermissionDenied(context);
} else {
showMicrophonePermissionDenied(context);
}
} else if (errorString.contains('notfound') || errorString.contains('not found')) {
showMediaDeviceError(context, 'device');
} else if (errorString.contains('notreadable') || errorString.contains('in use')) {
showMediaDeviceError(context, 'camera or microphone');
} else {
showWebRTCInitFailed(context);
}
}
/// Handle ICE connection error
static void handleICEConnectionError(BuildContext context) {
showICEConnectionFailed(context);
}
/// Handle unexpected error with fallback message
static void handleUnexpectedError(BuildContext context, dynamic error, {String? customMessage}) {
final message = customMessage ?? 'An unexpected error occurred. Please try again.';
showGenericError(context, message);
}
/// Validate and show appropriate error for call failure
static Future<bool> validateCallPreconditions(
BuildContext context, {
required bool checkInternet,
required bool checkPermissions,
required bool isVideoCall,
}) async {
// Check internet connection
if (checkInternet) {
final hasInternet = await hasInternetConnection();
if (!hasInternet) {
showNoInternetConnection(context);
return false;
}
}
// Check microphone permission
if (checkPermissions) {
final micStatus = await Permission.microphone.status;
if (!micStatus.isGranted) {
showMicrophonePermissionDenied(context);
return false;
}
// Check camera permission for video calls
if (isVideoCall) {
final cameraStatus = await Permission.camera.status;
if (!cameraStatus.isGranted) {
showCameraPermissionDenied(context);
return false;
}
}
}
return true;
}
}

@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/string_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import '../chat_provider.dart';
import '../model/call_session.dart';
import 'audio_call_page.dart';
import 'video_call_page.dart';
/// Native-style full-screen incoming call dialog matching audio call page design
class IncomingCallDialog extends StatefulWidget {
final CallSession call;
const IncomingCallDialog({
Key? key,
required this.call,
}) : super(key: key);
static void show(BuildContext context, CallSession call) {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => IncomingCallDialog(call: call),
),
);
}
@override
State<IncomingCallDialog> createState() => _IncomingCallDialogState();
}
class _IncomingCallDialogState extends State<IncomingCallDialog> {
@override
void initState() {
super.initState();
// Listen for call status changes
WidgetsBinding.instance.addPostFrameCallback((_) {
final chatProvider = context.read<ChatProvider>();
chatProvider.addListener(_checkCallStatus);
});
}
void _checkCallStatus() {
if (!mounted) return; // Early exit if widget is unmounted
final chatProvider = context.read<ChatProvider>();
// If call status is idle (call cancelled/ended), automatically dismiss dialog
if (chatProvider.callStatus == CallStatus.idle) {
// Remove listener before popping
chatProvider.removeListener(_checkCallStatus);
Navigator.of(context).pop();
}
}
@override
void dispose() {
// Clean up listener
try {
context.read<ChatProvider>().removeListener(_checkCallStatus);
} catch (e) {
// Provider might already be disposed
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final chatProvider = context.read<ChatProvider>();
return PopScope(
canPop: false, // Prevent dismissing by back button
child: Scaffold(
backgroundColor: AppColor.backgroundTabBarDark,
body: Column(
children: [
const Spacer(flex: 2),
_buildContactInfo(context, widget.call),
8.height,
_buildIncomingCallStatus(context),
const Spacer(flex: 4),
_buildActions(context, chatProvider, widget.call),
32.height,
],
),
),
);
}
Widget _buildContactInfo(BuildContext context, CallSession session) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.whiteF8d,
border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1),
),
child: ClipOval(
child: session.peerAvatar != null
? CachedNetworkImage(
imageUrl: session.peerAvatar!,
fit: BoxFit.cover,
placeholder: (context, url) => Center(
child: CircularProgressIndicator(
color: Colors.white.withOpacity(0.5),
),
),
errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
)
: 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
),
),
24.height,
Text(
session.peerName,
style: AppTextStyles.heading2.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildIncomingCallStatus(BuildContext context) {
return Column(
children: [
Text(
widget.call.type == CallType.video ? 'Atoms Video Call' : 'Atoms Audio Call',
style: AppTextStyles.heading5.copyWith(
color: AppColor.neutral100,
fontWeight: FontWeight.w400,
),
),
8.height,
Text(
'Incoming call...',
style: AppTextStyles.heading5.copyWith(
color: AppColor.neutral100,
fontWeight: FontWeight.w400,
),
),
],
);
}
Widget _buildActions(BuildContext context, ChatProvider chatProvider, CallSession session) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Decline button
_buildActionButton(
icon: 'end_call',
label: 'Decline',
onPressed: () async {
Navigator.of(context).pop();
await chatProvider.declineCall('declined');
},
),
// Accept button
_buildActionButton(
icon: 'calling_icon',
label: 'Accept',
onPressed: () async {
// Just dismiss the dialog - acceptCall() will handle navigation
Navigator.of(context).pop();
await chatProvider.acceptCall();
},
),
],
);
}
Widget _buildActionButton({
required String icon,
required String label,
required VoidCallback onPressed,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: icon == 'end_call' ? AppColor.red30 : Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
alignment: Alignment.center,
child: icon.toSvgAsset(height: 32, width: 32, color: AppColor.white10),
).onPress(onPressed),
8.height,
Text(
label,
style: AppTextStyles.heading5.copyWith(
color: AppColor.neutral100,
fontWeight: FontWeight.w400,
),
),
],
);
}
}

@ -0,0 +1,141 @@
import 'dart:developer';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
class CallNotificationService {
static final CallNotificationService _instance = CallNotificationService._internal();
factory CallNotificationService() => _instance;
CallNotificationService._internal();
final Set<String> _processedCallIds = {};
Map<String, dynamic>? _pendingCallData;
Future<void> handleIncomingCallNotification({
required Map<String, dynamic> notificationData,
required String source, // 'push' or 'signalr'
}) async {
try {
log('═══════════════════════════════════════════', name: 'CallNotificationService');
log('📞 [INCOMING CALL] Raw data: $notificationData', name: 'CallNotificationService');
final notificationType = notificationData['notificationType'] as String? ??
notificationData['type'] as String?; // Backend uses 'type'
final transactionType = notificationData['transactionType'] as String?;
log('📞 [INCOMING CALL] Notification Type: $notificationType', name: 'CallNotificationService');
// Verify this is a call notification
if (notificationType != 'incoming_call' && transactionType != 'call') {
log(' Got: notificationType=$notificationType, transactionType=$transactionType', name: 'CallNotificationService');
return;
}
// FIXED: Extract fields matching actual backend format
final callId = notificationData['callId'] as String? ??
notificationData['sessionId'] as String? ??
DateTime.now().millisecondsSinceEpoch.toString(); // Generate if missing
// CRITICAL FIX: Use callerEmployeeNumber (the person calling), NOT calleeEmployeeNumber (the receiver)
final callerId = notificationData['callerId'] as String? ??
notificationData['callerEmployeeNumber'] as String? ?? // The actual caller
notificationData['sourceUserId'] as String?;
final callerName = notificationData['callerName'] as String? ??
notificationData['callerUserName'] as String? ?? // Backend field
notificationData['userName'] as String? ??
'Unknown Caller';
final callerEmployeeNumber = notificationData['callerEmployeeNumber'] as String?;
// FIXED: Handle isVideoCall as String from backend
final isVideoCall = () {
final value = notificationData['isVideoCall'];
if (value is bool) {
return value;
} else if (value is String) {
return value.toLowerCase() == 'true';
} else if (notificationData['callType'] == 'video') {
return true;
}
return false;
}();
final moduleId = notificationData['moduleId'] as String? ??
notificationData['applicationId']?.toString();
final referenceId = notificationData['referenceId'] as String?;
final conversationId = notificationData['conversationId'] as String?;
log(' Caller Employee Number: $callerEmployeeNumber', name: 'CallNotificationService');
if (callerId == null) {
log('❌ [INCOMING CALL] Missing callerId/calleeEmployeeNumber', name: 'CallNotificationService');
return;
}
// CRITICAL: Prevent duplicate processing
if (_processedCallIds.contains(callId)) {
log('⚠️ [INCOMING CALL] Already processed call $callId, ignoring duplicate', name: 'CallNotificationService');
return;
}
_processedCallIds.add(callId);
// Clean up old processed IDs (keep last 50)
if (_processedCallIds.length > 50) {
final oldIds = _processedCallIds.take(_processedCallIds.length - 50).toList();
_processedCallIds.removeAll(oldIds);
}
log('✅ [INCOMING CALL] Valid call notification', name: 'CallNotificationService');
// Store pending call data
_pendingCallData = {
'callId': callId,
'callerId': callerId,
'callerName': callerName,
'callerEmployeeNumber': callerEmployeeNumber,
'isVideoCall': isVideoCall,
'moduleId': moduleId,
'referenceId': referenceId,
'conversationId': conversationId,
'timestamp': DateTime.now().toIso8601String(),
};
// Delegate to CallManager - it will show CallKit UI once
log('📞 [INCOMING CALL] Delegating to CallManager...', name: 'CallNotificationService');
await CallManager().handleIncomingCallNotification(
callId: callId,
callerId: callerId,
callerName: callerName,
isVideoCall: isVideoCall,
extraData: _pendingCallData,
);
log('✅ [INCOMING CALL] Call handled successfully', name: 'CallNotificationService');
log('═══════════════════════════════════════════', name: 'CallNotificationService');
} catch (e, stackTrace) {
log('❌ [INCOMING CALL] Error handling notification: $e',
name: 'CallNotificationService', error: e, stackTrace: stackTrace);
}
}
/// Get pending call data (used when app wakes up)
Map<String, dynamic>? get pendingCallData => _pendingCallData;
/// Clear pending call data
void clearPendingCallData() {
_pendingCallData = null;
}
/// Mark a call as processed (for external use)
void markCallAsProcessed(String callId) {
_processedCallIds.add(callId);
}
/// Check if a call has been processed
bool isCallProcessed(String callId) {
return _processedCallIds.contains(callId);
}
/// Reset the service (for testing or logout)
void reset() {
_processedCallIds.clear();
_pendingCallData = null;
log('🔄 [RESET] CallNotificationService reset', name: 'CallNotificationService');
}
}

@ -0,0 +1,386 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:uuid/uuid.dart';
/// Service to handle CallKit (iOS) and ConnectionService (Android) integration
/// Provides native call UI experience on both platforms
class CallKitService {
static final CallKitService _instance = CallKitService._internal();
factory CallKitService() => _instance;
CallKitService._internal();
// Callbacks for call events
Function(String callId)? onCallAccepted;
Function(String callId)? onCallDeclined;
Function(String callId)? onCallEnded;
Function(String callId)? onCallTimeout;
// Stream subscription for CallKit events
StreamSubscription<CallEvent?>? _eventSubscription;
// Current active call UUID
String? _currentCallId;
/// Initialize CallKit service and listen for events
Future<void> initialize() async {
if (kDebugMode) {
log('📞 [CallKit] Initializing CallKit service', name: 'CallKitService');
}
// Listen for CallKit events
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(_handleCallKitEvent);
if (kDebugMode) {
log('✅ [CallKit] Service initialized', name: 'CallKitService');
}
}
/// Handle CallKit events from native side
void _handleCallKitEvent(CallEvent? event) {
if (event == null) return;
if (kDebugMode) {
log('📞 [CallKit] Event received: ${event.toString()}', name: 'CallKitService');
log('📞 [CallKit] Event type: ${event.eventName}', name: 'CallKitService');
}
// Handle different event types using pattern matching
switch (event) {
case CallEventActionCallAccept(:final callKitParams):
// User accepted the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('✅ [CallKit] Call ACCEPTED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallAccepted?.call(callId);
break;
case CallEventActionCallDecline(:final callKitParams):
// User declined the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('❌ [CallKit] Call DECLINED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallDeclined?.call(callId);
break;
case CallEventActionCallEnded(:final callKitParams):
// User ended the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('🔴 [CallKit] Call ENDED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallEnded?.call(callId);
break;
case CallEventActionCallTimeout(:final id):
// Call timed out (no answer)
if (kDebugMode) {
log('⏱️ [CallKit] Call TIMEOUT - calling callback', name: 'CallKitService');
log(' Call ID: $id', name: 'CallKitService');
}
onCallTimeout?.call(id);
break;
case CallEventActionCallToggleMute(:final id, :final isMuted):
// User toggled mute via CallKit UI
if (kDebugMode) {
log('🔇 [CallKit] Mute toggled: $isMuted for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallToggleHold(:final id, :final isOnHold):
// User toggled hold via CallKit UI
if (kDebugMode) {
log('⏸️ [CallKit] Hold toggled: $isOnHold for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallIncoming(:final callKitParams):
// Incoming call notification shown
if (kDebugMode) {
log('📲 [CallKit] Incoming call notification for: ${callKitParams.id}', name: 'CallKitService');
}
break;
case CallEventActionCallStart(:final callKitParams):
// Call started
if (kDebugMode) {
log('📞 [CallKit] Call started: ${callKitParams.id}', name: 'CallKitService');
}
break;
case CallEventActionCallCallback(:final id):
// Callback event
if (kDebugMode) {
log('📞 [CallKit] Callback event for call: $id', name: 'CallKitService');
}
break;
case CallEventActionCallConnected(:final id):
// Call connected
if (kDebugMode) {
log('✅ [CallKit] Call connected: $id', name: 'CallKitService');
}
break;
case CallEventActionDidUpdateDevicePushTokenVoip():
// Push token updated
if (kDebugMode) {
log('🔔 [CallKit] Device push token updated', name: 'CallKitService');
}
break;
case CallEventActionCallToggleDmtf(:final id, :final digits, :final type):
// DTMF toggled
if (kDebugMode) {
log('🔢 [CallKit] DTMF toggled: $digits for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallToggleGroup(:final id, :final callUUIDToGroupWith):
// Call group toggled
if (kDebugMode) {
log('👥 [CallKit] Group toggled for call $id with $callUUIDToGroupWith', name: 'CallKitService');
}
break;
case CallEventActionCallToggleAudioSession(:final isActive):
// Audio session toggled
if (kDebugMode) {
log('🔊 [CallKit] Audio session toggled: $isActive', name: 'CallKitService');
}
break;
case CallEventActionCallCustom(:final body):
// Custom event
if (kDebugMode) {
log('🔧 [CallKit] Custom event: $body', name: 'CallKitService');
}
break;
default:
if (kDebugMode) {
log(' [CallKit] Unhandled event type: ${event.eventName}', name: 'CallKitService');
}
}
}
/// Show incoming call UI (CallKit on iOS, ConnectionService on Android)
Future<void> showIncomingCall({
required String callId,
required String callerName,
required String callerNumber,
String? callerAvatar,
required bool isVideo,
Map<String, dynamic>? extra,
}) async {
try {
if (kDebugMode) {
log('📞 [CallKit] Showing incoming call UI', name: 'CallKitService');
log(' Caller: $callerName ($callerNumber)', name: 'CallKitService');
log(' Video: $isVideo', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
_currentCallId = callId;
// Configure call parameters
final params = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'Atoms SA',
avatar: callerAvatar,
handle: callerNumber,
type: isVideo ? 1 : 0, // 0 = audio, 1 = video
duration: 30000, // 30 seconds timeout
extra: extra ?? {},
headers: <String, dynamic>{'platform': 'flutter'},
android: AndroidParams(
isCustomNotification: true,
isShowLogo: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: '#0955fa',
backgroundUrl: callerAvatar ?? '',
actionColor: '#4CAF50',
textColor: '#ffffff',
incomingCallNotificationChannelName: 'Incoming Calls',
missedCallNotificationChannelName: 'Missed Calls',
),
ios: const IOSParams(
iconName: 'CallKitLogo',
handleType: 'generic',
supportsVideo: true,
maximumCallGroups: 2,
maximumCallsPerCallGroup: 1,
audioSessionMode: 'videoChat',
audioSessionActive: true,
audioSessionPreferredSampleRate: 44100.0,
audioSessionPreferredIOBufferDuration: 0.005,
supportsDTMF: true,
supportsHolding: true,
supportsGrouping: false,
supportsUngrouping: false,
ringtonePath: 'system_ringtone_default',
),
);
// Show the incoming call UI
await FlutterCallkitIncoming.showCallkitIncoming(params);
if (kDebugMode) {
log('✅ [CallKit] Incoming call UI displayed', name: 'CallKitService');
}
} catch (e, stackTrace) {
log('❌ [CallKit] Error showing incoming call: $e',
name: 'CallKitService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Start an outgoing call (show native UI)
Future<void> startOutgoingCall({
required String callId,
required String callerName,
required String callerNumber,
String? callerAvatar,
required bool isVideo,
}) async {
try {
if (kDebugMode) {
log('📞 [CallKit] Starting outgoing call', name: 'CallKitService');
}
_currentCallId = callId;
final params = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'Atoms SA',
avatar: callerAvatar,
handle: callerNumber,
type: isVideo ? 1 : 0,
extra: <String, dynamic>{'outgoing': true},
ios: const IOSParams(
handleType: 'generic',
supportsVideo: true,
audioSessionMode: 'videoChat',
audioSessionActive: true,
),
);
await FlutterCallkitIncoming.startCall(params);
if (kDebugMode) {
log('✅ [CallKit] Outgoing call started', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error starting outgoing call: $e', name: 'CallKitService');
}
}
/// Mark call as connected (when peer accepts)
Future<void> setCallConnected(String callId) async {
try {
if (kDebugMode) {
log('✅ [CallKit] Marking call as connected: $callId', name: 'CallKitService');
}
// Update call to connected state - this will update the native UI
await FlutterCallkitIncoming.setCallConnected(callId);
if (kDebugMode) {
log('✅ [CallKit] Call marked as connected', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error setting call connected: $e', name: 'CallKitService');
}
}
/// End the current call
Future<void> endCall(String callId) async {
try {
if (kDebugMode) {
log('🔴 [CallKit] Ending call: $callId', name: 'CallKitService');
}
await FlutterCallkitIncoming.endCall(callId);
if (_currentCallId == callId) {
_currentCallId = null;
}
if (kDebugMode) {
log('✅ [CallKit] Call ended', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error ending call: $e', name: 'CallKitService');
}
}
/// End all active calls
Future<void> endAllCalls() async {
try {
if (kDebugMode) {
log('🔴 [CallKit] Ending all calls', name: 'CallKitService');
}
await FlutterCallkitIncoming.endAllCalls();
_currentCallId = null;
if (kDebugMode) {
log('✅ [CallKit] All calls ended', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error ending all calls: $e', name: 'CallKitService');
}
}
/// Get all active calls
Future<List<dynamic>> getActiveCalls() async {
try {
final calls = await FlutterCallkitIncoming.activeCalls();
if (kDebugMode) {
log('📋 [CallKit] Active calls: ${calls.length}', name: 'CallKitService');
}
return calls;
} catch (e) {
log('❌ [CallKit] Error getting active calls: $e', name: 'CallKitService');
return [];
}
}
/// Dispose and cleanup
Future<void> dispose() async {
try {
if (kDebugMode) {
log('🧹 [CallKit] Disposing service', name: 'CallKitService');
}
// Cancel event subscription
await _eventSubscription?.cancel();
_eventSubscription = null;
// End all active calls
await endAllCalls();
// Clear callbacks
onCallAccepted = null;
onCallDeclined = null;
onCallEnded = null;
onCallTimeout = null;
if (kDebugMode) {
log('✅ [CallKit] Service disposed', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error disposing service: $e', name: 'CallKitService');
}
}
}

@ -0,0 +1,827 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
/// WebRTC service for handling peer-to-peer audio/video connections
class WebRTCService {
// Peer connection
RTCPeerConnection? _peerConnection;
// Media streams
MediaStream? _localStream;
MediaStream? _remoteStream;
// Video renderers (for future video support)
RTCVideoRenderer? localRenderer;
RTCVideoRenderer? remoteRenderer;
// Callbacks
Function(MediaStream stream)? onLocalStream;
Function(MediaStream stream)? onRemoteStream;
Function(RTCIceCandidate candidate)? onIceCandidate;
Function(RTCIceConnectionState state)? onIceConnectionStateChange;
Function()? onCallEnded;
// ICE candidate queue (for candidates received before remote description)
final List<RTCIceCandidate> _iceCandidateQueue = [];
bool _remoteDescriptionSet = false;
// CRITICAL FIX: Atomic initialization tracking
Completer<void>? _initializationCompleter;
bool _isFullyInitialized = false;
// Track if current call is video call
bool _isVideoCall = false;
// Track if we're using test mode (Google STUN only)
static bool _useTestMode = false;
/// Enable test mode (use only Google STUN servers for debugging)
static void enableTestMode() {
_useTestMode = true;
if (kDebugMode) {
log('⚙️ [WebRTC] Test mode enabled', name: 'WebRTCService');
}
}
/// Disable test mode (use backend TURN servers)
static void disableTestMode() {
_useTestMode = false;
if (kDebugMode) {
log('⚙️ [WebRTC] Test mode disabled', name: 'WebRTCService');
}
}
// Configuration
static const Map<String, dynamic> _mediaConstraints = {
'audio': true,
'video': false,
};
// Video constraints for video calls
static const Map<String, dynamic> _videoConstraints = {
'audio': true,
'video': {
'facingMode': 'user',
'width': {'ideal': 1280},
'height': {'ideal': 720},
},
};
// ICE servers configuration for TEST MODE (Google STUN only)
static const Map<String, dynamic> _testConfiguration = {
'iceServers': [
{
'urls': [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
]
},
],
'sdpSemantics': 'unified-plan',
'iceTransportPolicy': 'all',
'iceCandidatePoolSize': 10,
};
// ICE servers configuration (from backend)
final Map<String, dynamic> _configuration = {
'iceServers': [
// Primary STUN servers (Google's public STUN - highly reliable)
{
'urls': [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302',
]
},
// Backend TURN server (PRIMARY)
{
'urls': [
'turn:15.185.116.59:3479',
'turn:15.185.116.59:3479?transport=tcp',
],
'username': 'admin',
'credential': 'admin'
},
// FALLBACK: Public TURN servers
{
'urls': 'turn:a.relay.metered.ca:80',
'username': 'e14d93f87f9ff517f1bee797',
'credential': 'PGfCGmR6CR2aM9OY',
},
{
'urls': 'turn:a.relay.metered.ca:443',
'username': 'e14d93f87f9ff517f1bee797',
'credential': 'PGfCGmR6CR2aM9OY',
},
],
'sdpSemantics': 'unified-plan',
'iceTransportPolicy': 'all',
'iceCandidatePoolSize': 10,
'bundlePolicy': 'max-bundle',
'rtcpMuxPolicy': 'require',
};
/// Initialize WebRTC for audio call
Future<void> initializeForAudioCall() async {
// CRITICAL FIX: Create completer and mark as not initialized
_initializationCompleter = Completer<void>();
_isFullyInitialized = false;
try {
log('🔧 [WebRTC] Initialization Started', name: 'WebRTCService');
_isVideoCall = false;
// Get local audio stream
try {
log('🎤 [WebRTC] Requesting microphone access...', name: 'WebRTCService');
_localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints);
// CRITICAL: Verify audio tracks were captured
final audioTracks = _localStream!.getAudioTracks();
log('✅ [WebRTC] Local audio stream captured', name: 'WebRTCService');
log(' Audio tracks count: ${audioTracks.length}', name: 'WebRTCService');
for (var i = 0; i < audioTracks.length; i++) {
final track = audioTracks[i];
log(' Track $i: ${track.kind} - ID: ${track.id}', name: 'WebRTCService');
log(' Track $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
// CRITICAL FIX: Ensure track is enabled
if (!track.enabled) {
log('⚠️ [WebRTC] Track $i was disabled, enabling it', name: 'WebRTCService');
track.enabled = true;
}
}
if (audioTracks.isEmpty) {
throw Exception('No audio tracks captured from microphone');
}
} catch (e) {
log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e);
throw Exception('Microphone access denied or unavailable');
}
// Create peer connection
log('🔧 [WebRTC] Creating peer connection...', name: 'WebRTCService');
_peerConnection = await createPeerConnection(_configuration);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
log('✅ [WebRTC] Peer connection created', name: 'WebRTCService');
// Add local stream tracks to peer connection
log('📤 [WebRTC] Adding local tracks to peer connection...', name: 'WebRTCService');
final tracks = _localStream!.getTracks();
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
log(' Adding track $i: ${track.kind} (ID: ${track.id})', name: 'WebRTCService');
await _peerConnection!.addTrack(track, _localStream!);
log(' ✅ Track $i added successfully', name: 'WebRTCService');
}
log('✅ [WebRTC] All local tracks added to peer connection', name: 'WebRTCService');
// Setup peer connection event handlers
_setupPeerConnectionListeners();
// CRITICAL FIX: Set speakerphone OFF by default (use earpiece)
log('🔇 [WebRTC] Setting speakerphone OFF by default (earpiece mode)...', name: 'WebRTCService');
try {
await Helper.setSpeakerphoneOn(false);
log('✅ [WebRTC] Speakerphone disabled (earpiece mode)', name: 'WebRTCService');
} catch (e) {
log('⚠️ [WebRTC] Failed to disable speakerphone: $e', name: 'WebRTCService');
}
// CRITICAL FIX: Mark as fully initialized AFTER everything is ready
_isFullyInitialized = true;
if (!_initializationCompleter!.isCompleted) {
_initializationCompleter!.complete();
}
log('✅ [WebRTC] Initialization Completed - PeerConnection Ready', name: 'WebRTCService');
} catch (e, stackTrace) {
log('❌ [WebRTC] Initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
// Complete with error
if (_initializationCompleter != null && !_initializationCompleter!.isCompleted) {
_initializationCompleter!.completeError(e, stackTrace);
}
_isFullyInitialized = false;
// Clean up any partial initialization
await dispose();
rethrow;
}
}
/// Initialize WebRTC for video call
Future<void> initializeForVideoCall() async {
try {
if (kDebugMode) {
log('🔧 [WebRTC] Initializing video call', name: 'WebRTCService');
}
_isVideoCall = true;
// Initialize video renderers
localRenderer = RTCVideoRenderer();
remoteRenderer = RTCVideoRenderer();
try {
await localRenderer!.initialize();
await remoteRenderer!.initialize();
} catch (e) {
log('❌ [WebRTC] Failed to initialize renderers: $e', name: 'WebRTCService', error: e);
throw Exception('Failed to initialize video renderers');
}
// Create peer connection FIRST (before getting media)
final config = _useTestMode ? _testConfiguration : _configuration;
_peerConnection = await createPeerConnection(config);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
// Setup peer connection listeners immediately
_setupPeerConnectionListeners();
// Get local audio + video stream
try {
_localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints);
} catch (e) {
log('❌ [WebRTC] Failed to get video stream: $e', name: 'WebRTCService', error: e);
throw Exception('Camera or microphone access denied or unavailable');
}
// Set local stream to renderer
if (localRenderer != null) {
localRenderer!.srcObject = _localStream;
}
// Add local stream tracks to peer connection
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
if (kDebugMode) {
log('✅ [WebRTC] Video call initialized', name: 'WebRTCService');
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Video initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
// Clean up any partial initialization
await dispose();
rethrow;
}
}
/// Setup peer connection event listeners
void _setupPeerConnectionListeners() {
log('🔧 [WebRTC] Setting up peer connection listeners...', name: 'WebRTCService');
// Handle ICE candidates
_peerConnection!.onIceCandidate = (RTCIceCandidate candidate) {
log('🧊 [WebRTC] onIceCandidate triggered', name: 'WebRTCService');
if (onIceCandidate != null) {
onIceCandidate!(candidate);
} else {
log('⚠️ [WebRTC] onIceCandidate callback is NULL!', name: 'WebRTCService');
}
};
// Handle ICE gathering state changes
_peerConnection!.onIceGatheringState = (RTCIceGatheringState state) {
log('📊 [WebRTC] ICE gathering state: ${state.toString()}', name: 'WebRTCService');
if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) {
log('✅ [WebRTC] ICE gathering completed', name: 'WebRTCService');
}
};
// Handle ICE connection state changes
_peerConnection!.onIceConnectionState = (RTCIceConnectionState state) {
log('═══════════════════════════════════════════', name: 'WebRTCService');
log('🔗 [WebRTC] onIceConnectionState TRIGGERED', name: 'WebRTCService');
log(' State: ${state.toString()}', name: 'WebRTCService');
log(' Callback is null?: ${onIceConnectionStateChange == null}', name: 'WebRTCService');
log('═══════════════════════════════════════════', name: 'WebRTCService');
// Log critical failures
if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
log('❌ [WebRTC] ICE connection failed', name: 'WebRTCService');
}
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
log('✅ [WebRTC] ICE connection CONNECTED!', name: 'WebRTCService');
}
if (onIceConnectionStateChange != null) {
log('📤 [WebRTC] Forwarding ICE state to callback...', name: 'WebRTCService');
onIceConnectionStateChange!(state);
log('✅ [WebRTC] ICE state forwarded to callback', name: 'WebRTCService');
} else {
log('❌ [WebRTC] CRITICAL: onIceConnectionStateChange callback is NULL!', name: 'WebRTCService');
log(' This means CallManager did not set up callbacks properly!', name: 'WebRTCService');
}
};
// Handle remote stream
_peerConnection!.onTrack = (RTCTrackEvent event) {
log('═══════════════════════════════════════════', name: 'WebRTCService');
log('📡 [WebRTC] onTrack event received!', name: 'WebRTCService');
log(' Track kind: ${event.track.kind}', name: 'WebRTCService');
log(' Track ID: ${event.track.id}', name: 'WebRTCService');
log(' Track enabled: ${event.track.enabled}', name: 'WebRTCService');
log(' Track muted: ${event.track.muted}', name: 'WebRTCService');
// log(' Track readyState: ${event.track.readyState}', name: 'WebRTCService');
log(' Streams count: ${event.streams.length}', name: 'WebRTCService');
if (event.streams.isNotEmpty) {
final stream = event.streams[0];
log(' Stream ID: ${stream.id}', name: 'WebRTCService');
log(' Audio tracks: ${stream.getAudioTracks().length}', name: 'WebRTCService');
log(' Video tracks: ${stream.getVideoTracks().length}', name: 'WebRTCService');
// Log all tracks in the stream
final allTracks = stream.getTracks();
for (var i = 0; i < allTracks.length; i++) {
final track = allTracks[i];
log(' Track $i: ${track.kind} - enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
}
// If this is the first remote stream or a different stream, update it
if (_remoteStream == null || _remoteStream!.id != stream.id) {
log('✅ [WebRTC] Setting new remote stream', name: 'WebRTCService');
_remoteStream = stream;
// For video calls, assign stream to renderer
if (remoteRenderer != null && _isVideoCall) {
try {
remoteRenderer!.srcObject = _remoteStream;
// Retry assignment after delays to ensure it sticks
Future.delayed(const Duration(milliseconds: 100), () {
if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
remoteRenderer!.srcObject = _remoteStream;
}
});
Future.delayed(const Duration(milliseconds: 300), () {
if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
remoteRenderer!.srcObject = _remoteStream;
}
});
} catch (e) {
log('⚠️ [WebRTC] Failed to assign remote stream: $e', name: 'WebRTCService');
}
}
// Notify callback
if (onRemoteStream != null) {
onRemoteStream!(_remoteStream!);
}
log('✅ [WebRTC] Remote stream set and callback notified', name: 'WebRTCService');
} else {
log(' [WebRTC] Additional track on existing stream', name: 'WebRTCService');
// Additional track on existing stream
if (remoteRenderer != null && _isVideoCall && remoteRenderer!.srcObject == null && _remoteStream != null) {
try {
remoteRenderer!.srcObject = _remoteStream;
} catch (e) {
log('⚠️ [WebRTC] Failed to assign stream (additional track): $e', name: 'WebRTCService');
}
}
if (onRemoteStream != null) {
onRemoteStream!(_remoteStream!);
}
}
} else {
log('⚠️ [WebRTC] No streams in onTrack event!', name: 'WebRTCService');
}
log('═══════════════════════════════════════════', name: 'WebRTCService');
};
// Handle connection state changes
_peerConnection!.onConnectionState = (RTCPeerConnectionState state) {
if (kDebugMode || state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
log('🔌 [WebRTC] Connection state: ${state.toString()}', name: 'WebRTCService');
}
};
}
/// Create SDP offer (caller side)
Future<RTCSessionDescription> createOffer() async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
log('🔧 [WebRTC] Creating SDP offer...', name: 'WebRTCService');
// CRITICAL: Verify local tracks before creating offer
final senders = await _peerConnection!.getSenders();
log('📊 [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService');
for (var i = 0; i < senders.length; i++) {
final sender = senders[i];
final track = sender.track;
if (track != null) {
log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService');
log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
} else {
log(' Sender $i: NO TRACK!', name: 'WebRTCService');
}
}
final offer = await _peerConnection!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
'iceRestart': false,
});
await _peerConnection!.setLocalDescription(offer);
if (kDebugMode) {
log('✅ [WebRTC] SDP offer created', name: 'WebRTCService');
log(' Offer type: ${offer.type}', name: 'WebRTCService');
log(' Offer SDP length: ${offer.sdp?.length ?? 0}', name: 'WebRTCService');
// CRITICAL: Log SDP to verify audio track is included
if (offer.sdp != null) {
final sdpLines = offer.sdp!.split('\n');
final audioLines = sdpLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] SDP Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!offer.sdp!.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in SDP offer!', name: 'WebRTCService');
}
}
}
return offer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating offer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Create SDP answer (callee side)
Future<RTCSessionDescription> createAnswer(String offerSdp) async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
log('🔧 [WebRTC] Creating SDP answer...', name: 'WebRTCService');
log(' Received offer SDP length: ${offerSdp.length}', name: 'WebRTCService');
// CRITICAL: Log received offer to verify it has audio
if (kDebugMode) {
final offerLines = offerSdp.split('\n');
final audioLines = offerLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] Received Offer Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!offerSdp.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in received offer!', name: 'WebRTCService');
}
}
// CRITICAL: Verify local tracks before creating answer
final senders = await _peerConnection!.getSenders();
log('📊 [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService');
for (var i = 0; i < senders.length; i++) {
final sender = senders[i];
final track = sender.track;
if (track != null) {
log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService');
log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
} else {
log(' Sender $i: NO TRACK!', name: 'WebRTCService');
}
}
// Set remote description (offer from caller)
final offer = RTCSessionDescription(offerSdp, 'offer');
log('🔧 [WebRTC] Setting remote description (offer)...', name: 'WebRTCService');
await _peerConnection!.setRemoteDescription(offer);
_remoteDescriptionSet = true;
log('✅ [WebRTC] Remote description set', name: 'WebRTCService');
// Flush queued ICE candidates
await _flushIceCandidateQueue();
// Create answer
log('🔧 [WebRTC] Creating answer...', name: 'WebRTCService');
final answer = await _peerConnection!.createAnswer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
});
await _peerConnection!.setLocalDescription(answer);
if (kDebugMode) {
log('✅ [WebRTC] SDP answer created', name: 'WebRTCService');
log(' Answer type: ${answer.type}', name: 'WebRTCService');
log(' Answer SDP length: ${answer.sdp?.length ?? 0}', name: 'WebRTCService');
// CRITICAL: Log SDP to verify audio track is included
if (answer.sdp != null) {
final sdpLines = answer.sdp!.split('\n');
final audioLines = sdpLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] SDP Answer Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!answer.sdp!.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in SDP answer!', name: 'WebRTCService');
}
}
}
return answer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Set remote answer (caller side)
Future<void> setRemoteAnswer(String answerSdp) async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
final answer = RTCSessionDescription(answerSdp, 'answer');
await _peerConnection!.setRemoteDescription(answer);
_remoteDescriptionSet = true;
// Flush queued ICE candidates
await _flushIceCandidateQueue();
if (kDebugMode) {
log('✅ [WebRTC] Remote answer set', name: 'WebRTCService');
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Error setting remote answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Send SDP answer (convenience method for callee)
Future<RTCSessionDescription> sendSDPAnswer(String? offerSdp) async {
if (offerSdp == null) {
throw Exception('Offer SDP is null');
}
return await createAnswer(offerSdp);
}
/// Add remote ICE candidate
Future<void> addIceCandidate(RTCIceCandidate candidate) async {
try {
// If peer connection not created yet, queue the candidate
if (_peerConnection == null) {
_iceCandidateQueue.add(candidate);
return;
}
// If remote description not set yet, queue the candidate
if (!_remoteDescriptionSet) {
_iceCandidateQueue.add(candidate);
return;
}
await _peerConnection!.addCandidate(candidate);
} catch (e) {
log('⚠️ [WebRTC] Error adding ICE candidate: $e', name: 'WebRTCService');
// Don't rethrow - ICE candidate errors shouldn't break the call
}
}
/// Flush queued ICE candidates
Future<void> _flushIceCandidateQueue() async {
if (_iceCandidateQueue.isEmpty) {
return;
}
if (kDebugMode) {
log('🔄 [WebRTC] Flushing ${_iceCandidateQueue.length} ICE candidates', name: 'WebRTCService');
}
for (final candidate in _iceCandidateQueue) {
try {
await _peerConnection!.addCandidate(candidate);
} catch (e) {
log('⚠️ [WebRTC] Error adding queued candidate: $e', name: 'WebRTCService');
}
}
_iceCandidateQueue.clear();
}
/// Toggle microphone mute
void setMicrophoneMuted(bool muted) {
if (_localStream != null) {
final audioTracks = _localStream!.getAudioTracks();
for (final track in audioTracks) {
track.enabled = !muted;
}
}
}
/// Toggle camera on/off for video calls
void setCameraEnabled(bool enabled) {
if (_localStream != null) {
final videoTracks = _localStream!.getVideoTracks();
for (final track in videoTracks) {
track.enabled = enabled;
}
}
}
/// Switch between front and rear camera
Future<void> switchCamera() async {
if (_localStream != null) {
final videoTracks = _localStream!.getVideoTracks();
if (videoTracks.isNotEmpty) {
try {
await Helper.switchCamera(videoTracks.first);
if (kDebugMode) {
log('✅ [WebRTC] Camera switched', name: 'WebRTCService');
}
} catch (e) {
log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService');
}
}
}
}
/// Enable/disable speakerphone
Future<void> setSpeakerphoneEnabled(bool enabled) async {
try {
await Helper.setSpeakerphoneOn(enabled);
} catch (e) {
log('❌ [WebRTC] Error setting speakerphone: $e', name: 'WebRTCService');
}
}
/// Get local stream
MediaStream? get localStream => _localStream;
/// Get remote stream
MediaStream? get remoteStream => _remoteStream;
/// Check if remote stream has video tracks
bool get hasRemoteVideo {
if (_remoteStream == null) return false;
return _remoteStream!.getVideoTracks().isNotEmpty;
}
/// Force refresh remote renderer
void refreshRemoteRenderer() {
if (_remoteStream != null && remoteRenderer != null && _isVideoCall) {
if (remoteRenderer!.srcObject == null) {
remoteRenderer!.srcObject = _remoteStream;
if (kDebugMode) {
log('✅ [WebRTC] Remote renderer refreshed', name: 'WebRTCService');
}
}
}
}
/// Restart ICE negotiation (used when connection fails)
Future<RTCSessionDescription?> restartIce() async {
try {
if (_peerConnection == null) {
return null;
}
final offer = await _peerConnection!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
'iceRestart': true,
});
await _peerConnection!.setLocalDescription(offer);
if (kDebugMode) {
log('✅ [WebRTC] ICE restart initiated', name: 'WebRTCService');
}
return offer;
} catch (e) {
log('❌ [WebRTC] ICE restart failed: $e', name: 'WebRTCService');
return null;
}
}
/// Get peer connection state
/// CRITICAL FIX: Check if WebRTC is FULLY initialized (ready to process signaling)
bool get isFullyInitialized => _isFullyInitialized;
/// CRITICAL FIX: Wait for WebRTC initialization to complete
Future<void> waitForInitialization() async {
if (_initializationCompleter != null && !_initializationCompleter!.isCompleted) {
log('⏳ [WebRTC] Waiting for initialization to complete...', name: 'WebRTCService');
await _initializationCompleter!.future;
log('✅ [WebRTC] Initialization wait complete', name: 'WebRTCService');
}
}
RTCPeerConnectionState? getPeerConnectionState() {
return _peerConnection?.connectionState;
}
/// Get ICE connection state
RTCIceConnectionState? getIceConnectionState() {
return _peerConnection?.iceConnectionState;
}
/// Check if peer connection is initialized
bool get isPeerConnectionInitialized => _peerConnection != null;
/// Dispose and cleanup
Future<void> dispose() async {
try {
// Stop local stream tracks
if (_localStream != null) {
_localStream!.getTracks().forEach((track) {
track.stop();
});
await _localStream!.dispose();
_localStream = null;
}
// Stop remote stream tracks
if (_remoteStream != null) {
_remoteStream!.getTracks().forEach((track) {
track.stop();
});
await _remoteStream!.dispose();
_remoteStream = null;
}
// Dispose renderers
if (localRenderer != null) {
await localRenderer!.dispose();
localRenderer = null;
}
if (remoteRenderer != null) {
await remoteRenderer!.dispose();
remoteRenderer = null;
}
// Close peer connection
if (_peerConnection != null) {
await _peerConnection!.close();
await _peerConnection!.dispose();
_peerConnection = null;
}
// Clear ICE candidate queue
_iceCandidateQueue.clear();
_remoteDescriptionSet = false;
if (kDebugMode) {
log('✅ [WebRTC] Service disposed', name: 'WebRTCService');
}
} catch (e) {
log('⚠️ [WebRTC] Error during dispose: $e', name: 'WebRTCService');
}
}
}

@ -0,0 +1,404 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/app_style/app_themes.dart';
class VideoCallPage extends StatefulWidget {
const VideoCallPage({Key? key}) : super(key: key);
@override
State<VideoCallPage> createState() => _VideoCallPageState();
}
class _VideoCallPageState extends State<VideoCallPage> {
late CallManager _callManager;
Timer? _streamCheckTimer;
bool _isLocalVideoFullscreen = false;
@override
void initState() {
super.initState();
_callManager = CallManager();
// Listen for call end
_callManager.addListener(_checkCallStatus);
// Start periodic check to ensure remote stream gets assigned
_startStreamCheckTimer();
}
void _startStreamCheckTimer() {
_streamCheckTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
final webrtc = _callManager.webrtcService;
if (webrtc == null || !mounted) {
timer.cancel();
return;
}
// If we have remote stream but renderer doesn't have it, assign it
if (webrtc.remoteStream != null &&
webrtc.remoteRenderer != null &&
webrtc.remoteRenderer!.srcObject == null) {
try {
webrtc.remoteRenderer!.srcObject = webrtc.remoteStream;
if (mounted) setState(() {});
} catch (e) {
if (kDebugMode) {
log('⚠️ Stream assignment failed: $e', name: 'VideoCallPage');
}
}
}
// If call is connected and remote stream is assigned, slow down checks
if (_callManager.callStatus == CallStatus.connected &&
webrtc.remoteRenderer?.srcObject != null) {
timer.cancel();
}
});
}
void _checkCallStatus() {
if (_callManager.callStatus == CallStatus.idle && mounted) {
Navigator.of(context).pop();
}
}
@override
void dispose() {
_streamCheckTimer?.cancel();
_callManager.removeListener(_checkCallStatus);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.backgroundTabBarDark,
body: SafeArea(
top: false,
child: ListenableBuilder(
listenable: _callManager,
builder: (context, _) {
final session = _callManager.currentCall;
if (session == null) {
return const Center(
child: Text('No active call', style: TextStyle(color: Colors.white)),
);
}
return Stack(
children: [
// Remote video (fullscreen by default)
if (!_isLocalVideoFullscreen) _buildRemoteVideo(context),
// Local video (fullscreen when tapped)
if (_isLocalVideoFullscreen) _buildFullscreenLocalVideo(context),
// Small preview (top-right corner)
if (_isLocalVideoFullscreen)
_buildSmallRemoteVideo(context)
else
_buildLocalVideoPreview(context),
_buildTopBar(context, session),
_buildBottomControls(context, session),
],
);
},
),
),
);
}
Widget _buildRemoteVideo(BuildContext context) {
final webrtc = _callManager.webrtcService;
final isPeerCameraOn = _callManager.isPeerCameraOn;
return Positioned.fill(
child: GestureDetector(
onTap: () => setState(() => _isLocalVideoFullscreen = true),
child: Container(
color: Colors.black,
child: !isPeerCameraOn || webrtc?.remoteRenderer == null
? _buildAvatarPlaceholder(_callManager.currentCall?.peerName ?? 'User', large: true)
: RTCVideoView(
webrtc!.remoteRenderer!,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
),
);
}
Widget _buildFullscreenLocalVideo(BuildContext context) {
final webrtc = _callManager.webrtcService;
final isCameraOn = _callManager.isCameraOn;
return Positioned.fill(
child: GestureDetector(
onTap: () => setState(() => _isLocalVideoFullscreen = false),
child: Container(
color: Colors.grey[800],
child: !isCameraOn || webrtc?.localRenderer == null
? const Center(child: Icon(Icons.videocam_off, color: Colors.white, size: 64))
: RTCVideoView(
webrtc!.localRenderer!,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
mirror: true,
),
),
),
);
}
Widget _buildLocalVideoPreview(BuildContext context) {
final webrtc = _callManager.webrtcService;
final isCameraOn = _callManager.isCameraOn;
return Positioned(
top: 100,
right: 16,
child: GestureDetector(
onTap: () => setState(() => _isLocalVideoFullscreen = true),
child: Container(
width: 120,
height: 160,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24, width: 2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: !isCameraOn || webrtc?.localRenderer == null
? const Center(child: Icon(Icons.videocam_off, color: Colors.white, size: 32))
: RTCVideoView(
webrtc!.localRenderer!,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
mirror: true,
),
),
),
),
);
}
Widget _buildSmallRemoteVideo(BuildContext context) {
final webrtc = _callManager.webrtcService;
final isPeerCameraOn = _callManager.isPeerCameraOn;
return Positioned(
top: 100,
right: 16,
child: GestureDetector(
onTap: () => setState(() => _isLocalVideoFullscreen = false),
child: Container(
width: 120,
height: 160,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24, width: 2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: !isPeerCameraOn || webrtc?.remoteRenderer == null
? _buildAvatarPlaceholder(_callManager.currentCall?.peerName ?? 'User', large: false)
: RTCVideoView(
webrtc!.remoteRenderer!,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
),
),
);
}
Widget _buildAvatarPlaceholder(String name, {required bool large}) {
return Container(
color: AppColor.backgroundTabBarDark,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person,
size: large ? 80 : 40,
color: Colors.white54,
),
if (large) ...[
12.height,
Text(
name,
style: AppTextStyles.heading5.copyWith(color: Colors.white),
),
4.height,
Text(
'Camera is off',
style: AppTextStyles.bodyText2.copyWith(color: Colors.white54),
),
],
],
),
),
);
}
Widget _buildTopBar(BuildContext context, CallSession session) {
final status = _callManager.callStatus;
final duration = _callManager.callDuration;
String statusText;
if (status == CallStatus.connected) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
statusText = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
statusText = _getStatusText(status);
}
return Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16,
bottom: 16,
left: 16,
right: 16,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.6),
Colors.transparent,
],
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
session.peerName,
style: AppTextStyles.heading6.copyWith(color: Colors.white),
),
4.height,
Text(
statusText,
style: AppTextStyles.bodyText2.copyWith(color: Colors.white70),
),
],
),
),
const SizedBox(width: 48),
],
),
),
);
}
String _getStatusText(CallStatus status) {
switch (status) {
case CallStatus.connecting:
return 'Connecting...';
case CallStatus.incomingRinging:
return 'Incoming call...';
case CallStatus.outgoingRinging:
return 'Ringing...';
case CallStatus.connected:
return 'Connected';
default:
return '';
}
}
Widget _buildBottomControls(BuildContext context, CallSession session) {
final isMuted = _callManager.isMuted;
final isCameraOn = _callManager.isCameraOn;
final isSpeakerOn = _callManager.isSpeakerOn;
return Positioned(
bottom: 40,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildControlButton(
icon: isMuted ? Icons.mic_off : Icons.mic,
label: isMuted ? 'Unmute' : 'Mute',
onPressed: () => _callManager.toggleMute(),
backgroundColor: isMuted ? Colors.red : Colors.white24,
),
_buildControlButton(
icon: isCameraOn ? Icons.videocam : Icons.videocam_off,
label: isCameraOn ? 'Camera' : 'Camera Off',
onPressed: () => _callManager.toggleCamera(),
backgroundColor: isCameraOn ? Colors.white24 : Colors.red,
),
_buildControlButton(
icon: Icons.cameraswitch,
label: 'Switch',
onPressed: () => _callManager.switchCamera(),
backgroundColor: Colors.white24,
),
_buildControlButton(
icon: Icons.call_end,
label: 'End',
onPressed: () => _callManager.hangUp(),
backgroundColor: Colors.red,
iconColor: Colors.white,
),
],
),
),
);
}
Widget _buildControlButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
Color? backgroundColor,
Color? iconColor,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor ?? Colors.white24,
shape: BoxShape.circle,
border: Border.all(color: Colors.white24),
),
child: Icon(icon, size: 24, color: iconColor ?? Colors.white),
).onPress(onPressed),
8.height,
Text(
label,
style: AppTextStyles.bodyText2.copyWith(color: Colors.white),
),
],
);
}
}

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
/// Draggable widget for local video preview in video calls
/// Allows user to move the PiP (Picture-in-Picture) preview around the screen
class DraggableLocalPreview extends StatefulWidget {
final Widget child;
const DraggableLocalPreview({
Key? key,
required this.child,
}) : super(key: key);
@override
State<DraggableLocalPreview> createState() => _DraggableLocalPreviewState();
}
class _DraggableLocalPreviewState extends State<DraggableLocalPreview> {
Offset position = const Offset(20, 100); // Initial position (top-right area)
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
return Positioned(
left: position.dx,
top: position.dy,
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
// Update position while dragging
position = Offset(
(position.dx + details.delta.dx).clamp(
0.0,
screenSize.width - 120, // Subtract preview width
),
(position.dy + details.delta.dy).clamp(
0.0,
screenSize.height - 160, // Subtract preview height
),
);
});
},
onPanEnd: (details) {
// Snap to nearest edge for better UX
setState(() {
final middleX = screenSize.width / 2;
final middleY = screenSize.height / 2;
// Snap to left or right edge
if (position.dx < middleX) {
position = Offset(20, position.dy);
} else {
position = Offset(screenSize.width - 140, position.dy);
}
// Keep within vertical bounds
position = Offset(
position.dx,
position.dy.clamp(80.0, screenSize.height - 200),
);
});
},
child: Container(
width: 120,
height: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: widget.child,
),
),
);
}
}

@ -5,6 +5,7 @@ import 'dart:io';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/extensions/context_extension.dart';
@ -16,8 +17,11 @@ import 'package:test_sa/helper/utils.dart';
import 'package:test_sa/models/service_request/service_request.dart';
import 'package:test_sa/modules/cm_module/cm_detail_provider.dart';
import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart';
import 'package:test_sa/modules/cx_module/chat/call/audio_call_page.dart';
import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart';
import 'package:test_sa/modules/cx_module/chat/chat_api_client.dart';
import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
import 'package:test_sa/modules/cx_module/chat/view_all_attachment_page.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/app_filled_button.dart';
@ -186,7 +190,8 @@ class _ChatPageState extends State<ChatPage> {
@override
void dispose() {
chatHubConnection?.stop();
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
chatProvider.chatHubConnection?.stop();
playerController.dispose();
recorderController.dispose();
super.dispose();
@ -194,9 +199,75 @@ class _ChatPageState extends State<ChatPage> {
@override
Widget build(BuildContext context) {
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
return Scaffold(
// backgroundColor: AppColor.white10,
appBar: DefaultAppBar(title: widget.title),
appBar: DefaultAppBar(
title: widget.title,
actions: [
// NEW: Call readiness indicator - shows if device can receive calls
if (kDebugMode)
Selector<ChatProvider, bool>(
selector: (_, provider) => provider.areCallHandlersRegistered,
builder: (context, handlersRegistered, _) => Padding(
padding: const EdgeInsets.only(right: 8),
child: Center(
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: handlersRegistered ? Colors.green : Colors.red,
),
),
),
),
),
Selector<ChatProvider, Participants?>(
selector: (_, provider) => provider.recipient,
builder: (context, recipient, _) => IconButton(
icon: "calling_icon".toSvgAsset(width: 24, height: 24),
onPressed: () {
log('recipient: ${recipient?.userName}');
if (recipient != null) {
chatProvider.startCall(recipient, CallType.audio);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AudioCallPage(),
),
);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
Selector<ChatProvider, Participants?>(
selector: (_, provider) => provider.recipient,
builder: (context, recipient, _) => IconButton(
icon: "video_call_icon".toSvgAsset(
width: 24,
height: 24,
),
onPressed: () {
if (recipient != null) {
chatProvider.startCall(recipient, CallType.video);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const VideoCallPage(),
),
);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
),
// OPTIMIZATION: Using Selector for connection state (rarely changes)
// This prevents rebuilding entire chat UI on every message/typing event
body: Selector<ChatProvider, _ChatConnectionState>(
@ -272,11 +343,7 @@ class _ChatPageState extends State<ChatPage> {
4.width,
Text(
"View All Documents",
style: AppTextStyles.bodyText.copyWith(
color: AppColor.white10,
decoration: TextDecoration.underline,
decorationColor: AppColor.white10
),
style: AppTextStyles.bodyText.copyWith(color: AppColor.white10, decoration: TextDecoration.underline, decorationColor: AppColor.white10),
).onPress(() {
Navigator.push(context, CupertinoPageRoute(builder: (context) => ViewAllAttachmentPage(moduleId: widget.moduleId, requestId: widget.requestId)));
}),
@ -353,13 +420,13 @@ class _ChatPageState extends State<ChatPage> {
textInputAction: TextInputAction.none,
keyboardType: TextInputType.multiline,
onTap: () {
chatHubConnection!.invoke("SendTypingAsync", args: [receiver]);
chatProvider.chatHubConnection!.invoke("SendTypingAsync", args: [receiver]);
},
onTapOutside: (PointerDownEvent event) {
chatHubConnection!.invoke("SendStopTypingAsync", args: [receiver]);
chatProvider.chatHubConnection!.invoke("SendStopTypingAsync", args: [receiver]);
},
onChanged: (text) {
chatHubConnection!.invoke("SendTypingAsync", args: [receiver]);
chatProvider.chatHubConnection!.invoke("SendTypingAsync", args: [receiver]);
},
decoration: InputDecoration(
enabledBorder: InputBorder.none,
@ -396,7 +463,7 @@ class _ChatPageState extends State<ChatPage> {
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // or .padded
),
icon: "chat_attachment".toSvgAsset(width: 24, height: 24,color: AppColor.icon2Color(context)),
icon: "chat_attachment".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
constraints: const BoxConstraints(),
),
IconButton(
@ -417,7 +484,7 @@ class _ChatPageState extends State<ChatPage> {
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // or .padded
),
icon: "chat_mic".toSvgAsset(width: 24, height: 24,color: AppColor.icon2Color(context)),
icon: "chat_mic".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
constraints: const BoxConstraints(),
),
] else if (chatState == ChatState.voiceRecordingStarted) ...[

File diff suppressed because it is too large Load Diff

@ -1,5 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -44,7 +42,13 @@ class _ChatWidgetState extends State<ChatWidget> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<ChatProvider>(context, listen: false).reset();
// CRITICAL FIX: Don't reset if already initialized with same request
// This prevents closing SignalR connection when navigating between pages
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
if (chatProvider.referenceID != widget.requestId) {
// Only reset if it's a different request
chatProvider.reset();
}
getChatToken();
});
}

@ -0,0 +1,100 @@
/// Call session data model for audio/video calling
/// Stores all information about an active call
enum CallType { audio, video }
enum CallDirection { outgoing, incoming }
enum CallStatus {
idle,
checkingPermissions,
outgoingRinging,
incomingRinging,
connecting,
connected,
ended
}
class CallSession {
final String callId;
final CallType type;
final CallDirection direction;
final String peerId; // Employee number
final String peerName;
final String? peerAvatar;
final DateTime startTime;
String? sessionId; // For SDP/ICE correlation
String? sdpOffer;
String? sdpAnswer;
CallSession({
required this.callId,
required this.type,
required this.direction,
required this.peerId,
required this.peerName,
this.peerAvatar,
required this.startTime,
this.sessionId,
this.sdpOffer,
this.sdpAnswer,
});
Map<String, dynamic> toJson() => {
'callId': callId,
'type': type.name,
'direction': direction.name,
'peerId': peerId,
'peerName': peerName,
'peerAvatar': peerAvatar,
'startTime': startTime.toIso8601String(),
'sessionId': sessionId,
'sdpOffer': sdpOffer,
'sdpAnswer': sdpAnswer,
};
factory CallSession.fromJson(Map<String, dynamic> json) => CallSession(
callId: json['callId'],
type: CallType.values.firstWhere((e) => e.name == json['type']),
direction: CallDirection.values.firstWhere((e) => e.name == json['direction']),
peerId: json['peerId'],
peerName: json['peerName'],
peerAvatar: json['peerAvatar'],
startTime: DateTime.parse(json['startTime']),
sessionId: json['sessionId'],
sdpOffer: json['sdpOffer'],
sdpAnswer: json['sdpAnswer'],
);
CallSession copyWith({
String? callId,
CallType? type,
CallDirection? direction,
String? peerId,
String? peerName,
String? peerAvatar,
DateTime? startTime,
String? sessionId,
String? sdpOffer,
String? sdpAnswer,
}) {
return CallSession(
callId: callId ?? this.callId,
type: type ?? this.type,
direction: direction ?? this.direction,
peerId: peerId ?? this.peerId,
peerName: peerName ?? this.peerName,
peerAvatar: peerAvatar ?? this.peerAvatar,
startTime: startTime ?? this.startTime,
sessionId: sessionId ?? this.sessionId,
sdpOffer: sdpOffer ?? this.sdpOffer,
sdpAnswer: sdpAnswer ?? this.sdpAnswer,
);
}
bool get isVideo => type == CallType.video;
bool get isAudio => type == CallType.audio;
bool get isIncoming => direction == CallDirection.incoming;
bool get isOutgoing => direction == CallDirection.outgoing;
}

@ -47,8 +47,9 @@ class Participants {
String? employeeNumber;
String? role;
int? userStatus;
String? image;
Participants({this.userId, this.userName, this.employeeNumber, this.role, this.userStatus});
Participants({this.userId, this.userName, this.employeeNumber, this.role, this.image, this.userStatus});
Participants.fromJson(Map<String, dynamic> json) {
userId = json['userId'];
@ -56,6 +57,7 @@ class Participants {
employeeNumber = json['employeeNumber'];
role = json['role'];
userStatus = json['userStatus'];
image = json['image'];
}
Map<String, dynamic> toJson() {
@ -65,6 +67,7 @@ class Participants {
data['employeeNumber'] = employeeNumber;
data['role'] = role;
data['userStatus'] = userStatus;
data['image'] = image;
return data;
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,393 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:signalr_netcore/hub_connection.dart';
import 'package:signalr_netcore/signalr_client.dart';
import 'package:test_sa/controllers/api_routes/urls.dart';
class SignalRService {
// Singleton instance - DO NOT instantiate directly, use DI (Provider)
static final SignalRService _instance = SignalRService._internal();
factory SignalRService() => _instance;
SignalRService._internal() {
log('🏗️ [SIGNALR] SignalRService singleton created (hashCode: ${hashCode})', name: 'SignalRService');
}
// Single HubConnection instance - ONLY created and managed by this service
HubConnection? _hubConnection;
// Connection state management
bool _isInitializing = false;
Completer<bool>? _initializationCompleter;
// Authentication data
String? _userId;
String? _authToken;
String? _currentConversationId;
// Event handler registrations for re-registration after reconnect
final Map<String, List<Function(List<Object?>?)>> _eventHandlers = {};
// Connection state stream for reactive updates
final StreamController<HubConnectionState> _connectionStateController =
StreamController<HubConnectionState>.broadcast();
/// Stream of connection state changes for consumers to listen
Stream<HubConnectionState> get connectionStateStream => _connectionStateController.stream;
/// Get the current HubConnection (read-only access)
HubConnection? get hubConnection => _hubConnection;
/// Check if connected
bool get isConnected => _hubConnection?.state == HubConnectionState.Connected;
/// Get current connection state
HubConnectionState? get connectionState => _hubConnection?.state;
/// Get connection ID (for debugging and verification)
String? get connectionId => _hubConnection?.connectionId;
/// Initialize SignalR connection with authentication
/// Thread-safe: Multiple callers will wait for the same connection task
Future<bool> initialize({
required String userId,
required String authToken,
String? conversationId,
}) async {
log('═══════════════════════════════════════════', name: 'SignalRService');
log('🔌 [SIGNALR] initialize() called', name: 'SignalRService');
log(' Instance hashCode: ${hashCode}', name: 'SignalRService');
log(' User ID: $userId', name: 'SignalRService');
log(' Conversation ID: ${conversationId ?? "none"}', name: 'SignalRService');
// THREAD SAFETY: If already initializing, wait for current initialization
if (_isInitializing && _initializationCompleter != null) {
log('⏳ [SIGNALR] Already initializing, waiting for completion...', name: 'SignalRService');
return await _initializationCompleter!.future;
}
// If already connected with same credentials, reuse connection
if (isConnected && _userId == userId && _authToken == authToken) {
log('✅ [SIGNALR] Already connected with same credentials', name: 'SignalRService');
log(' Connection ID: ${_hubConnection?.connectionId}', name: 'SignalRService');
// Join new conversation if provided and different
if (conversationId != null && conversationId != _currentConversationId) {
await _joinConversation(conversationId);
}
log('═══════════════════════════════════════════', name: 'SignalRService');
return true;
}
// Start new initialization
_isInitializing = true;
_initializationCompleter = Completer<bool>();
try {
// Store credentials
_userId = userId;
_authToken = authToken;
_currentConversationId = conversationId;
// Dispose existing connection if any
await _disposeConnection();
// Create new connection
log('🔧 [SIGNALR] Creating new HubConnection...', name: 'SignalRService');
log(' URL: ${URLs.chatHubUrlChat}', name: 'SignalRService');
final httpOp = HttpConnectionOptions(
skipNegotiation: false,
logMessageContent: kDebugMode,
transport: HttpTransportType.WebSockets, // Use WebSockets
requestTimeout: 30000, // 30 seconds timeout
);
_hubConnection = HubConnectionBuilder()
.withUrl(
"${URLs.chatHubUrlChat}?UserId=$userId&source=Desktop&access_token=$authToken",
options: httpOp,
)
.withAutomaticReconnect(retryDelays: <int>[2000, 5000, 10000, 20000])
.build();
log('✅ [SIGNALR] HubConnection created (hashCode: ${_hubConnection.hashCode})', name: 'SignalRService');
// Setup reconnection handlers BEFORE starting connection
_setupReconnectionHandlers();
// Start connection with timeout
log('🔌 [SIGNALR] Starting connection...', name: 'SignalRService');
try {
final startFuture = _hubConnection!.start();
if (startFuture != null) {
await startFuture.timeout(
const Duration(seconds: 30),
onTimeout: () {
throw TimeoutException('SignalR connection timeout after 30 seconds');
},
);
} else {
log('⚠️ [SIGNALR] start() returned null, checking state...', name: 'SignalRService');
}
} catch (e) {
log('❌ [SIGNALR] Connection start failed: $e', name: 'SignalRService');
rethrow;
}
// Verify connection state
if (_hubConnection!.state != HubConnectionState.Connected) {
throw Exception('SignalR failed to connect. State: ${_hubConnection!.state}');
}
log('✅ [SIGNALR] Connection established', name: 'SignalRService');
log(' Connection ID: ${_hubConnection!.connectionId}', name: 'SignalRService');
log(' Connection State: ${_hubConnection!.state}', name: 'SignalRService');
// Emit connected state
_connectionStateController.add(HubConnectionState.Connected);
// Join conversation if provided
if (conversationId != null) {
await _joinConversation(conversationId);
}
// Re-register all event handlers
_reregisterAllHandlers();
_isInitializing = false;
_initializationCompleter?.complete(true);
log('═══════════════════════════════════════════', name: 'SignalRService');
return true;
} catch (e, stackTrace) {
log('❌ [SIGNALR] Error initializing connection: $e',
name: 'SignalRService', error: e, stackTrace: stackTrace);
log(' User ID: $userId', name: 'SignalRService');
log(' URL: ${URLs.chatHubUrlChat}', name: 'SignalRService');
_isInitializing = false;
_initializationCompleter?.complete(false);
log('═══════════════════════════════════════════', name: 'SignalRService');
return false;
}
}
/// Setup reconnection handlers
void _setupReconnectionHandlers() {
if (_hubConnection == null) return;
_hubConnection!.onclose(({Exception? error}) {
log('🔴 [SIGNALR] Connection closed: $error', name: 'SignalRService');
_connectionStateController.add(HubConnectionState.Disconnected);
});
_hubConnection!.onreconnecting(({Exception? error}) {
log('🟡 [SIGNALR] Reconnecting: $error', name: 'SignalRService');
_connectionStateController.add(HubConnectionState.Reconnecting);
});
_hubConnection!.onreconnected(({String? connectionId}) async {
log('🟢 [SIGNALR] Reconnected: $connectionId', name: 'SignalRService');
_connectionStateController.add(HubConnectionState.Connected);
// Rejoin conversation if we had one
if (_currentConversationId != null) {
await _joinConversation(_currentConversationId!);
}
// Re-register all event handlers
_reregisterAllHandlers();
log('✅ [SIGNALR] Reconnection complete', name: 'SignalRService');
});
}
/// Join a conversation
Future<void> _joinConversation(String conversationId) async {
try {
if (_hubConnection?.state != HubConnectionState.Connected) {
log('⚠️ [SIGNALR] Cannot join conversation - not connected', name: 'SignalRService');
return;
}
await _hubConnection!.invoke("JoinConversation", args: [conversationId]);
_currentConversationId = conversationId;
log('✅ [SIGNALR] Joined conversation: $conversationId', name: 'SignalRService');
} catch (e) {
log('❌ [SIGNALR] Error joining conversation: $e', name: 'SignalRService');
}
}
/// Register an event handler
/// Events are stored and automatically re-registered after reconnect
void on(String eventName, Function(List<Object?>?) handler) {
log('🔧 [SIGNALR] Registering handler for: $eventName', name: 'SignalRService');
// Store handler for re-registration after reconnect
if (!_eventHandlers.containsKey(eventName)) {
_eventHandlers[eventName] = [];
}
// Check for duplicate handler
if (_eventHandlers[eventName]!.contains(handler)) {
log('⚠️ [SIGNALR] Handler already registered for: $eventName', name: 'SignalRService');
return;
}
_eventHandlers[eventName]!.add(handler);
// Register with SignalR if connected
if (_hubConnection != null) {
_hubConnection!.on(eventName, handler);
log('✅ [SIGNALR] Handler registered for: $eventName', name: 'SignalRService');
} else {
log('⚠️ [SIGNALR] Handler stored but not registered (not connected): $eventName', name: 'SignalRService');
}
}
/// Unregister an event handler
void off(String eventName, [Function(List<Object?>?)? handler]) {
log('🔧 [SIGNALR] Unregistering handler for: $eventName', name: 'SignalRService');
if (handler != null) {
_eventHandlers[eventName]?.remove(handler);
if (_eventHandlers[eventName]?.isEmpty ?? false) {
_eventHandlers.remove(eventName);
}
} else {
_eventHandlers.remove(eventName);
}
// Unregister from SignalR if connected
if (_hubConnection != null) {
_hubConnection!.off(eventName, method: handler);
}
}
/// Re-register all event handlers (after reconnection)
void _reregisterAllHandlers() {
if (_hubConnection == null) return;
log('🔄 [SIGNALR] Re-registering ${_eventHandlers.length} event types...', name: 'SignalRService');
int totalHandlers = 0;
for (final entry in _eventHandlers.entries) {
final eventName = entry.key;
final handlers = entry.value;
for (final handler in handlers) {
_hubConnection!.on(eventName, handler);
totalHandlers++;
}
}
log('✅ [SIGNALR] $totalHandlers event handlers re-registered', name: 'SignalRService');
}
/// Invoke a SignalR method
Future<Object?> invoke(String methodName, {List<Object>? args}) async {
if (_hubConnection?.state != HubConnectionState.Connected) {
throw Exception('SignalR not connected. Current state: ${_hubConnection?.state}');
}
log('📤 [SIGNALR] Invoking: $methodName', name: 'SignalRService');
if (kDebugMode && args != null && args.isNotEmpty) {
log(' Args: ${args.take(3)}${args.length > 3 ? "..." : ""}', name: 'SignalRService');
}
return await _hubConnection!.invoke(methodName, args: args);
}
/// Ensure connection is ready (connect if needed)
/// Thread-safe: Multiple callers will wait for the same connection task
Future<bool> ensureConnected() async {
// If already connected, we're good
if (isConnected) {
return true;
}
// CRITICAL FIX: If currently reconnecting, wait for it to complete
// Don't dispose the connection while it's reconnecting!
if (_hubConnection?.state == HubConnectionState.Reconnecting) {
log('⏳ [SIGNALR] Already reconnecting, waiting for completion...', name: 'SignalRService');
// Wait up to 10 seconds for reconnection to complete
final startTime = DateTime.now();
while (_hubConnection?.state == HubConnectionState.Reconnecting) {
await Future.delayed(const Duration(milliseconds: 500));
// Timeout after 10 seconds
if (DateTime.now().difference(startTime).inSeconds > 10) {
log('⏱️ [SIGNALR] Reconnection timeout, forcing new connection', name: 'SignalRService');
break;
}
}
// Check if reconnection succeeded
if (isConnected) {
log('✅ [SIGNALR] Reconnection completed successfully', name: 'SignalRService');
return true;
}
}
// If we have credentials, try to reconnect
if (_userId != null && _authToken != null) {
log('🔄 [SIGNALR] Not connected, attempting to reconnect...', name: 'SignalRService');
return await initialize(
userId: _userId!,
authToken: _authToken!,
conversationId: _currentConversationId,
);
}
log('❌ [SIGNALR] Cannot reconnect - no credentials stored', name: 'SignalRService');
return false;
}
/// Dispose connection
Future<void> _disposeConnection() async {
try {
if (_hubConnection != null) {
log('🔌 [SIGNALR] Disposing existing connection...', name: 'SignalRService');
await _hubConnection!.stop();
_hubConnection = null;
log('✅ [SIGNALR] Connection disposed', name: 'SignalRService');
}
} catch (e) {
log('⚠️ [SIGNALR] Error disposing connection: $e', name: 'SignalRService');
}
}
/// Reset service (for logout)
Future<void> reset() async {
log('═══════════════════════════════════════════', name: 'SignalRService');
log('🔄 [SIGNALR] Resetting service...', name: 'SignalRService');
await _disposeConnection();
_eventHandlers.clear();
_userId = null;
_authToken = null;
_currentConversationId = null;
_isInitializing = false;
_initializationCompleter = null;
log('✅ [SIGNALR] Service reset complete', name: 'SignalRService');
log('═══════════════════════════════════════════', name: 'SignalRService');
}
/// Disconnect from SignalR (alias for reset)
Future<void> disconnect() async {
await reset();
}
/// Dispose stream controller (called when app is terminating)
void dispose() {
_connectionStateController.close();
}
}

@ -35,6 +35,7 @@ class AppColor {
static const Color neutral150 = Color(0xffA1A1A1);
static const Color neutral160 = Color(0xffE5E5E5);
static const Color neutral170 = Color(0xff6E6E6E);
static const Color neutral180 = Color(0xff414352);
static const Color disableDark = Color(0xff444756);
static const Color switchBgColor = Color(0xff5A5E79);
@ -59,12 +60,15 @@ class AppColor {
static const Color white90 = Color(0xffBFD1E4);
static const Color white936 = Color(0xff212936);
static const Color whiteF3 = Color(0xffF1F1F3);
static const Color whiteF33 = Color(0xffF3F3F3);
static const Color whiteF8d = Color(0xff8d96a3);
//black
static const Color black10 = Color(0xff3B3D4A);
static const Color black20 = Color(0xff212936);
static const Color black35 = Color(0xff222A36);
static const Color black1E = Color(0xff1E1F20);
static const Color black2E = Color(0xff2E2E2E);
static const Color black92 = Color(0xff929292);
//red

@ -16,8 +16,21 @@ class DefaultAppBar extends StatelessWidget implements PreferredSizeWidget {
final bool showBackButton;
final VoidCallback? onWillPopScope;
final TextStyle? titleStyle;
final Color? backgroundColor;
final Color? arrowBackColor;
const DefaultAppBar({this.title, this.onBackPress, this.onWillPopScope, this.actions, this.titleStyle, this.showHomeActionButton = false, this.showBackButton = true, Key? key}) : super(key: key);
const DefaultAppBar(
{this.title,
this.onBackPress,
this.arrowBackColor,
this.onWillPopScope,
this.actions,
this.titleStyle,
this.backgroundColor,
this.showHomeActionButton = false,
this.showBackButton = true,
Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
@ -39,12 +52,16 @@ class DefaultAppBar extends StatelessWidget implements PreferredSizeWidget {
_actions.addAll(actions!);
}
return AppBar(
backgroundColor: backgroundColor,
automaticallyImplyLeading: false,
titleSpacing: 16,
title: Row(
children: [
if (showBackButton)
const Icon(Icons.arrow_back_ios).onPress(() {
Icon(
Icons.arrow_back_ios,
color: arrowBackColor,
).onPress(() {
if (onWillPopScope != null) {
showDialog(
context: context,

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:developer' as dev;
import 'dart:io';
import 'package:flutter/material.dart';
@ -23,6 +24,9 @@ import 'package:test_sa/new_views/pages/login_page.dart';
import 'package:test_sa/new_views/pages/settings_page.dart';
import 'package:test_sa/providers/work_order/vendor_provider.dart';
import 'package:test_sa/views/widgets/equipment/my_assets_page.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
import 'package:test_sa/modules/cx_module/chat/chat_api_client.dart';
import '../../../controllers/providers/settings/setting_provider.dart';
import '../../../views/widgets/dialogs/dialog.dart';
@ -124,19 +128,22 @@ class _LandPageState extends State<LandPage> {
DashboardView(onDrawerPress: (() {
_scaffoldKey.currentState!.isDrawerOpen ? _scaffoldKey.currentState!.closeDrawer() : _scaffoldKey.currentState!.openDrawer();
})),
// const old_page.LandPage(),
const MyRequestsPage(),
// if (_userProvider!.user!.type != UsersTypes.engineer)
const SizedBox(),
// if (_userProvider!.user!.type != UsersTypes.engineer) const CalendarPage(),
const MyAssetsPage(fromBottomBar: true),
];
if (isFCM) {
FirebaseNotificationManger.initialized(context);
NotificationManger.initialisation((notificationDetails) {
FirebaseNotificationManger.handleMessage(context, json.decode(notificationDetails.payload!));
}, (id, title, body, payload) async {});
// Initialize SignalR & CallManager ONCE at app startup
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _initializeCallingSystem();
});
isFCM = false;
}
checkLocalAuth();
@ -149,6 +156,9 @@ class _LandPageState extends State<LandPage> {
builder: (_) => AAlertDialog(title: context.translation.signOut, content: context.translation.logoutAlert),
);
if (result) {
// Cleanup calling system before logout
await _cleanupCallingSystem();
bool isSuccess = await Provider.of<UserProvider>(context, listen: false).logout(context);
if (isSuccess) {
Provider.of<SettingProvider>(context, listen: false).resetSettings();
@ -209,4 +219,107 @@ class _LandPageState extends State<LandPage> {
),
);
}
/// Initialize SignalR and CallManager once at app startup
/// This ensures calls work from ANY screen without CMDetailPage dependency
Future<void> _initializeCallingSystem() async {
try {
final user = _userProvider?.user;
if (user?.username == null) {
dev.log('⚠️ [LAND PAGE] User not logged in, skipping call initialization', name: 'LandPage');
return;
}
final myEmployeeNumber = user!.username!; // Use username as employee number
final userId = user.userID?.toString() ?? user.username!;
dev.log('═══════════════════════════════════════════', name: 'LandPage');
dev.log('🚀 [LAND PAGE] Initializing calling system...', name: 'LandPage');
dev.log(' User: ${user.username}', name: 'LandPage');
dev.log(' User ID: $userId', name: 'LandPage');
dev.log(' Employee Number: $myEmployeeNumber', name: 'LandPage');
// Step 1: Get chat credentials
dev.log('🔑 [LAND PAGE] Fetching chat credentials...', name: 'LandPage');
final chatLoginResponse = await ChatApiClient().getChatLoginToken(
1002, // moduleId - using default
0, // referenceId - using default for app-level init
'App Init',
myEmployeeNumber,
myEmployeeNumber,
);
if (chatLoginResponse == null || chatLoginResponse.token == null) {
dev.log('❌ [LAND PAGE] Failed to get chat credentials', name: 'LandPage');
dev.log(' Response: ${chatLoginResponse?.toJson()}', name: 'LandPage');
return;
}
dev.log('✅ [LAND PAGE] Got chat credentials', name: 'LandPage');
dev.log(' User ID: ${chatLoginResponse.userId}', name: 'LandPage');
dev.log(' Token: ${chatLoginResponse.token!.substring(0, 20)}...', name: 'LandPage');
// Step 2: Initialize SignalR Service (SINGLETON - only once)
dev.log('🔌 [LAND PAGE] Initializing SignalR Service...', name: 'LandPage');
final signalRService = SignalRService();
final connected = await signalRService.initialize(
userId: chatLoginResponse.userId.toString(),
authToken: chatLoginResponse.token!,
conversationId: null, // No conversation at app level
);
if (!connected) {
dev.log('❌ [LAND PAGE] SignalR initialization failed', name: 'LandPage');
return;
}
dev.log('✅ [LAND PAGE] SignalR Service initialized', name: 'LandPage');
dev.log(' Connection State: ${signalRService.connectionState}', name: 'LandPage');
dev.log(' Connection ID: ${signalRService.connectionId ?? "NULL"}', name: 'LandPage');
// Step 3: Initialize CallManager (registers call event handlers)
dev.log('📞 [LAND PAGE] Initializing CallManager...', name: 'LandPage');
await CallManager().initialize(
userId: chatLoginResponse.userId.toString(),
authToken: chatLoginResponse.token!,
conversationId: null,
moduleId: '1002',
referenceId: '0',
employeeNumber: myEmployeeNumber,
);
dev.log('✅ [LAND PAGE] CallManager initialized', name: 'LandPage');
dev.log('═══════════════════════════════════════════', name: 'LandPage');
dev.log('✅ [LAND PAGE] Calling system ready!', name: 'LandPage');
dev.log(' ✅ SignalR: Connected', name: 'LandPage');
dev.log(' ✅ CallManager: Ready', name: 'LandPage');
dev.log(' ✅ WebRTC: Will initialize on first call', name: 'LandPage');
dev.log(' ✅ Calls work from ANY screen now', name: 'LandPage');
dev.log('═══════════════════════════════════════════', name: 'LandPage');
} catch (e, stackTrace) {
dev.log('❌ [LAND PAGE] Error initializing calling system: $e',
name: 'LandPage', error: e, stackTrace: stackTrace);
// Don't crash the app, just log the error
// User can still use the app, calls just won't work
}
}
/// Cleanup SignalR and CallManager on logout
Future<void> _cleanupCallingSystem() async {
try {
dev.log('🧹 [LAND PAGE] Cleaning up calling system...', name: 'LandPage');
// Reset CallManager
await CallManager().reset();
// Disconnect SignalR
await SignalRService().disconnect();
dev.log('✅ [LAND PAGE] Calling system cleaned up', name: 'LandPage');
} catch (e) {
dev.log('⚠️ [LAND PAGE] Error during cleanup: $e', name: 'LandPage');
}
}
}

@ -0,0 +1,111 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
/// App-level initialization service
/// Handles initialization of core services like SignalR and CallManager
class AppInitializationService {
static final AppInitializationService _instance = AppInitializationService._internal();
factory AppInitializationService() => _instance;
AppInitializationService._internal();
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
/// Initialize SignalR and CallManager after user login
/// This should be called from the landing page after successful login
Future<void> initializeAfterLogin({
required String userId,
required String authToken,
required String employeeNumber,
String? conversationId,
String? moduleId,
String? referenceId,
}) async {
if (_isInitialized) {
log('⚠️ [APP INIT] Already initialized, skipping...', name: 'AppInitializationService');
return;
}
try {
log('═══════════════════════════════════════════', name: 'AppInitializationService');
log('🚀 [APP INIT] Starting app initialization...', name: 'AppInitializationService');
log(' User ID: $userId', name: 'AppInitializationService');
log(' Employee Number: $employeeNumber', name: 'AppInitializationService');
log('═══════════════════════════════════════════', name: 'AppInitializationService');
// Step 1: Initialize SignalR Service
log('🔌 [APP INIT] Step 1: Initializing SignalR Service...', name: 'AppInitializationService');
final signalRService = SignalRService();
final connected = await signalRService.initialize(
userId: userId,
authToken: authToken,
conversationId: conversationId,
);
if (!connected) {
throw Exception('Failed to initialize SignalR connection');
}
log('✅ [APP INIT] SignalR Service initialized', name: 'AppInitializationService');
log(' Connection State: ${signalRService.connectionState}', name: 'AppInitializationService');
log(' Connection ID: ${signalRService.connectionId ?? "NULL"}', name: 'AppInitializationService');
// Step 2: Initialize CallManager
log('📞 [APP INIT] Step 2: Initializing CallManager...', name: 'AppInitializationService');
await CallManager().initialize(
userId: userId,
authToken: authToken,
conversationId: conversationId,
moduleId: moduleId,
referenceId: referenceId,
employeeNumber: employeeNumber,
);
log('✅ [APP INIT] CallManager initialized', name: 'AppInitializationService');
_isInitialized = true;
log('═══════════════════════════════════════════', name: 'AppInitializationService');
log('✅ [APP INIT] App initialization complete!', name: 'AppInitializationService');
log(' ✅ SignalR Service: Ready', name: 'AppInitializationService');
log(' ✅ CallManager: Ready', name: 'AppInitializationService');
log(' ✅ WebRTC: Will initialize on first call', name: 'AppInitializationService');
log('═══════════════════════════════════════════', name: 'AppInitializationService');
} catch (e, stackTrace) {
log('❌ [APP INIT] Initialization failed: $e',
name: 'AppInitializationService', error: e, stackTrace: stackTrace);
_isInitialized = false;
rethrow;
}
}
/// Reset initialization state (for logout)
Future<void> reset() async {
log('🔄 [APP INIT] Resetting app initialization...', name: 'AppInitializationService');
try {
// Reset CallManager
await CallManager().reset();
// Disconnect SignalR
await SignalRService().disconnect();
_isInitialized = false;
log('✅ [APP INIT] Reset complete', name: 'AppInitializationService');
} catch (e) {
log('⚠️ [APP INIT] Error during reset: $e', name: 'AppInitializationService');
}
}
/// Check if services are ready for calling
bool areServicesReady() {
final signalRConnected = SignalRService().isConnected;
final callManagerInitialized = CallManager().isCallInProgress || _isInitialized;
return signalRConnected && _isInitialized;
}
}

@ -233,6 +233,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
url: "https://pub.dev"
source: hosted
version: "1.8.1"
date_picker_timeline:
dependency: "direct main"
description:
@ -423,6 +431,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_callkit_incoming:
dependency: "direct main"
description:
name: flutter_callkit_incoming
sha256: "0e418f2c7fdbddeebc0288ce0e10c17fd01c1a886731d5cb1048d86b72443b92"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_custom_month_picker:
dependency: "direct main"
description:
@ -590,6 +606,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: "0f89dee7f4c35dab5611f351c3897a3bfe9f7057720cc7da209b52455af4316e"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
fluttertoast:
dependency: "direct main"
description:
@ -662,6 +686,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.7.3"
get_it:
dependency: "direct main"
description:
name: get_it
sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66"
url: "https://pub.dev"
source: hosted
version: "9.2.1"
glob:
dependency: transitive
description:
@ -854,6 +886,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
@ -1828,7 +1868,7 @@ packages:
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
@ -1899,6 +1939,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
url: "https://pub.dev"
source: hosted
version: "1.5.1"
wifi_iot:
dependency: "direct main"
description:

@ -39,6 +39,7 @@ dependencies:
cupertino_icons: ^1.0.5
font_awesome_flutter: ^10.12.0
http: ^1.6.0
get_it: ^9.2.1
provider: ^6.1.5+1
shared_preferences: ^2.5.4
fluttertoast: ^9.0.0
@ -95,6 +96,8 @@ dependencies:
local_auth: ^2.3.0
google_api_availability: ^5.0.1
huawei_push: ^6.14.0+300
flutter_webrtc: ^1.5.2
flutter_callkit_incoming: ^3.1.3
huawei_location: ^6.16.0+300
geolocator: ^9.0.2
wifi_iot: ^0.3.19+2

Loading…
Cancel
Save