feat(ai-scribe): reorganize abstractions and update dialog modal UI
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 10.833H4.16667L4.16667 9.16634H2.5V10.833ZM2.5 14.1663H4.16667V12.4997H2.5V14.1663ZM2.5 7.49967H4.16667V5.83301H2.5V7.49967ZM5.83333 10.833L17.5 10.833V9.16634L5.83333 9.16634V10.833ZM5.83333 14.1663L17.5 14.1663V12.4997L5.83333 12.4997V14.1663ZM5.83333 5.83301V7.49967L17.5 7.49967V5.83301L5.83333 5.83301ZM2.5 10.833H4.16667L4.16667 9.16634H2.5V10.833ZM2.5 14.1663H4.16667V12.4997H2.5V14.1663ZM2.5 7.49967H4.16667V5.83301H2.5V7.49967ZM5.83333 10.833L17.5 10.833V9.16634L5.83333 9.16634V10.833ZM5.83333 14.1663L17.5 14.1663V12.4997L5.83333 12.4997V14.1663ZM5.83333 5.83301V7.49967L17.5 7.49967V5.83301L5.83333 5.83301Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 775 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.42773 14.2787L6.78488 8.9144L9.64202 11.7715L14.7063 6.07868L15.7134 7.08583L9.64202 13.9144L6.78488 11.0573L2.49916 15.3501L1.42773 14.2787ZM2.49916 11.0644L6.78488 6.77154L9.64202 9.62868L15.7134 2.80011L14.7063 1.79297L9.64202 7.48583L6.78488 4.62868L1.42773 9.99297L2.49916 11.0644Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.916 9.16699C13.6064 9.16699 14.166 8.60735 14.166 7.91699C14.166 7.22664 13.6064 6.66699 12.916 6.66699C12.2257 6.66699 11.666 7.22664 11.666 7.91699C11.666 8.60735 12.2257 9.16699 12.916 9.16699Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M7.08268 9.16699C7.77304 9.16699 8.33268 8.60735 8.33268 7.91699C8.33268 7.22664 7.77304 6.66699 7.08268 6.66699C6.39233 6.66699 5.83268 7.22664 5.83268 7.91699C5.83268 8.60735 6.39233 9.16699 7.08268 9.16699Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M9.99935 15.0003C11.8993 15.0003 13.516 13.617 14.166 11.667L5.83268 11.667C6.48268 13.617 8.09935 15.0003 9.99935 15.0003Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M9.99102 1.66699C5.39102 1.66699 1.66602 5.40033 1.66602 10.0003C1.66602 14.6003 5.39102 18.3337 9.99102 18.3337C14.5993 18.3337 18.3327 14.6003 18.3327 10.0003C18.3327 5.40033 14.5993 1.66699 9.99102 1.66699ZM9.99935 16.667C6.31602 16.667 3.33268 13.6837 3.33268 10.0003C3.33268 6.31699 6.31602 3.33366 9.99935 3.33366C13.6827 3.33366 16.666 6.31699 16.666 10.0003C16.666 13.6837 13.6827 16.667 9.99935 16.667Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.76641 12.7083H11.5081L7.24974 1.875L5.69974 1.875L1.44141 12.7083H3.18307L4.11641 10.2083L8.81641 10.2083L9.76641 12.7083ZM4.74974 8.54167L6.47474 3.94167L8.19974 8.54167H4.74974ZM17.3831 9.03333L10.6414 15.775L7.58307 12.7083L6.40807 13.8833L10.6497 18.125L18.5581 10.2083L17.3831 9.03333Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.0077 6.61634L10.8827 3.49134L3.33268 11.0413L3.33268 14.1663H6.45768L14.0077 6.61634ZM4.99935 12.4997V11.733L10.8827 5.84968L11.6493 6.61634L5.76602 12.4997H4.99935Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M16.4243 4.19967C16.7493 3.87467 16.7493 3.34967 16.4243 3.02467L14.4743 1.07467C14.3077 0.908008 14.0993 0.833008 13.8827 0.833008C13.6743 0.833008 13.4577 0.916341 13.2993 1.07467L11.7743 2.59967L14.8993 5.72467L16.4243 4.19967Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M18.3327 15.833L1.66602 15.833L1.66602 19.1663L18.3327 19.1663V15.833Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.916 9.1665C13.6064 9.1665 14.166 8.60686 14.166 7.9165C14.166 7.22615 13.6064 6.6665 12.916 6.6665C12.2257 6.6665 11.666 7.22615 11.666 7.9165C11.666 8.60686 12.2257 9.1665 12.916 9.1665Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M7.08268 9.1665C7.77304 9.1665 8.33268 8.60686 8.33268 7.9165C8.33268 7.22615 7.77304 6.6665 7.08268 6.6665C6.39233 6.6665 5.83268 7.22615 5.83268 7.9165C5.83268 8.60686 6.39233 9.1665 7.08268 9.1665Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M9.99935 13.3332C8.76602 13.3332 7.70768 12.6582 7.12435 11.6665H5.73268C6.39935 13.3748 8.05768 14.5832 9.99935 14.5832C11.941 14.5832 13.5993 13.3748 14.266 11.6665H12.8743C12.291 12.6582 11.2327 13.3332 9.99935 13.3332ZM9.99102 1.6665C5.39102 1.6665 1.66602 5.39984 1.66602 9.99984C1.66602 14.5998 5.39102 18.3332 9.99102 18.3332C14.5993 18.3332 18.3327 14.5998 18.3327 9.99984C18.3327 5.39984 14.5993 1.6665 9.99102 1.6665ZM9.99935 16.6665C6.31602 16.6665 3.33268 13.6832 3.33268 9.99984C3.33268 6.3165 6.31602 3.33317 9.99935 3.33317C13.6827 3.33317 16.666 6.3165 16.666 9.99984C16.666 13.6832 13.6827 16.6665 9.99935 16.6665Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 16.667H3.33398L3.33398 18.3337H16.6673V16.667Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M16.6673 1.66699L3.33398 1.66699V3.33366L16.6673 3.33366V1.66699Z" fill="#424244" fill-opacity="0.72"/>
|
||||
<path d="M7.84232 11.3253L6.66732 12.5003L10.0007 15.8337L13.334 12.5003L12.159 11.3253L10.834 12.642L10.834 7.35866L12.159 8.67533L13.334 7.50033L10.0007 4.16699L6.66732 7.50033L7.84232 8.67533L9.16732 7.35866L9.16732 12.642L7.84232 11.3253Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.9993 10.8332C12.241 10.8332 9.99935 13.0748 9.99935 15.8332C12.7577 15.8332 14.9993 13.5915 14.9993 10.8332ZM4.99935 10.8332C4.99935 13.5915 7.24102 15.8332 9.99935 15.8332C9.99935 13.0748 7.75768 10.8332 4.99935 10.8332ZM6.66602 9.1915C6.66602 9.90817 7.24935 10.4915 7.96602 10.4915C8.24102 10.4915 8.49102 10.4082 8.70768 10.2582L8.69935 10.3582C8.69935 11.0748 9.28268 11.6582 9.99935 11.6582C10.716 11.6582 11.2993 11.0748 11.2993 10.3582L11.291 10.2582C11.4993 10.3998 11.7577 10.4915 12.0327 10.4915C12.7493 10.4915 13.3327 9.90817 13.3327 9.1915C13.3327 8.67484 13.0243 8.22484 12.591 8.0165C13.0243 7.8165 13.3327 7.3665 13.3327 6.84984C13.3327 6.13317 12.7493 5.54984 12.0327 5.54984C11.7577 5.54984 11.5077 5.63317 11.291 5.78317L11.2993 5.68317C11.2993 4.9665 10.716 4.38317 9.99935 4.38317C9.28268 4.38317 8.69935 4.9665 8.69935 5.68317L8.70768 5.78317C8.49935 5.63317 8.24102 5.54984 7.96602 5.54984C7.24935 5.54984 6.66602 6.13317 6.66602 6.84984C6.66602 7.3665 6.97435 7.8165 7.40768 8.02484C6.97435 8.22484 6.66602 8.67484 6.66602 9.1915ZM9.99935 6.7165C10.716 6.7165 11.2993 7.29984 11.2993 8.0165C11.2993 8.73317 10.716 9.3165 9.99935 9.3165C9.28268 9.3165 8.69935 8.73317 8.69935 8.0165C8.69935 7.29984 9.28268 6.7165 9.99935 6.7165ZM16.666 3.33317V16.6665H3.33268L3.33268 3.33317L16.666 3.33317ZM16.666 1.6665L3.33268 1.6665C2.41602 1.6665 1.66602 2.4165 1.66602 3.33317L1.66602 16.6665C1.66602 17.5832 2.41602 18.3332 3.33268 18.3332L16.666 18.3332C17.5827 18.3332 18.3327 17.5832 18.3327 16.6665L18.3327 3.33317C18.3327 2.4165 17.5827 1.6665 16.666 1.6665Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.9992 15.1679L10.8326 9.79294V9.0346C12.2076 8.62627 13.1659 7.22627 12.8576 5.6596C12.6409 4.56794 11.7742 3.6596 10.6826 3.4096C8.78257 2.97627 7.08257 4.41794 7.08257 6.25127L8.74924 6.25127C8.74924 5.5596 9.30757 5.00127 9.99924 5.00127C10.6909 5.00127 11.2492 5.5596 11.2492 6.25127C11.2492 6.95127 10.6742 7.51794 9.97424 7.50127C9.52424 7.49294 9.16591 7.87627 9.16591 8.32627V9.79294L1.99924 15.1679C1.35757 15.6513 1.69924 16.6679 2.49924 16.6679H17.4992C18.2992 16.6679 18.6409 15.6513 17.9992 15.1679ZM4.99924 15.0013L9.99924 11.2513L14.9992 15.0013L4.99924 15.0013Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M15.5604 4.44057C15.8132 4.69337 15.8132 5.10323 15.5604 5.35602L12.9253 7.99106L14.3821 7.99106C14.7396 7.99106 15.0294 8.28087 15.0294 8.63838C15.0294 8.99588 14.7396 9.2857 14.3821 9.2857H11.3625C11.005 9.2857 10.7152 8.99588 10.7152 8.63838V5.61755C10.7152 5.26004 11.005 4.97023 11.3625 4.97023C11.7201 4.97023 12.0099 5.26004 12.0099 5.61755V7.07561L14.6449 4.44057C14.8977 4.18778 15.3076 4.18778 15.5604 4.44057Z"
|
||||
fill="#424244" fill-opacity="0.72" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M4.97118 11.3616C4.97118 11.0041 5.261 10.7143 5.6185 10.7143H8.63935C8.99686 10.7143 9.28668 11.0041 9.28668 11.3616V14.3824C9.28668 14.7399 8.99686 15.0298 8.63935 15.0298C8.28185 15.0298 7.99203 14.7399 7.99203 14.3824L7.99203 12.9244L5.357 15.5594C5.10421 15.8122 4.69434 15.8122 4.44155 15.5594C4.18875 15.3066 4.18875 14.8967 4.44155 14.644L7.07658 12.0089H5.6185C5.261 12.0089 4.97118 11.7191 4.97118 11.3616Z"
|
||||
fill="#424244" fill-opacity="0.72" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.7257 12.5587L8.60898 10.467L8.63398 10.442C10.084 8.82533 11.1173 6.96699 11.7257 5.00033H14.1673V3.33366L8.33398 3.33366V1.66699L6.66732 1.66699V3.33366L0.833984 3.33366L0.833984 4.99199L10.1423 4.99199C9.58398 6.60033 8.70065 8.12533 7.50065 9.45866C6.72565 8.60033 6.08398 7.65866 5.57565 6.66699L3.90898 6.66699C4.51732 8.02533 5.35065 9.30866 6.39232 10.467L2.15065 14.6503L3.33398 15.8337L7.50065 11.667L10.0923 14.2587L10.7257 12.5587ZM15.4173 8.33366H13.7507L10.0007 18.3337H11.6673L12.6007 15.8337H16.559L17.5007 18.3337H19.1673L15.4173 8.33366ZM13.234 14.167L14.584 10.5587L15.934 14.167H13.234Z" fill="#424244" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 760 B |
@@ -0,0 +1,41 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_60276_30700)">
|
||||
<path
|
||||
d="M9.11012 14.2073C9.02829 13.89 8.86293 13.6005 8.63127 13.3689C8.39961 13.1372 8.11011 12.9718 7.79287 12.89L2.16912 11.4398C2.07318 11.4126 1.98873 11.3548 1.9286 11.2753C1.86847 11.1957 1.83594 11.0987 1.83594 10.9989C1.83594 10.8992 1.86847 10.8022 1.9286 10.7226C1.98873 10.643 2.07318 10.5852 2.16912 10.558L7.79287 9.10693C8.10999 9.02517 8.39942 8.85995 8.63108 8.62846C8.86273 8.39697 9.02814 8.10766 9.11012 7.79059L10.5603 2.16684C10.5872 2.07052 10.645 1.98566 10.7247 1.9252C10.8044 1.86475 10.9016 1.83203 11.0017 1.83203C11.1017 1.83203 11.199 1.86475 11.2787 1.9252C11.3584 1.98566 11.4161 2.07052 11.443 2.16684L12.8923 7.79059C12.9741 8.10783 13.1395 8.39733 13.3711 8.62899C13.6028 8.86065 13.8923 9.02601 14.2095 9.10784L19.8333 10.5571C19.93 10.5838 20.0153 10.6414 20.0761 10.7212C20.1368 10.8011 20.1698 10.8986 20.1698 10.9989C20.1698 11.0992 20.1368 11.1968 20.0761 11.2766C20.0153 11.3564 19.93 11.4141 19.8333 11.4408L14.2095 12.89C13.8923 12.9718 13.6028 13.1372 13.3711 13.3689C13.1395 13.6005 12.9741 13.89 12.8923 14.2073L11.4421 19.831C11.4152 19.9273 11.3574 20.0122 11.2777 20.0726C11.1981 20.1331 11.1008 20.1658 11.0007 20.1658C10.9007 20.1658 10.8034 20.1331 10.7238 20.0726C10.6441 20.0122 10.5863 19.9273 10.5594 19.831L9.11012 14.2073Z"
|
||||
stroke="#868687" stroke-width="1.83333" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path
|
||||
d="M9.11012 14.2073C9.02829 13.89 8.86293 13.6005 8.63127 13.3689C8.39961 13.1372 8.11011 12.9718 7.79287 12.89L2.16912 11.4398C2.07318 11.4126 1.98873 11.3548 1.9286 11.2753C1.86847 11.1957 1.83594 11.0987 1.83594 10.9989C1.83594 10.8992 1.86847 10.8022 1.9286 10.7226C1.98873 10.643 2.07318 10.5852 2.16912 10.558L7.79287 9.10693C8.10999 9.02517 8.39942 8.85995 8.63108 8.62846C8.86273 8.39697 9.02814 8.10766 9.11012 7.79059L10.5603 2.16684C10.5872 2.07052 10.645 1.98566 10.7247 1.9252C10.8044 1.86475 10.9016 1.83203 11.0017 1.83203C11.1017 1.83203 11.199 1.86475 11.2787 1.9252C11.3584 1.98566 11.4161 2.07052 11.443 2.16684L12.8923 7.79059C12.9741 8.10783 13.1395 8.39733 13.3711 8.62899C13.6028 8.86065 13.8923 9.02601 14.2095 9.10784L19.8333 10.5571C19.93 10.5838 20.0153 10.6414 20.0761 10.7212C20.1368 10.8011 20.1698 10.8986 20.1698 10.9989C20.1698 11.0992 20.1368 11.1968 20.0761 11.2766C20.0153 11.3564 19.93 11.4141 19.8333 11.4408L14.2095 12.89C13.8923 12.9718 13.6028 13.1372 13.3711 13.3689C13.1395 13.6005 12.9741 13.89 12.8923 14.2073L11.4421 19.831C11.4152 19.9273 11.3574 20.0122 11.2777 20.0726C11.1981 20.1331 11.1008 20.1658 11.0007 20.1658C10.9007 20.1658 10.8034 20.1331 10.7238 20.0726C10.6441 20.0122 10.5863 19.9273 10.5594 19.831L9.11012 14.2073Z"
|
||||
stroke="url(#paint0_linear_60276_30700)" stroke-opacity="0.9" stroke-width="1.83333"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M18.334 1.83496C18.8402 1.83496 19.2508 2.2458 19.251 2.75195V3.66797H20.1699C20.676 3.66814 21.0859 4.07881 21.0859 4.58496C21.0859 5.09111 20.676 5.50178 20.1699 5.50195H19.251V6.41895C19.2508 6.92506 18.8401 7.33496 18.334 7.33496C17.8278 7.33496 17.4172 6.92506 17.417 6.41895V5.50195H16.5029C15.9967 5.50195 15.5859 5.09122 15.5859 4.58496C15.5859 4.0787 15.9967 3.66797 16.5029 3.66797H17.417V2.75195C17.4171 2.2458 17.8278 1.83496 18.334 1.83496Z"
|
||||
fill="url(#paint1_linear_60276_30700)" fill-opacity="0.9" />
|
||||
<path
|
||||
d="M3.66699 14.667C4.1726 14.667 4.58293 15.0767 4.58398 15.582L4.67773 15.5859C5.14001 15.6329 5.50098 16.0244 5.50098 16.499C5.50073 16.9734 5.13985 17.3642 4.67773 17.4111L4.58398 17.416V17.417C4.58398 17.9233 4.17325 18.334 3.66699 18.334C3.16073 18.334 2.75 17.9233 2.75 17.417V17.415C2.24435 17.4145 1.83425 17.0047 1.83398 16.499C1.83398 15.9931 2.24419 15.5826 2.75 15.582C2.75105 15.0767 3.16138 14.667 3.66699 14.667Z"
|
||||
fill="url(#paint2_linear_60276_30700)" fill-opacity="0.9" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_60276_30700" x1="1.83594" y1="10.9989" x2="26.983"
|
||||
y2="10.9989" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00B7FF" />
|
||||
<stop offset="0.567308" stop-color="#E06DD1" />
|
||||
<stop offset="1" stop-color="#FFFF00" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_60276_30700" x1="15.5859" y1="4.58496" x2="23.1299"
|
||||
y2="4.58496" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00B7FF" />
|
||||
<stop offset="0.567308" stop-color="#E06DD1" />
|
||||
<stop offset="1" stop-color="#FFFF00" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_60276_30700" x1="1.83398" y1="16.5005" x2="6.86371"
|
||||
y2="16.5005" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00B7FF" />
|
||||
<stop offset="0.567308" stop-color="#E06DD1" />
|
||||
<stop offset="1" stop-color="#FFFF00" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_60276_30700">
|
||||
<rect width="22" height="22" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -1,3 +1,13 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.50594 0.166391C5.61219 0.0857701 5.73654 0.0323467 5.86816 0.0107736C5.99978 -0.0107996 6.13468 0.000131761 6.26111 0.0426156C6.38754 0.0850994 6.50167 0.157847 6.59355 0.254525C6.68544 0.351202 6.75229 0.468876 6.7883 0.597301L6.79103 0.60821L7.65339 3.95457C7.67767 4.04897 7.72681 4.13514 7.79568 4.20411C7.86456 4.27309 7.95066 4.32234 8.04503 4.34676L11.3919 5.20857L11.4007 5.2113C11.5733 5.25892 11.7255 5.36186 11.834 5.50434C11.9425 5.64681 12.0013 5.82095 12.0013 6.00003C12.0013 6.17911 11.9425 6.35325 11.834 6.49572C11.7255 6.63819 11.5733 6.74114 11.4007 6.78875L11.3919 6.79094L8.04557 7.6533C7.95111 7.67763 7.86489 7.72685 7.79591 7.79582C7.72694 7.8648 7.67772 7.95102 7.65339 8.04548L6.79048 11.3918L6.78775 11.4028C6.73963 11.5747 6.63658 11.7262 6.49432 11.8341C6.35206 11.942 6.1784 12.0004 5.99985 12.0004C5.82129 12.0004 5.64763 11.942 5.50537 11.8341C5.36311 11.7262 5.26006 11.5747 5.21194 11.4028L5.20921 11.3918L4.34685 8.04548C4.32246 7.95104 4.27323 7.86486 4.20426 7.79589C4.13529 7.72692 4.0491 7.67768 3.95466 7.6533L0.6083 6.79094L0.595754 6.78766C0.424477 6.73905 0.273732 6.63589 0.166392 6.49385C0.0590526 6.3518 0.000976563 6.17862 0.000976562 6.00057C0.000976563 5.82253 0.0590526 5.64935 0.166392 5.5073C0.273732 5.36525 0.424477 5.2621 0.595754 5.21348L0.6083 5.21021L3.95466 4.34621C4.04906 4.32193 4.13523 4.27279 4.2042 4.20392C4.27318 4.13504 4.32243 4.04894 4.34685 3.95457L5.20921 0.60821L5.21194 0.597301C5.26015 0.425508 5.36375 0.274187 5.50594 0.166391ZM6.00012 1.91076L6.59739 4.2273C6.67056 4.51031 6.81816 4.76858 7.02486 4.97528C7.23156 5.18198 7.48983 5.32958 7.77285 5.40275L10.0899 6.00003L7.77285 6.5973C7.48981 6.67042 7.23151 6.818 7.0248 7.02471C6.81809 7.23142 6.67051 7.48972 6.59739 7.77276L6.00012 10.0893L5.40284 7.77276C5.32986 7.48959 5.18234 7.23115 4.97562 7.02433C4.76889 6.81751 4.51052 6.66987 4.22739 6.59676L1.91139 6.00003L4.22739 5.40221C4.51039 5.32919 4.76867 5.18171 4.97538 4.97509C5.18209 4.76847 5.32969 4.51027 5.40284 4.2273L6.00012 1.91076ZM10.3638 0.545482C10.5084 0.545482 10.6472 0.60295 10.7494 0.705242C10.8517 0.807535 10.9092 0.946273 10.9092 1.09094V1.63639H11.4547C11.5993 1.63639 11.7381 1.69386 11.8404 1.79615C11.9427 1.89844 12.0001 2.03718 12.0001 2.18185C12.0001 2.32651 11.9427 2.46525 11.8404 2.56754C11.7381 2.66983 11.5993 2.7273 11.4547 2.7273H10.9092V3.27276C10.9092 3.41742 10.8517 3.55616 10.7494 3.65845C10.6472 3.76074 10.5084 3.81821 10.3638 3.81821C10.2191 3.81821 10.0804 3.76074 9.97806 3.65845C9.87577 3.55616 9.8183 3.41742 9.8183 3.27276V2.7273H9.27285C9.12818 2.7273 8.98944 2.66983 8.88715 2.56754C8.78486 2.46525 8.72739 2.32651 8.72739 2.18185C8.72739 2.03718 8.78486 1.89844 8.88715 1.79615C8.98944 1.69386 9.12818 1.63639 9.27285 1.63639H9.8183V1.09094C9.8183 0.946273 9.87577 0.807535 9.97806 0.705242C10.0804 0.60295 10.2191 0.545482 10.3638 0.545482ZM1.63648 8.18185C1.78115 8.18185 1.91988 8.23931 2.02218 8.34161C2.12447 8.4439 2.18194 8.58264 2.18194 8.7273C2.3266 8.7273 2.46534 8.78477 2.56763 8.88706C2.66992 8.98935 2.72739 9.12809 2.72739 9.27276C2.72739 9.41742 2.66992 9.55616 2.56763 9.65845C2.46534 9.76074 2.3266 9.81821 2.18194 9.81821C2.18194 9.96287 2.12447 10.1016 2.02218 10.2039C1.91988 10.3062 1.78115 10.3637 1.63648 10.3637C1.49182 10.3637 1.35308 10.3062 1.25079 10.2039C1.14849 10.1016 1.09103 9.96287 1.09103 9.81821C0.946364 9.81821 0.807625 9.76074 0.705332 9.65845C0.60304 9.55616 0.545573 9.41742 0.545573 9.27276C0.545573 9.12809 0.60304 8.98935 0.705332 8.88706C0.807625 8.78477 0.946364 8.7273 1.09103 8.7273C1.09103 8.58264 1.14849 8.4439 1.25079 8.34161C1.35308 8.23931 1.49182 8.18185 1.63648 8.18185Z" fill="#007AFF"/>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.93548 15.5002C9.8462 15.1542 9.66581 14.8384 9.41309 14.5856C9.16037 14.3329 8.84455 14.1525 8.49848 14.0632L2.36348 12.4812C2.25881 12.4515 2.16668 12.3885 2.10109 12.3017C2.03549 12.2149 2 12.1091 2 12.0002C2 11.8914 2.03549 11.7856 2.10109 11.6988C2.16668 11.612 2.25881 11.549 2.36348 11.5192L8.49848 9.93625C8.84442 9.84706 9.16017 9.66682 9.41288 9.41429C9.66559 9.16175 9.84604 8.84614 9.93548 8.50025L11.5175 2.36525C11.5469 2.26017 11.6099 2.16759 11.6968 2.10164C11.7837 2.0357 11.8899 2 11.999 2C12.1081 2 12.2142 2.0357 12.3012 2.10164C12.3881 2.16759 12.4511 2.26017 12.4805 2.36525L14.0615 8.50025C14.1508 8.84632 14.3311 9.16215 14.5839 9.41487C14.8366 9.66759 15.1524 9.84797 15.4985 9.93725L21.6335 11.5182C21.739 11.5473 21.832 11.6103 21.8983 11.6973C21.9646 11.7844 22.0005 11.8908 22.0005 12.0002C22.0005 12.1097 21.9646 12.2161 21.8983 12.3032C21.832 12.3902 21.739 12.4531 21.6335 12.4822L15.4985 14.0632C15.1524 14.1525 14.8366 14.3329 14.5839 14.5856C14.3311 14.8384 14.1508 15.1542 14.0615 15.5002L12.4795 21.6353C12.4501 21.7403 12.3871 21.8329 12.3002 21.8989C12.2132 21.9648 12.1071 22.0005 11.998 22.0005C11.8889 22.0005 11.7827 21.9648 11.6958 21.8989C11.6089 21.8329 11.5459 21.7403 11.5165 21.6353L9.93548 15.5002Z"
|
||||
stroke="#55687D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M20 3V7" stroke="#55687D" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M22 5H18" stroke="#55687D" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M4 17V19" stroke="#55687D" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M5 18H3" stroke="#55687D" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -255,10 +255,12 @@ extension AppColor on Color {
|
||||
static const iconFolder = Color(0xFF297EF2);
|
||||
static const folderDivider = Color(0xFFE4E8EC);
|
||||
static const gray424244 = Color(0xFF424244);
|
||||
static const gray777778 = Color(0xFF777778);
|
||||
static const gray200 = Color(0xFFCCCCCC);
|
||||
static const lightGrayF4F4F4 = Color(0xFFF4F4F4);
|
||||
static const gray959DAD = Color(0xFF959DAD);
|
||||
static const gray9AA7B6 = Color(0xFF9AA7B6);
|
||||
static const gray9B9B9B = Color(0xFF9B9B9B);
|
||||
static const redFF3347 = Color(0xFFFF3347);
|
||||
static const gray686E76 = Color(0xFF686E76);
|
||||
static const gray900 = Color(0xFF222222);
|
||||
@@ -276,8 +278,10 @@ extension AppColor on Color {
|
||||
static const m3Primary = Color(0xFF0A84FF);
|
||||
static const m3Primary95 = Color(0xFFE3F1FF);
|
||||
static const gray49454F = Color(0xFF49454F);
|
||||
static const blueFF00B7FF = Color(0xFF00B7FF);
|
||||
static const lightGrayF9FAFB = Color(0xFFF9FAFB);
|
||||
static const black4D4D4D = Color(0xFF4D4D4D);
|
||||
static const black1A1A1A = Color(0xFF1A1A1A);
|
||||
static const green166534 = Color(0xFF166534);
|
||||
static const lightGreenF0FDF4 = Color(0xFFF0FDF4);
|
||||
static const lightGreenBBF7D0 = Color(0xFFBBF7D0);
|
||||
|
||||
@@ -262,6 +262,18 @@ class ImagePaths {
|
||||
String get icText => _getImagePath('ic_text.svg');
|
||||
String get icUser => _getImagePath('ic_user.svg');
|
||||
String get icSparkle => _getImagePath('ic_sparkle.svg');
|
||||
String get icGradientSparkle => _getImagePath('ic_gradient_sparkle.svg');
|
||||
String get icAiChangeTons => _getImagePath('ic_ai_change_tons.svg');
|
||||
String get icAiGrammar => _getImagePath('ic_ai_grammar.svg');
|
||||
String get icAiImprove => _getImagePath('ic_ai_improve.svg');
|
||||
String get icAiTranslate => _getImagePath('ic_ai_translate.svg');
|
||||
String get icAiBullets => _getImagePath('ic_ai_bullets.svg');
|
||||
String get icAiEmojify => _getImagePath('ic_ai_emojify.svg');
|
||||
String get icAiShorter => _getImagePath('ic_ai_shorter.svg');
|
||||
String get icAiMoreCasual => _getImagePath('ic_ai_more_casual.svg');
|
||||
String get icAiMoreDetail => _getImagePath('ic_ai_more_detail.svg');
|
||||
String get icAiMoreProfessional => _getImagePath('ic_ai_more_professional.svg');
|
||||
String get icAiMorePolite => _getImagePath('ic_ai_more_polite.svg');
|
||||
|
||||
String _getImagePath(String imageName) {
|
||||
return AssetsPaths.images + imageName;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:jmap_dart_client/jmap/account_id.dart';
|
||||
import 'package:jmap_dart_client/jmap/core/session/session.dart';
|
||||
import 'package:tmail_ui_user/features/home/domain/extensions/session_extensions.dart';
|
||||
|
||||
mixin AiScribeMixin {
|
||||
bool isAICapabilitySupported({Session? session, AccountId? accountId}) {
|
||||
if (accountId == null || session == null) {
|
||||
return false;
|
||||
}
|
||||
final aiCapability = session.getAICapability(accountId);
|
||||
return aiCapability != null;
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,12 @@ import 'package:model/model.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:rich_text_composer/rich_text_composer.dart';
|
||||
import 'package:scribe/scribe.dart';
|
||||
import 'package:super_tag_editor/tag_editor.dart';
|
||||
import 'package:tmail_ui_user/features/base/base_controller.dart';
|
||||
import 'package:tmail_ui_user/features/base/before_reconnect_handler.dart';
|
||||
import 'package:tmail_ui_user/features/base/before_reconnect_manager.dart';
|
||||
import 'package:tmail_ui_user/features/base/mixin/ai_scribe_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/base/mixin/auto_complete_result_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart';
|
||||
import 'package:tmail_ui_user/features/base/state/base_ui_state.dart';
|
||||
@@ -77,7 +79,6 @@ import 'package:tmail_ui_user/features/composer/presentation/extensions/setup_em
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/setup_list_identities_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/setup_selected_identity_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/update_screen_display_mode_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/mixin/drag_drog_file_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/model/create_email_request.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/model/inline_image.dart';
|
||||
@@ -90,7 +91,6 @@ import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/from
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/saving_message_dialog_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/saving_template_dialog_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/sending_message_dialog_view.dart';
|
||||
import 'package:scribe/scribe.dart';
|
||||
import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart';
|
||||
import 'package:tmail_ui_user/features/email/domain/state/save_template_email_state.dart';
|
||||
import 'package:tmail_ui_user/features/email/domain/state/update_template_email_state.dart';
|
||||
@@ -103,9 +103,9 @@ import 'package:tmail_ui_user/features/email/presentation/model/composer_argumen
|
||||
import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/remove_composer_cache_by_id_on_web_interactor.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/validate_premium_storage_extension.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/open_and_close_composer_extension.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_text_formatting_menu_state_extension.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/validate_premium_storage_extension.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/draggable_app_state.dart';
|
||||
import 'package:tmail_ui_user/features/manage_account/domain/state/get_all_identities_state.dart';
|
||||
import 'package:tmail_ui_user/features/manage_account/domain/usecases/get_all_identities_interactor.dart';
|
||||
@@ -131,9 +131,12 @@ import 'package:tmail_ui_user/main/universal_import/html_stub.dart' as html;
|
||||
import 'package:tmail_ui_user/main/utils/app_config.dart';
|
||||
|
||||
class ComposerController extends BaseController
|
||||
with DragDropFileMixin, AutoCompleteResultMixin, EditorViewMixin, AIScribeInComposerMixin
|
||||
with
|
||||
DragDropFileMixin,
|
||||
AutoCompleteResultMixin,
|
||||
EditorViewMixin,
|
||||
AiScribeMixin
|
||||
implements BeforeReconnectHandler {
|
||||
|
||||
final mailboxDashBoardController = Get.find<MailboxDashBoardController>();
|
||||
final networkConnectionController = Get.find<NetworkConnectionController>();
|
||||
final _beforeReconnectManager = Get.find<BeforeReconnectManager>();
|
||||
@@ -157,6 +160,7 @@ class ComposerController extends BaseController
|
||||
final isEmailChanged = Rx<bool>(false);
|
||||
final isMarkAsImportant = Rx<bool>(false);
|
||||
final isContentHeightExceeded = Rx<bool>(false);
|
||||
final editorTextSelection = Rxn<TextSelectionModel>();
|
||||
|
||||
final LocalFilePickerInteractor _localFilePickerInteractor;
|
||||
final LocalImagePickerInteractor _localImagePickerInteractor;
|
||||
@@ -221,11 +225,8 @@ class ComposerController extends BaseController
|
||||
StreamSubscription<html.Event>? _subscriptionOnBlur;
|
||||
StreamSubscription<String>? _composerCacheListener;
|
||||
|
||||
@override
|
||||
RichTextMobileTabletController? richTextMobileTabletController;
|
||||
@override
|
||||
RichTextWebController? richTextWebController;
|
||||
|
||||
CustomPopupMenuController? menuMoreOptionController;
|
||||
|
||||
final ScrollController scrollController = ScrollController();
|
||||
@@ -262,18 +263,11 @@ class ComposerController extends BaseController
|
||||
|
||||
TransformHtmlEmailContentInteractor get transformHtmlEmailContentInteractor => _transformHtmlEmailContentInteractor;
|
||||
|
||||
@override
|
||||
GenerateAITextInteractor get generateAITextInteractor => Get.find<GenerateAITextInteractor>();
|
||||
|
||||
String get ownEmailAddress =>
|
||||
mailboxDashBoardController.ownEmailAddress.value;
|
||||
|
||||
@override
|
||||
Session? get session => mailboxDashBoardController.sessionCurrent;
|
||||
|
||||
@override
|
||||
AccountId? get accountId => mailboxDashBoardController.accountId.value;
|
||||
|
||||
late Worker uploadInlineImageWorker;
|
||||
late bool _isEmailBodyLoaded;
|
||||
|
||||
@@ -873,20 +867,6 @@ class ComposerController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getTextOnlyContentInEditor() async {
|
||||
try {
|
||||
final htmlContent = await getContentInEditor();
|
||||
|
||||
String textContent = StringConvert.convertHtmlContentToTextContent(htmlContent);
|
||||
|
||||
return textContent;
|
||||
} catch (e) {
|
||||
logError('ComposerController::getTextOnlyContentInEditor:Exception = $e');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prepareToSendMessages(BuildContext context) async {
|
||||
final arguments = composerArguments.value;
|
||||
final session = mailboxDashBoardController.sessionCurrent;
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
|
||||
import 'package:model/email/prefix_email_address.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_button.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_content_height_exceeded_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_edit_recipient_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_open_context_menu_extension.dart';
|
||||
@@ -22,6 +22,7 @@ import 'package:tmail_ui_user/features/composer/presentation/styles/mobile_app_b
|
||||
import 'package:tmail_ui_user/features/composer/presentation/view/mobile/mobile_container_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/view/mobile/mobile_editor_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/view/mobile/tablet_container_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/insert_image_loading_bar_widget.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/list_recipients_collapsed_widget.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/app_bar_composer_widget.dart';
|
||||
@@ -264,7 +265,7 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64),
|
||||
],
|
||||
),
|
||||
_buildAIScribeSelectionButton(context),
|
||||
ComposerAiScribeSelectionOverlay(controller: controller),
|
||||
],
|
||||
)
|
||||
],
|
||||
@@ -453,7 +454,7 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64),
|
||||
],
|
||||
),
|
||||
_buildAIScribeSelectionButton(context),
|
||||
ComposerAiScribeSelectionOverlay(controller: controller),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -469,8 +470,9 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
sendMessageAction: () => controller.handleClickSendButton(context),
|
||||
requestReadReceiptAction: () => controller.toggleRequestReadReceipt(context),
|
||||
toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context),
|
||||
onOpenAIScribe: controller.isAIScribeAvailable ? () => controller.showAIScribeMenuForFullText(context) : null,
|
||||
aiScribeButtonKey: controller.isAIScribeAvailable ? controller.aiScribeButtonKey : null,
|
||||
onOpenAiAssistantModal: controller.isAIScribeAvailable
|
||||
? controller.openAIAssistantModal
|
||||
: null,
|
||||
)),
|
||||
]
|
||||
)
|
||||
@@ -553,46 +555,4 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputOnMobileAction,
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildAIScribeSelectionButton(BuildContext context) {
|
||||
if (!controller.isAIScribeAvailable) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Obx(() {
|
||||
final textSelection = controller.editorTextSelection.value;
|
||||
if (textSelection != null &&
|
||||
textSelection.hasSelection &&
|
||||
textSelection.coordinates != null) {
|
||||
final coordinates = textSelection.coordinates!;
|
||||
// Account for the horizontal padding around the editor
|
||||
const editorHorizontalPadding = 12.0;
|
||||
return PositionedDirectional(
|
||||
start: coordinates.dx + editorHorizontalPadding,
|
||||
top: coordinates.dy,
|
||||
child: Builder(
|
||||
builder: (buttonContext) {
|
||||
return AIScribeButton(
|
||||
imagePaths: controller.imagePaths,
|
||||
onTap: () {
|
||||
final RenderBox? renderBox = buttonContext.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final position = renderBox.localToGlobal(Offset.zero);
|
||||
controller.showAIScribeMenuForSelectedText(
|
||||
context,
|
||||
buttonPosition: position,
|
||||
);
|
||||
} else {
|
||||
controller.showAIScribeMenuForSelectedText(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,16 @@ import 'package:get/get.dart';
|
||||
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
|
||||
import 'package:model/email/prefix_email_address.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_button.dart';
|
||||
import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/dialog_picker/color_dialog_picker.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/keyboard/keyboard_handler_wrapper.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/composer_print_draft_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_edit_recipient_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_insert_link_composer_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_recipients_collapsed_extensions.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_recipients_collapsed_extensions.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/mark_as_important_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/preview_upload_file_extension.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart';
|
||||
@@ -25,6 +25,7 @@ import 'package:tmail_ui_user/features/composer/presentation/view/web/desktop_re
|
||||
import 'package:tmail_ui_user/features/composer/presentation/view/web/mobile_responsive_container_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/view/web/tablet_responsive_container_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/view/web/web_editor_view.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/insert_image_loading_bar_widget.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/list_recipients_collapsed_widget.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/from_composer_mobile_widget.dart';
|
||||
@@ -333,7 +334,7 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}),
|
||||
_buildAIScribeSelectionButton(context),
|
||||
ComposerAiScribeSelectionOverlay(controller: controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -559,8 +560,9 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context),
|
||||
saveAsTemplateAction: () => controller.handleClickSaveAsTemplateButton(context),
|
||||
onOpenInsertLink: controller.openInsertLink,
|
||||
onOpenAIScribe: controller.isAIScribeAvailable ? () => controller.showAIScribeMenuForFullText(context) : null,
|
||||
aiScribeButtonKey: controller.isAIScribeAvailable ? controller.aiScribeButtonKey : null,
|
||||
onOpenAiAssistantModal: controller.isAIScribeAvailable
|
||||
? controller.openAIAssistantModal
|
||||
: null,
|
||||
)),
|
||||
],
|
||||
),
|
||||
@@ -607,7 +609,7 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}),
|
||||
_buildAIScribeSelectionButton(context),
|
||||
ComposerAiScribeSelectionOverlay(controller: controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -835,8 +837,9 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context),
|
||||
saveAsTemplateAction: () => controller.handleClickSaveAsTemplateButton(context),
|
||||
onOpenInsertLink: controller.openInsertLink,
|
||||
onOpenAIScribe: controller.isAIScribeAvailable ? () => controller.showAIScribeMenuForFullText(context) : null,
|
||||
aiScribeButtonKey: controller.isAIScribeAvailable ? controller.aiScribeButtonKey : null,
|
||||
onOpenAiAssistantModal: controller.isAIScribeAvailable
|
||||
? controller.openAIAssistantModal
|
||||
: null,
|
||||
)),
|
||||
],
|
||||
),
|
||||
@@ -882,7 +885,7 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}),
|
||||
_buildAIScribeSelectionButton(context),
|
||||
ComposerAiScribeSelectionOverlay(controller: controller),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -955,46 +958,4 @@ class ComposerView extends GetWidget<ComposerController> {
|
||||
onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputOnMobileAction,
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildAIScribeSelectionButton(BuildContext context) {
|
||||
if (!controller.isAIScribeAvailable) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Obx(() {
|
||||
final textSelection = controller.editorTextSelection.value;
|
||||
if (textSelection != null &&
|
||||
textSelection.hasSelection &&
|
||||
textSelection.coordinates != null) {
|
||||
final coordinates = textSelection.coordinates!;
|
||||
return PositionedDirectional(
|
||||
start: coordinates.dx,
|
||||
top: coordinates.dy,
|
||||
child: PointerInterceptor(
|
||||
child: Builder(
|
||||
builder: (buttonContext) {
|
||||
return AIScribeButton(
|
||||
imagePaths: controller.imagePaths,
|
||||
onTap: () {
|
||||
final RenderBox? renderBox = buttonContext.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final position = renderBox.localToGlobal(Offset.zero);
|
||||
controller.showAIScribeMenuForSelectedText(
|
||||
context,
|
||||
buttonPosition: position,
|
||||
);
|
||||
} else {
|
||||
controller.showAIScribeMenuForSelectedText(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:core/utils/platform_info.dart';
|
||||
import 'package:core/utils/string_convert.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/mixin/text_selection_mixin.dart';
|
||||
|
||||
extension HandleAiScribeInComposerExtension on ComposerController {
|
||||
bool get isAIScribeAvailable {
|
||||
return isAICapabilitySupported(
|
||||
session: mailboxDashBoardController.sessionCurrent,
|
||||
accountId: mailboxDashBoardController.accountId.value,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _getTextOnlyContentInEditor() async {
|
||||
try {
|
||||
final htmlContent = await getContentInEditor();
|
||||
String textContent = StringConvert.convertHtmlContentToTextContent(
|
||||
htmlContent,);
|
||||
return textContent;
|
||||
} catch (e) {
|
||||
logError('$runtimeType::getTextOnlyContentInEditor:Exception = $e');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
void insertTextInEditor(String text) {
|
||||
final htmlContent = text.replaceAll('\n', '<br>');
|
||||
|
||||
if (PlatformInfo.isWeb) {
|
||||
richTextWebController?.editorController.insertHtml(htmlContent);
|
||||
} else {
|
||||
richTextMobileTabletController?.htmlEditorApi?.insertHtml(htmlContent);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openAIAssistantModal(Offset? position, Size? size) async {
|
||||
final fullText = await _getTextOnlyContentInEditor();
|
||||
|
||||
final aiResult = await AiScribeModalManager.showAIScribeMenuModal(
|
||||
imagePaths: imagePaths,
|
||||
availableCategories: AIScribeMenuCategory.values,
|
||||
buttonPosition: position,
|
||||
buttonSize: size,
|
||||
content: fullText,
|
||||
preferredPlacement: ModalPlacement.top,
|
||||
crossAxisAlignment: ModalCrossAxisAlignment.start,
|
||||
);
|
||||
|
||||
if (aiResult != null) {
|
||||
insertTextInEditor(aiResult);
|
||||
}
|
||||
}
|
||||
|
||||
void handleTextSelection(TextSelectionData? textSelectionData) {
|
||||
if (textSelectionData != null && textSelectionData.hasSelection) {
|
||||
editorTextSelection.value = TextSelectionModel(
|
||||
selectedText: textSelectionData.selectedText,
|
||||
coordinates: textSelectionData.coordinates != null
|
||||
? Offset(
|
||||
textSelectionData.coordinates!.x,
|
||||
textSelectionData.coordinates!.y,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
editorTextSelection.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/utils/platform_info.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:jmap_dart_client/jmap/account_id.dart';
|
||||
import 'package:jmap_dart_client/jmap/core/session/session.dart';
|
||||
import 'package:scribe/scribe.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_web_controller.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/mixins/text_selection_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/home/domain/extensions/session_extensions.dart';
|
||||
|
||||
mixin AIScribeInComposerMixin {
|
||||
final editorTextSelection = Rxn<EditorTextSelection>();
|
||||
final GlobalKey aiScribeButtonKey = GlobalKey();
|
||||
|
||||
RichTextWebController? get richTextWebController;
|
||||
RichTextMobileTabletController? get richTextMobileTabletController;
|
||||
ImagePaths get imagePaths;
|
||||
GenerateAITextInteractor get generateAITextInteractor;
|
||||
Session? get session;
|
||||
AccountId? get accountId;
|
||||
|
||||
bool get isAIScribeAvailable {
|
||||
if (!PlatformInfo.isWeb) return false;
|
||||
|
||||
final currentSession = session;
|
||||
final currentAccountId = accountId;
|
||||
|
||||
if (currentSession == null || currentAccountId == null) return false;
|
||||
|
||||
final aiCapability = currentSession.getAICapability(currentAccountId);
|
||||
return aiCapability != null;
|
||||
}
|
||||
|
||||
Future<String> getTextOnlyContentInEditor();
|
||||
|
||||
void insertTextInEditor(String text) {
|
||||
final htmlContent = text.replaceAll('\n', '<br>');
|
||||
|
||||
if (PlatformInfo.isWeb) {
|
||||
richTextWebController?.editorController.insertHtml(htmlContent);
|
||||
} else {
|
||||
richTextMobileTabletController?.htmlEditorApi?.insertHtml(htmlContent);
|
||||
}
|
||||
}
|
||||
|
||||
void showAIScribeMenuForFullText(BuildContext context) async {
|
||||
final fullText = await getTextOnlyContentInEditor();
|
||||
|
||||
final RenderBox? renderBox = aiScribeButtonKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
Offset? buttonPosition;
|
||||
if (renderBox != null) {
|
||||
buttonPosition = renderBox.localToGlobal(Offset.zero);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showAIScribeDialog(
|
||||
context: context,
|
||||
imagePaths: imagePaths,
|
||||
content: fullText,
|
||||
onInsertText: insertTextInEditor,
|
||||
interactor: generateAITextInteractor,
|
||||
buttonPosition: buttonPosition,
|
||||
);
|
||||
}
|
||||
|
||||
void showAIScribeMenuForSelectedText(BuildContext context, {Offset? buttonPosition}) {
|
||||
final selection = editorTextSelection.value?.selectedText;
|
||||
if (selection == null || selection.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
showAIScribeDialog(
|
||||
context: context,
|
||||
imagePaths: imagePaths,
|
||||
content: selection,
|
||||
onInsertText: insertTextInEditor,
|
||||
interactor: generateAITextInteractor,
|
||||
buttonPosition: buttonPosition,
|
||||
);
|
||||
}
|
||||
|
||||
void handleTextSelection(TextSelectionData? textSelectionData) {
|
||||
if (textSelectionData != null && textSelectionData.hasSelection) {
|
||||
editorTextSelection.value = EditorTextSelection(
|
||||
selectedText: textSelectionData.selectedText,
|
||||
coordinates: textSelectionData.coordinates != null
|
||||
? Offset(
|
||||
textSelectionData.coordinates!.x,
|
||||
textSelectionData.coordinates!.y,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
editorTextSelection.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,18 +69,6 @@ class TextSelectionCoordinates {
|
||||
Offset get position => Offset(x, y);
|
||||
}
|
||||
|
||||
class EditorTextSelection {
|
||||
final String? selectedText;
|
||||
final Offset? coordinates;
|
||||
|
||||
const EditorTextSelection({
|
||||
this.selectedText,
|
||||
this.coordinates,
|
||||
});
|
||||
|
||||
bool get hasSelection => selectedText != null && selectedText!.isNotEmpty;
|
||||
}
|
||||
|
||||
mixin TextSelectionMixin<T extends StatefulWidget> on State<T> {
|
||||
OnTextSelectionChanged? get onSelectionChanged => null;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart';
|
||||
|
||||
class ComposerAiScribeSelectionOverlay extends StatelessWidget {
|
||||
const ComposerAiScribeSelectionOverlay({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final ComposerController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!controller.isAIScribeAvailable) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Obx(() {
|
||||
return AiSelectionOverlay(
|
||||
selection: controller.editorTextSelection.value,
|
||||
imagePaths: controller.imagePaths,
|
||||
onAiScribeResultCallback: controller.insertTextInEditor,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:core/utils/platform_info.dart';
|
||||
import 'package:core/utils/html/html_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rich_text_composer/rich_text_composer.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/mixins/text_selection_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/mixin/text_selection_mixin.dart';
|
||||
|
||||
typedef OnCreatedEditorAction = Function(BuildContext context, HtmlEditorApi editorApi, String content);
|
||||
typedef OnLoadCompletedEditorAction = Function(HtmlEditorApi editorApi, WebUri? url);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/button/ai_assistant_button.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/styles/mobile/tablet_bottom_bar_composer_widget_style.dart';
|
||||
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
|
||||
|
||||
@@ -14,8 +15,7 @@ class TabletBottomBarComposerWidget extends StatelessWidget {
|
||||
final VoidCallback sendMessageAction;
|
||||
final VoidCallback requestReadReceiptAction;
|
||||
final VoidCallback toggleMarkAsImportantAction;
|
||||
final VoidCallback? onOpenAIScribe;
|
||||
final GlobalKey? aiScribeButtonKey;
|
||||
final OnOpenAiAssistantModal? onOpenAiAssistantModal;
|
||||
|
||||
const TabletBottomBarComposerWidget({
|
||||
super.key,
|
||||
@@ -27,8 +27,7 @@ class TabletBottomBarComposerWidget extends StatelessWidget {
|
||||
required this.sendMessageAction,
|
||||
required this.requestReadReceiptAction,
|
||||
required this.toggleMarkAsImportantAction,
|
||||
this.onOpenAIScribe,
|
||||
this.aiScribeButtonKey,
|
||||
this.onOpenAiAssistantModal,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -75,18 +74,14 @@ class TabletBottomBarComposerWidget extends StatelessWidget {
|
||||
: AppLocalizations.of(context).turnOnRequestReadReceipt,
|
||||
onTapActionCallback: requestReadReceiptAction,
|
||||
),
|
||||
if (onOpenAIScribe != null) ...[
|
||||
const SizedBox(width: TabletBottomBarComposerWidgetStyle.space),
|
||||
TMailButtonWidget.fromIcon(
|
||||
key: aiScribeButtonKey,
|
||||
icon: imagePaths.icSparkle,
|
||||
borderRadius: TabletBottomBarComposerWidgetStyle.iconRadius,
|
||||
padding: TabletBottomBarComposerWidgetStyle.iconPadding,
|
||||
iconSize: TabletBottomBarComposerWidgetStyle.iconSize,
|
||||
tooltipMessage: AppLocalizations.of(context).aiAssistant,
|
||||
onTapActionCallback: onOpenAIScribe!,
|
||||
if (onOpenAiAssistantModal != null)
|
||||
AiAssistantButton(
|
||||
imagePaths: imagePaths,
|
||||
margin: const EdgeInsetsDirectional.only(
|
||||
start: TabletBottomBarComposerWidgetStyle.space,
|
||||
),
|
||||
onOpenAiAssistantModal: onOpenAiAssistantModal!,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: TabletBottomBarComposerWidgetStyle.space),
|
||||
TMailButtonWidget.fromIcon(
|
||||
icon: imagePaths.icSaveToDraft,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:core/utils/platform_info.dart';
|
||||
import 'package:custom_pop_up_menu/custom_pop_up_menu.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/button/ai_assistant_button.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/highlight_svg_icon_on_hover.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/popup_item_widget.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/popup_menu_overlay_widget.dart';
|
||||
@@ -31,9 +32,8 @@ class BottomBarComposerWidget extends StatelessWidget {
|
||||
final VoidCallback toggleMarkAsImportantAction;
|
||||
final VoidCallback saveAsTemplateAction;
|
||||
final VoidCallback onOpenInsertLink;
|
||||
final VoidCallback? onOpenAIScribe;
|
||||
final GlobalKey? aiScribeButtonKey;
|
||||
final OnMenuChanged? onPopupMenuChanged;
|
||||
final OnOpenAiAssistantModal? onOpenAiAssistantModal;
|
||||
|
||||
const BottomBarComposerWidget({
|
||||
super.key,
|
||||
@@ -56,9 +56,8 @@ class BottomBarComposerWidget extends StatelessWidget {
|
||||
required this.toggleMarkAsImportantAction,
|
||||
required this.saveAsTemplateAction,
|
||||
required this.onOpenInsertLink,
|
||||
this.onOpenAIScribe,
|
||||
this.aiScribeButtonKey,
|
||||
this.onPopupMenuChanged,
|
||||
this.onOpenAiAssistantModal,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -131,25 +130,20 @@ class BottomBarComposerWidget extends StatelessWidget {
|
||||
onTapActionCallback: onOpenInsertLink,
|
||||
),
|
||||
),
|
||||
if (onOpenAIScribe != null) ...[
|
||||
const SizedBox(width: BottomBarComposerWidgetStyle.space),
|
||||
if (onOpenAiAssistantModal != null)
|
||||
AbsorbPointer(
|
||||
absorbing: isCodeViewEnabled,
|
||||
child: TMailButtonWidget.fromIcon(
|
||||
key: aiScribeButtonKey,
|
||||
icon: imagePaths.icSparkle,
|
||||
child: AiAssistantButton(
|
||||
imagePaths: imagePaths,
|
||||
iconColor: isCodeViewEnabled
|
||||
? BottomBarComposerWidgetStyle.disabledIconColor
|
||||
: BottomBarComposerWidgetStyle.iconColor,
|
||||
borderRadius: BottomBarComposerWidgetStyle.iconRadius,
|
||||
backgroundColor: Colors.transparent,
|
||||
padding: BottomBarComposerWidgetStyle.iconPadding,
|
||||
iconSize: BottomBarComposerWidgetStyle.iconSize,
|
||||
tooltipMessage: AppLocalizations.of(context).aiAssistant,
|
||||
onTapActionCallback: onOpenAIScribe!,
|
||||
: null,
|
||||
margin: const EdgeInsetsDirectional.only(
|
||||
start: BottomBarComposerWidgetStyle.space,
|
||||
),
|
||||
onOpenAiAssistantModal: onOpenAiAssistantModal!,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: BottomBarComposerWidgetStyle.space),
|
||||
PopupMenuOverlayWidget(
|
||||
controller: menuMoreOptionController,
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:core/utils/html/html_template.dart';
|
||||
import 'package:core/utils/html/html_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html_editor_enhanced/html_editor.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/mixins/text_selection_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/mixin/text_selection_mixin.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/widgets/web/signature_tooltip_widget.dart';
|
||||
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
|
||||
import 'package:universal_html/html.dart' hide VoidCallback;
|
||||
|
||||
@@ -5436,11 +5436,4 @@ class AppLocalizations {
|
||||
name: 'startToAddFavoritesEmails',
|
||||
);
|
||||
}
|
||||
|
||||
String get aiAssistant {
|
||||
return Intl.message(
|
||||
'AI assistant',
|
||||
name: 'aiAssistant',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
library scribe;
|
||||
|
||||
export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
|
||||
export 'scribe/ai/domain/repository/ai_scribe_repository.dart';
|
||||
export 'scribe/ai/domain/model/ai_response.dart';
|
||||
export 'scribe/ai/domain/state/generate_ai_text_state.dart';
|
||||
|
||||
export 'scribe/ai/data/repository/ai_repository_impl.dart';
|
||||
export 'scribe/ai/data/datasource/ai_datasource.dart';
|
||||
|
||||
export 'scribe/ai/data/repository/ai_repository_impl.dart';
|
||||
export 'scribe/ai/domain/model/ai_response.dart';
|
||||
export 'scribe/ai/domain/repository/ai_scribe_repository.dart';
|
||||
export 'scribe/ai/domain/state/generate_ai_text_state.dart';
|
||||
export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
|
||||
export 'scribe/ai/presentation/model/ai_action.dart';
|
||||
export 'scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
export 'scribe/ai/presentation/model/ai_capability.dart';
|
||||
export 'scribe/ai/presentation/utils/ai_scribe_constants.dart';
|
||||
export 'scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
export 'scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
|
||||
export 'scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart';
|
||||
export 'scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
export 'scribe/ai/presentation/model/text_selection_model.dart';
|
||||
export 'scribe/ai/presentation/utils/ai_scribe_constants.dart';
|
||||
export 'scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart';
|
||||
export 'scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
|
||||
export 'scribe/ai/presentation/widgets/button/ai_assistant_button.dart';
|
||||
export 'scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart';
|
||||
export 'scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart';
|
||||
export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:core/core.dart';
|
||||
import 'package:core/presentation/state/failure.dart';
|
||||
import 'package:core/presentation/state/success.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:scribe/scribe/ai/domain/constants/ai_prompts.dart';
|
||||
import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"@@last_modified": "2025-12-16T18:07:47.039649",
|
||||
"@@last_modified": "2025-12-18T02:49:48.638213",
|
||||
"categoryCorrectGrammar": "Correct grammar",
|
||||
"@categoryCorrectGrammar": {
|
||||
"type": "text",
|
||||
@@ -113,5 +113,29 @@
|
||||
"type": "text",
|
||||
"placeholders_order": [],
|
||||
"placeholders": {}
|
||||
},
|
||||
"aiAssistant": "AI assistant",
|
||||
"@aiAssistant": {
|
||||
"type": "text",
|
||||
"placeholders_order": [],
|
||||
"placeholders": {}
|
||||
},
|
||||
"generatingResponse": "Generating response",
|
||||
"@generatingResponse": {
|
||||
"type": "text",
|
||||
"placeholders_order": [],
|
||||
"placeholders": {}
|
||||
},
|
||||
"replace": "Replace",
|
||||
"@replace": {
|
||||
"type": "text",
|
||||
"placeholders_order": [],
|
||||
"placeholders": {}
|
||||
},
|
||||
"insert": "Insert",
|
||||
"@insert": {
|
||||
"type": "text",
|
||||
"placeholders_order": [],
|
||||
"placeholders": {}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import 'package:intl/intl.dart';
|
||||
import 'package:scribe/scribe/ai/l10n/messages_all.dart';
|
||||
|
||||
class ScribeLocalizations {
|
||||
|
||||
static ScribeLocalizations? of(BuildContext context) {
|
||||
return Localizations.of<ScribeLocalizations>(context, ScribeLocalizations);
|
||||
static ScribeLocalizations of(BuildContext context) {
|
||||
return Localizations.of<ScribeLocalizations>(context, ScribeLocalizations)!;
|
||||
}
|
||||
|
||||
static Future<ScribeLocalizations> load(Locale locale) async {
|
||||
final name = locale.countryCode == null ? locale.languageCode : locale.toString();
|
||||
final name =
|
||||
locale.countryCode == null ? locale.languageCode : locale.toString();
|
||||
|
||||
final localeName = Intl.canonicalizedLocale(name);
|
||||
|
||||
@@ -19,118 +19,188 @@ class ScribeLocalizations {
|
||||
});
|
||||
}
|
||||
|
||||
static const LocalizationsDelegate<ScribeLocalizations> delegate = _ScribeLocalizationsDelegate();
|
||||
static const LocalizationsDelegate<ScribeLocalizations> delegate =
|
||||
_ScribeLocalizationsDelegate();
|
||||
|
||||
// Menu Categories
|
||||
String get categoryCorrectGrammar {
|
||||
return Intl.message('Correct grammar',
|
||||
name: 'categoryCorrectGrammar');
|
||||
return Intl.message(
|
||||
'Correct grammar',
|
||||
name: 'categoryCorrectGrammar',
|
||||
);
|
||||
}
|
||||
|
||||
String get categoryImprove {
|
||||
return Intl.message('Improve',
|
||||
name: 'categoryImprove');
|
||||
return Intl.message(
|
||||
'Improve',
|
||||
name: 'categoryImprove',
|
||||
);
|
||||
}
|
||||
|
||||
String get categoryChangeTone {
|
||||
return Intl.message('Change tone',
|
||||
name: 'categoryChangeTone');
|
||||
return Intl.message(
|
||||
'Change tone',
|
||||
name: 'categoryChangeTone',
|
||||
);
|
||||
}
|
||||
|
||||
String get categoryTranslate {
|
||||
return Intl.message('Translate',
|
||||
name: 'categoryTranslate');
|
||||
return Intl.message(
|
||||
'Translate',
|
||||
name: 'categoryTranslate',
|
||||
);
|
||||
}
|
||||
|
||||
// Menu Actions - Improve
|
||||
String get actionMakeShorter {
|
||||
return Intl.message('Make it shorter',
|
||||
name: 'actionMakeShorter');
|
||||
return Intl.message(
|
||||
'Make it shorter',
|
||||
name: 'actionMakeShorter',
|
||||
);
|
||||
}
|
||||
|
||||
String get actionExpandContext {
|
||||
return Intl.message('Expand context',
|
||||
name: 'actionExpandContext');
|
||||
return Intl.message(
|
||||
'Expand context',
|
||||
name: 'actionExpandContext',
|
||||
);
|
||||
}
|
||||
|
||||
String get actionEmojify {
|
||||
return Intl.message('Emojify',
|
||||
name: 'actionEmojify');
|
||||
return Intl.message(
|
||||
'Emojify',
|
||||
name: 'actionEmojify',
|
||||
);
|
||||
}
|
||||
|
||||
String get actionTransformToBullets {
|
||||
return Intl.message('Transform to bullets',
|
||||
name: 'actionTransformToBullets');
|
||||
return Intl.message(
|
||||
'Transform to bullets',
|
||||
name: 'actionTransformToBullets',
|
||||
);
|
||||
}
|
||||
|
||||
// Menu Actions - Change Tone
|
||||
String get actionMoreProfessional {
|
||||
return Intl.message('More professional',
|
||||
name: 'actionMoreProfessional');
|
||||
return Intl.message(
|
||||
'More professional',
|
||||
name: 'actionMoreProfessional',
|
||||
);
|
||||
}
|
||||
|
||||
String get actionMoreCasual {
|
||||
return Intl.message('More casual',
|
||||
name: 'actionMoreCasual');
|
||||
return Intl.message(
|
||||
'More casual',
|
||||
name: 'actionMoreCasual',
|
||||
);
|
||||
}
|
||||
|
||||
String get actionMorePolite {
|
||||
return Intl.message('More polite',
|
||||
name: 'actionMorePolite');
|
||||
return Intl.message(
|
||||
'More polite',
|
||||
name: 'actionMorePolite',
|
||||
);
|
||||
}
|
||||
|
||||
// Menu Actions - Translate
|
||||
String get languageFrench {
|
||||
return Intl.message('French',
|
||||
name: 'languageFrench');
|
||||
return Intl.message(
|
||||
'French',
|
||||
name: 'languageFrench',
|
||||
);
|
||||
}
|
||||
|
||||
String get languageEnglish {
|
||||
return Intl.message('English',
|
||||
name: 'languageEnglish');
|
||||
return Intl.message(
|
||||
'English',
|
||||
name: 'languageEnglish',
|
||||
);
|
||||
}
|
||||
|
||||
String get languageRussian {
|
||||
return Intl.message('Russian',
|
||||
name: 'languageRussian');
|
||||
return Intl.message(
|
||||
'Russian',
|
||||
name: 'languageRussian',
|
||||
);
|
||||
}
|
||||
|
||||
String get languageVietnamese {
|
||||
return Intl.message('Vietnamese',
|
||||
name: 'languageVietnamese');
|
||||
return Intl.message(
|
||||
'Vietnamese',
|
||||
name: 'languageVietnamese',
|
||||
);
|
||||
}
|
||||
|
||||
// Input Bar
|
||||
String get inputPlaceholder {
|
||||
return Intl.message('Help me write',
|
||||
name: 'inputPlaceholder');
|
||||
return Intl.message(
|
||||
'Help me write',
|
||||
name: 'inputPlaceholder',
|
||||
);
|
||||
}
|
||||
|
||||
String get customPromptAction {
|
||||
return Intl.message('Help me write',
|
||||
name: 'customPromptAction');
|
||||
return Intl.message(
|
||||
'Help me write',
|
||||
name: 'customPromptAction',
|
||||
);
|
||||
}
|
||||
|
||||
// Suggestion Dialog
|
||||
String get failedToGenerate {
|
||||
return Intl.message('Failed to generate AI response',
|
||||
name: 'failedToGenerate');
|
||||
return Intl.message(
|
||||
'Failed to generate AI response',
|
||||
name: 'failedToGenerate',
|
||||
);
|
||||
}
|
||||
|
||||
String get insertButton {
|
||||
return Intl.message('Insert',
|
||||
name: 'insertButton');
|
||||
return Intl.message(
|
||||
'Insert',
|
||||
name: 'insertButton',
|
||||
);
|
||||
}
|
||||
|
||||
String get aiAssistant {
|
||||
return Intl.message(
|
||||
'AI assistant',
|
||||
name: 'aiAssistant',
|
||||
);
|
||||
}
|
||||
|
||||
String get generatingResponse {
|
||||
return Intl.message(
|
||||
'Generating response',
|
||||
name: 'generatingResponse',
|
||||
);
|
||||
}
|
||||
|
||||
String get replace {
|
||||
return Intl.message(
|
||||
'Replace',
|
||||
name: 'replace',
|
||||
);
|
||||
}
|
||||
|
||||
String get insert {
|
||||
return Intl.message(
|
||||
'Insert',
|
||||
name: 'insert',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScribeLocalizationsDelegate extends LocalizationsDelegate<ScribeLocalizations> {
|
||||
class _ScribeLocalizationsDelegate
|
||||
extends LocalizationsDelegate<ScribeLocalizations> {
|
||||
const _ScribeLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => ['en', 'fr', 'ru', 'vi'].contains(locale.languageCode);
|
||||
bool isSupported(Locale locale) =>
|
||||
['en', 'fr', 'ru', 'vi'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
Future<ScribeLocalizations> load(Locale locale) => ScribeLocalizations.load(locale);
|
||||
Future<ScribeLocalizations> load(Locale locale) =>
|
||||
ScribeLocalizations.load(locale);
|
||||
|
||||
@override
|
||||
bool shouldReload(_ScribeLocalizationsDelegate old) => false;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
|
||||
sealed class AIAction {
|
||||
const AIAction();
|
||||
|
||||
String getLabel(BuildContext context);
|
||||
String getLabel(ScribeLocalizations localizations);
|
||||
}
|
||||
|
||||
class PredefinedAction extends AIAction {
|
||||
@@ -14,7 +13,8 @@ class PredefinedAction extends AIAction {
|
||||
const PredefinedAction(this.action);
|
||||
|
||||
@override
|
||||
String getLabel(BuildContext context) => action.getFullLabel(context);
|
||||
String getLabel(ScribeLocalizations localizations) =>
|
||||
action.getFullLabel(localizations);
|
||||
}
|
||||
|
||||
class CustomPromptAction extends AIAction {
|
||||
@@ -23,7 +23,7 @@ class CustomPromptAction extends AIAction {
|
||||
const CustomPromptAction(this.prompt);
|
||||
|
||||
@override
|
||||
String getLabel(BuildContext context) {
|
||||
return ScribeLocalizations.of(context)?.customPromptAction ?? 'Help me write';
|
||||
String getLabel(ScribeLocalizations localizations) {
|
||||
return localizations.customPromptAction;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
|
||||
enum AIScribeMenuAction {
|
||||
@@ -15,8 +15,7 @@ enum AIScribeMenuAction {
|
||||
translateRussian,
|
||||
translateVietnamese;
|
||||
|
||||
String getLabel(BuildContext context) {
|
||||
final localizations = ScribeLocalizations.of(context)!;
|
||||
String getLabel(ScribeLocalizations localizations) {
|
||||
switch (this) {
|
||||
case AIScribeMenuAction.correctGrammar:
|
||||
return localizations.categoryCorrectGrammar;
|
||||
@@ -66,24 +65,44 @@ enum AIScribeMenuAction {
|
||||
}
|
||||
}
|
||||
|
||||
String getFullLabel(BuildContext context) {
|
||||
final categoryLabel = category.getLabel(context);
|
||||
String getFullLabel(ScribeLocalizations localizations) {
|
||||
final categoryLabel = category.getLabel(localizations);
|
||||
if (category.hasSubmenu) {
|
||||
return '$categoryLabel > ${getLabel(context)}';
|
||||
return '$categoryLabel > ${getLabel(localizations)}';
|
||||
} else {
|
||||
return getLabel(context);
|
||||
return getLabel(localizations);
|
||||
}
|
||||
}
|
||||
|
||||
String? getIcon(ImagePaths imagePaths) {
|
||||
switch (this) {
|
||||
case AIScribeMenuAction.improveMakeShorter:
|
||||
return imagePaths.icAiShorter;
|
||||
case AIScribeMenuAction.improveExpandContext:
|
||||
return imagePaths.icAiMoreDetail;
|
||||
case AIScribeMenuAction.improveEmojify:
|
||||
return imagePaths.icAiEmojify;
|
||||
case AIScribeMenuAction.improveTransformToBullets:
|
||||
return imagePaths.icAiBullets;
|
||||
case AIScribeMenuAction.changeToneProfessional:
|
||||
return imagePaths.icAiMoreProfessional;
|
||||
case AIScribeMenuAction.changeToneCasual:
|
||||
return imagePaths.icAiMoreCasual;
|
||||
case AIScribeMenuAction.changeTonePolite:
|
||||
return imagePaths.icAiMorePolite;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AIScribeMenuCategory {
|
||||
correctGrammar,
|
||||
improve,
|
||||
translate,
|
||||
changeTone,
|
||||
translate;
|
||||
improve;
|
||||
|
||||
String getLabel(BuildContext context) {
|
||||
final localizations = ScribeLocalizations.of(context)!;
|
||||
String getLabel(ScribeLocalizations localizations) {
|
||||
switch (this) {
|
||||
case AIScribeMenuCategory.correctGrammar:
|
||||
return localizations.categoryCorrectGrammar;
|
||||
@@ -96,6 +115,19 @@ enum AIScribeMenuCategory {
|
||||
}
|
||||
}
|
||||
|
||||
String getIcon(ImagePaths imagePaths) {
|
||||
switch (this) {
|
||||
case AIScribeMenuCategory.correctGrammar:
|
||||
return imagePaths.icAiGrammar;
|
||||
case AIScribeMenuCategory.improve:
|
||||
return imagePaths.icAiImprove;
|
||||
case AIScribeMenuCategory.changeTone:
|
||||
return imagePaths.icAiChangeTons;
|
||||
case AIScribeMenuCategory.translate:
|
||||
return imagePaths.icAiTranslate;
|
||||
}
|
||||
}
|
||||
|
||||
List<AIScribeMenuAction> get actions {
|
||||
switch (this) {
|
||||
case AIScribeMenuCategory.correctGrammar:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
|
||||
|
||||
class AiScribeActionContextMenuAction
|
||||
extends AiScribeContextMenuAction<AIScribeMenuAction> {
|
||||
AiScribeActionContextMenuAction(
|
||||
super.action,
|
||||
this.localizations,
|
||||
this.imagePaths,
|
||||
);
|
||||
|
||||
final ScribeLocalizations localizations;
|
||||
final ImagePaths imagePaths;
|
||||
|
||||
@override
|
||||
String get actionName => action.getLabel(localizations);
|
||||
|
||||
@override
|
||||
String? get actionIcon => action.getIcon(imagePaths);
|
||||
|
||||
@override
|
||||
bool get hasSubmenu => false;
|
||||
|
||||
@override
|
||||
List<AiScribeContextMenuAction>? get submenuActions => null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
|
||||
|
||||
class AiScribeCategoryContextMenuAction
|
||||
extends AiScribeContextMenuAction<AIScribeMenuCategory> {
|
||||
AiScribeCategoryContextMenuAction(
|
||||
super.action,
|
||||
this.localizations,
|
||||
this.imagePaths,
|
||||
);
|
||||
|
||||
final ScribeLocalizations localizations;
|
||||
final ImagePaths imagePaths;
|
||||
|
||||
@override
|
||||
String get actionName => action.getLabel(localizations);
|
||||
|
||||
@override
|
||||
String? get actionIcon => action.getIcon(imagePaths);
|
||||
|
||||
@override
|
||||
bool get hasSubmenu => action.hasSubmenu;
|
||||
|
||||
@override
|
||||
List<AiScribeContextMenuAction>? get submenuActions => action.actions
|
||||
.map((action) => AiScribeActionContextMenuAction(
|
||||
action,
|
||||
localizations,
|
||||
imagePaths,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
abstract class AiScribeContextMenuAction<T> {
|
||||
final T action;
|
||||
|
||||
AiScribeContextMenuAction(this.action);
|
||||
|
||||
String get actionName;
|
||||
|
||||
String? get actionIcon;
|
||||
|
||||
bool get hasSubmenu;
|
||||
|
||||
List<AiScribeContextMenuAction>? get submenuActions;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/animation.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
|
||||
class AnchoredModalLayoutInput {
|
||||
final Size screenSize;
|
||||
final Offset anchorPosition;
|
||||
final Size anchorSize;
|
||||
final Size menuSize;
|
||||
final ModalPlacement? preferredPlacement;
|
||||
final ModalCrossAxisAlignment crossAxisAlignment;
|
||||
|
||||
const AnchoredModalLayoutInput({
|
||||
required this.screenSize,
|
||||
required this.anchorPosition,
|
||||
required this.anchorSize,
|
||||
required this.menuSize,
|
||||
this.preferredPlacement,
|
||||
this.crossAxisAlignment = ModalCrossAxisAlignment.center,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/animation.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
|
||||
class AnchoredModalLayoutResult {
|
||||
final Offset position;
|
||||
final ModalPlacement placement;
|
||||
|
||||
const AnchoredModalLayoutResult({
|
||||
required this.position,
|
||||
required this.placement,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
enum ModalCrossAxisAlignment {
|
||||
start,
|
||||
center,
|
||||
end,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
enum ModalPlacement {
|
||||
right,
|
||||
bottom,
|
||||
top,
|
||||
left
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
class TextSelectionModel {
|
||||
final String? selectedText;
|
||||
final Offset? coordinates;
|
||||
|
||||
const TextSelectionModel({
|
||||
this.selectedText,
|
||||
this.coordinates,
|
||||
});
|
||||
|
||||
bool get hasSelection => selectedText != null && selectedText!.isNotEmpty;
|
||||
}
|
||||
@@ -1,82 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:core/presentation/utils/theme_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AIScribeColors {
|
||||
static const textPrimary = AppColor.textPrimary;
|
||||
static const background = Colors.white;
|
||||
abstract final class AIScribeColors {
|
||||
// Backgrounds
|
||||
static const Color background = Colors.white;
|
||||
static const Color mainActionButtonBackground = Color(0xFFD2E9FF);
|
||||
static const Color sendPromptBackground = AppColor.blue700;
|
||||
static const Color sendPromptBackgroundDisabled = Color(0xFFD2E9FF);
|
||||
|
||||
// Icons
|
||||
static const Color scribeIcon = AppColor.primaryMain;
|
||||
static const Color aiAssistantIcon = AppColor.primaryMain;
|
||||
|
||||
// Overlays
|
||||
static final Color dialogBarrier = Colors.black.withValues(alpha: 0.12);
|
||||
}
|
||||
|
||||
class AIScribeShadows {
|
||||
static List<BoxShadow> get elevation8 => [
|
||||
abstract final class AIScribeShadows {
|
||||
static final List<BoxShadow> sparkleIcon = [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.16),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
color: AppColor.gray424244.withValues(alpha: 0.08),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1.5),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
];
|
||||
|
||||
static final List<BoxShadow> modal = [
|
||||
BoxShadow(
|
||||
color: AppColor.gray424244.withValues(alpha: 0.12),
|
||||
spreadRadius: 0.5,
|
||||
),
|
||||
BoxShadow(
|
||||
color: AppColor.gray424244.withValues(alpha: 0.11),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 26,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
class AIScribeTextStyles {
|
||||
static TextStyle menuItem = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary);
|
||||
static TextStyle menuHint = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary.withValues(alpha: 0.6));
|
||||
static TextStyle suggestionTitle = ThemeUtils.textStyleInter700();
|
||||
static TextStyle suggestionContent = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary);
|
||||
abstract final class AIScribeTextStyles {
|
||||
static final TextStyle menuItem = ThemeUtils.textStyleInter400.copyWith(
|
||||
fontSize: 14,
|
||||
height: 21.01 / 14,
|
||||
letterSpacing: -0.15,
|
||||
color: AppColor.gray424244.withValues(alpha: 0.9),
|
||||
);
|
||||
|
||||
static final TextStyle searchBarHint =
|
||||
ThemeUtils.textStyleInter500().copyWith(
|
||||
fontSize: 14,
|
||||
height: 22 / 14,
|
||||
letterSpacing: 0.4,
|
||||
color: AppColor.gray9B9B9B.withValues(alpha: 0.85),
|
||||
);
|
||||
|
||||
static final TextStyle searchBar = ThemeUtils.textStyleInter400.copyWith(
|
||||
fontSize: 14,
|
||||
height: 24 / 14,
|
||||
letterSpacing: 0.4,
|
||||
color: Colors.black.withValues(alpha: 0.85),
|
||||
);
|
||||
|
||||
static final TextStyle suggestionTitle = ThemeUtils.textStyleInter700().copyWith(
|
||||
fontSize: 14,
|
||||
height: 22 / 14,
|
||||
letterSpacing: 0.4,
|
||||
color: AppColor.black1A1A1A.withValues(alpha: 0.85),
|
||||
);
|
||||
|
||||
static final TextStyle suggestionLoading = ThemeUtils.textStyleInter400.copyWith(
|
||||
fontSize: 14,
|
||||
height: 22 / 14,
|
||||
letterSpacing: 0.4,
|
||||
color: AppColor.black1A1A1A.withValues(alpha: 0.85),
|
||||
);
|
||||
|
||||
static final TextStyle suggestionContent = ThemeUtils.textStyleInter400.copyWith(
|
||||
fontSize: 14,
|
||||
height: 22 / 14,
|
||||
letterSpacing: 0.4,
|
||||
color: Colors.black.withValues(alpha: 0.85),
|
||||
);
|
||||
|
||||
static final TextStyle mainActionButton =
|
||||
ThemeUtils.textStyleInter500().copyWith(
|
||||
color: AppColor.blue700,
|
||||
);
|
||||
}
|
||||
|
||||
class AIScribeButtonStyles {
|
||||
static TextStyle mainActionButtonText = ThemeUtils.textStyleInter500().copyWith(color: AppColor.blue700);
|
||||
static const Color mainActionButtonBackgroundColor = Color(0xFFD2E9FF);
|
||||
static const EdgeInsetsGeometry mainActionButtonPadding = EdgeInsetsDirectional.symmetric(vertical: 8, horizontal: 16);
|
||||
|
||||
static const Color sendCustomPromptBackgroundColor = AppColor.blue700;
|
||||
static const Color sendCustomPromptBackgroundColorDisabled = Color(0xFFD2E9FF);
|
||||
}
|
||||
|
||||
class AIScribeSizes {
|
||||
abstract final class AIScribeSizes {
|
||||
// Border radius
|
||||
static const double menuBorderRadius = 12.0;
|
||||
static const double menuItemBorderRadius = 6.0;
|
||||
static const double scribeButtonBorderRadius = 100.0;
|
||||
static const double menuRadius = 6;
|
||||
static const double menuItemRadius = 6;
|
||||
static const double searchBarRadius = 10;
|
||||
static const double scribeButtonRadius = 100;
|
||||
static const double aiAssistantIconRadius = 8;
|
||||
|
||||
// Dialog dimensions
|
||||
static const double menuWidth = 200.0;
|
||||
static const double barWidth = 440.0;
|
||||
static const double modalMaxHeight = 400.0;
|
||||
static const double modalMaxWidthLargeScreen = 500.0;
|
||||
static const double mobileWidthPercentage = 0.9;
|
||||
static const double mobileBreakpoint = 600.0;
|
||||
static const double infoHeight = 120.0;
|
||||
// Width / height
|
||||
static const double menuItemHeight = 40;
|
||||
static const double searchBarMinHeight = 48;
|
||||
static const double searchBarMaxHeight = 100;
|
||||
static const double searchBarWidth = 405;
|
||||
|
||||
// Heights
|
||||
static const double menuItemHeight = 40.0;
|
||||
static const double barHeight = 48.0;
|
||||
static const double submenuMaxHeight = 300.0;
|
||||
static const double submenuWidth = 191;
|
||||
static const double submenuMaxHeight = 352;
|
||||
static const double contextMenuWidth = 191;
|
||||
static const double contextMenuHeight = 352;
|
||||
|
||||
static const double modalMaxHeight = 256;
|
||||
static const double modalMaxWidth = 405;
|
||||
static const double suggestionModalMaxWidth = 482;
|
||||
static const double suggestionModalMinHeight = 96;
|
||||
static const double suggestionModalMaxHeight = 587;
|
||||
|
||||
static const double infoHeight = 120;
|
||||
|
||||
// Breakpoints
|
||||
static const double mobileBreakpoint = 600;
|
||||
static const double mobileFactor = 0.9;
|
||||
|
||||
// Spacing
|
||||
static const double screenEdgePadding = 16.0;
|
||||
static const double submenuSpacing = 6.0;
|
||||
static const double fieldSpacing = 8.0;
|
||||
static const double screenEdgePadding = 16;
|
||||
static const double fieldSpacing = 8;
|
||||
static const double submenuSpacing = 6;
|
||||
static const double modalSpacing = 26;
|
||||
static const double modalWithoutContentSpacing = 12;
|
||||
|
||||
// Elevation
|
||||
static const double dialogElevation = 8.0;
|
||||
static const double dialogElevation = 8;
|
||||
|
||||
// Icon sizes
|
||||
static const double iconSize = 18.0;
|
||||
static const double sendIconSize = 16.0;
|
||||
static const double scribeIconSize = 12.0;
|
||||
static const double icon = 18;
|
||||
static const double sendIcon = 16;
|
||||
static const double scribeIcon = 12;
|
||||
static const double aiAssistantIcon = 24;
|
||||
|
||||
// Padding (using EdgeInsets)
|
||||
static const EdgeInsetsGeometry menuItemPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 10);
|
||||
static const EdgeInsetsGeometry barPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
|
||||
static const EdgeInsetsGeometry suggestionContentPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
|
||||
static const EdgeInsetsGeometry suggestionInfoPadding = EdgeInsets.only(bottom: 16);
|
||||
static const EdgeInsetsGeometry suggestionHeaderPadding = EdgeInsets.fromLTRB(16, 8, 8, 8);
|
||||
static const EdgeInsetsGeometry suggestionFooterPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 16);
|
||||
static const EdgeInsetsGeometry scribeButtonPadding = EdgeInsets.all(6);
|
||||
// Padding
|
||||
static const EdgeInsetsGeometry menuItemPadding =
|
||||
EdgeInsetsDirectional.only(start: 16, end: 10);
|
||||
|
||||
static const EdgeInsetsGeometry menuCategoryItemPadding =
|
||||
EdgeInsetsDirectional.symmetric(horizontal: 14);
|
||||
|
||||
static const EdgeInsetsGeometry searchBarPadding =
|
||||
EdgeInsetsDirectional.symmetric(horizontal: 16);
|
||||
|
||||
static const EdgeInsetsGeometry suggestionContentPadding =
|
||||
EdgeInsetsDirectional.all(16);
|
||||
|
||||
static const EdgeInsetsGeometry suggestionInfoPadding =
|
||||
EdgeInsetsGeometry.only(bottom: 16);
|
||||
|
||||
static const EdgeInsetsGeometry suggestionHeaderPadding =
|
||||
EdgeInsetsGeometry.fromLTRB(16, 8, 8, 8);
|
||||
|
||||
static const EdgeInsetsGeometry suggestionFooterPadding =
|
||||
EdgeInsetsGeometry.symmetric(horizontal: 16, vertical: 16);
|
||||
|
||||
static const EdgeInsetsGeometry scribeButtonPadding =
|
||||
EdgeInsetsGeometry.all(6);
|
||||
|
||||
static const EdgeInsetsGeometry mainActionButtonPadding =
|
||||
EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
|
||||
|
||||
static const EdgeInsetsGeometry contextMenuPadding =
|
||||
EdgeInsetsDirectional.symmetric(vertical: 8);
|
||||
|
||||
static const EdgeInsetsGeometry aiAssistantIconPadding =
|
||||
EdgeInsetsDirectional.all(5);
|
||||
|
||||
static const EdgeInsetsGeometry sendIconPadding =
|
||||
EdgeInsetsDirectional.all(8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
typedef OnHoverShowSubmenu = void Function(GlobalKey key);
|
||||
|
||||
class HoverSubmenuController {
|
||||
HoverSubmenuController({
|
||||
this.exitDelay = const Duration(milliseconds: 120),
|
||||
});
|
||||
|
||||
final Duration exitDelay;
|
||||
|
||||
final ValueNotifier<bool> isHovering = ValueNotifier(false);
|
||||
|
||||
int _hoverRefCount = 0;
|
||||
Timer? _exitTimer;
|
||||
|
||||
void enter() {
|
||||
_exitTimer?.cancel();
|
||||
_hoverRefCount++;
|
||||
if (!isHovering.value) {
|
||||
isHovering.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void exit() {
|
||||
_hoverRefCount = (_hoverRefCount - 1).clamp(0, 999);
|
||||
|
||||
if (_hoverRefCount == 0) {
|
||||
_exitTimer?.cancel();
|
||||
_exitTimer = Timer(exitDelay, () {
|
||||
if (_hoverRefCount == 0) {
|
||||
isHovering.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_exitTimer?.cancel();
|
||||
isHovering.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum SubmenuDirection { left, right, auto }
|
||||
|
||||
class PopupSubmenuController {
|
||||
OverlayEntry? _submenuEntry;
|
||||
|
||||
bool get isShowing => _submenuEntry != null;
|
||||
|
||||
void show({
|
||||
required BuildContext context,
|
||||
required Rect anchor,
|
||||
required Rect anchorMenu,
|
||||
required Widget submenu,
|
||||
SubmenuDirection direction = SubmenuDirection.auto,
|
||||
double submenuWidth = 191,
|
||||
double submenuMaxHeight = 352,
|
||||
double offset = 4,
|
||||
double menuFieldSpacing = 8,
|
||||
}) {
|
||||
hide();
|
||||
|
||||
final overlayState = Overlay.maybeOf(context, rootOverlay: true);
|
||||
if (overlayState == null) return;
|
||||
|
||||
final mediaSize = MediaQuery.sizeOf(context);
|
||||
final screenWidth = mediaSize.width;
|
||||
final screenHeight = mediaSize.height;
|
||||
|
||||
final rightPosition = anchor.right + offset;
|
||||
final leftPosition = anchor.left - submenuWidth - offset;
|
||||
|
||||
bool canShowRight = rightPosition + submenuWidth <= screenWidth;
|
||||
bool canShowLeft = leftPosition >= 0;
|
||||
|
||||
double? finalLeft;
|
||||
|
||||
if (direction == SubmenuDirection.right) {
|
||||
finalLeft = rightPosition;
|
||||
} else if (direction == SubmenuDirection.left) {
|
||||
finalLeft = leftPosition;
|
||||
} else {
|
||||
if (canShowRight) {
|
||||
finalLeft = rightPosition;
|
||||
} else if (canShowLeft) {
|
||||
finalLeft = leftPosition;
|
||||
} else {
|
||||
finalLeft = rightPosition;
|
||||
}
|
||||
}
|
||||
|
||||
final bottom = screenHeight - anchorMenu.bottom + menuFieldSpacing;
|
||||
|
||||
final clampedLeft = finalLeft
|
||||
.clamp(0.0, math.max(0.0, screenWidth - submenuWidth))
|
||||
.toDouble();
|
||||
final availableHeight = math.max(0.0, screenHeight - anchor.top);
|
||||
final finalHeight = math.min(submenuMaxHeight, availableHeight);
|
||||
|
||||
_submenuEntry = OverlayEntry(
|
||||
builder: (_) {
|
||||
return PositionedDirectional(
|
||||
start: clampedLeft,
|
||||
bottom: bottom,
|
||||
child: MouseRegion(
|
||||
onExit: (_) => hide(),
|
||||
child: Container(
|
||||
width: submenuWidth,
|
||||
constraints: BoxConstraints(maxHeight: finalHeight),
|
||||
child: submenu,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlayState.insert(_submenuEntry!);
|
||||
}
|
||||
|
||||
void hide() {
|
||||
_submenuEntry?.remove();
|
||||
_submenuEntry = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_modal_widget.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart';
|
||||
|
||||
class AiScribeModalManager {
|
||||
AiScribeModalManager._();
|
||||
|
||||
static Future<String?> showAIScribeMenuModal({
|
||||
required ImagePaths imagePaths,
|
||||
required List<AIScribeMenuCategory> availableCategories,
|
||||
String? content,
|
||||
Offset? buttonPosition,
|
||||
Size? buttonSize,
|
||||
ModalPlacement? preferredPlacement,
|
||||
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
|
||||
}) async {
|
||||
final PopupSubmenuController submenuController = PopupSubmenuController();
|
||||
|
||||
final aiAction = await Get.dialog<AIAction>(
|
||||
AiScribeModalWidget(
|
||||
imagePaths: imagePaths,
|
||||
content: content,
|
||||
availableCategories: availableCategories,
|
||||
buttonPosition: buttonPosition,
|
||||
buttonSize: buttonSize,
|
||||
preferredPlacement: preferredPlacement,
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
submenuController: submenuController,
|
||||
),
|
||||
barrierColor: AIScribeColors.dialogBarrier,
|
||||
).whenComplete(submenuController.hide);
|
||||
|
||||
if (aiAction != null) {
|
||||
return await showAIScribeSuggestionModal(
|
||||
aiAction: aiAction,
|
||||
imagePaths: imagePaths,
|
||||
content: content,
|
||||
buttonPosition: buttonPosition,
|
||||
buttonSize: buttonSize,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> showAIScribeSuggestionModal({
|
||||
required AIAction aiAction,
|
||||
required ImagePaths imagePaths,
|
||||
String? content,
|
||||
Offset? buttonPosition,
|
||||
Size? buttonSize,
|
||||
}) {
|
||||
return Get.dialog<String?>(
|
||||
AiScribeSuggestionWidget(
|
||||
aiAction: aiAction,
|
||||
imagePaths: imagePaths,
|
||||
content: content,
|
||||
buttonPosition: buttonPosition,
|
||||
buttonSize: buttonSize,
|
||||
),
|
||||
barrierColor: AIScribeColors.dialogBarrier,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/animation.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
|
||||
class AnchoredModalLayoutCalculator {
|
||||
static AnchoredModalLayoutResult calculate({
|
||||
required AnchoredModalLayoutInput input,
|
||||
double padding = 8,
|
||||
double gap = 8,
|
||||
double verticalOffset = 6,
|
||||
}) {
|
||||
final screen = input.screenSize;
|
||||
final anchor = input.anchorPosition;
|
||||
final size = input.anchorSize;
|
||||
final menu = input.menuSize;
|
||||
|
||||
final fallbackOrder = [
|
||||
ModalPlacement.right,
|
||||
ModalPlacement.bottom,
|
||||
ModalPlacement.top,
|
||||
ModalPlacement.left,
|
||||
];
|
||||
|
||||
late ModalPlacement placement;
|
||||
|
||||
if (input.preferredPlacement != null &&
|
||||
_canPlace(
|
||||
placement: input.preferredPlacement!,
|
||||
screen: screen,
|
||||
anchor: anchor,
|
||||
anchorSize: size,
|
||||
menu: menu,
|
||||
gap: gap,
|
||||
)) {
|
||||
placement = input.preferredPlacement!;
|
||||
} else {
|
||||
placement = fallbackOrder.firstWhere(
|
||||
(p) => _canPlace(
|
||||
placement: p,
|
||||
screen: screen,
|
||||
anchor: anchor,
|
||||
anchorSize: size,
|
||||
menu: menu,
|
||||
gap: gap,
|
||||
),
|
||||
orElse: () => ModalPlacement.left,
|
||||
);
|
||||
}
|
||||
|
||||
late double left;
|
||||
late double top;
|
||||
|
||||
switch (placement) {
|
||||
case ModalPlacement.right:
|
||||
left = anchor.dx + size.width + gap;
|
||||
top = _resolveAlignedPosition(
|
||||
alignment: input.crossAxisAlignment,
|
||||
anchorStart: anchor.dy,
|
||||
anchorSize: size.height,
|
||||
menuSize: menu.height,
|
||||
) +
|
||||
verticalOffset;
|
||||
break;
|
||||
|
||||
case ModalPlacement.bottom:
|
||||
left = _resolveAlignedPosition(
|
||||
alignment: input.crossAxisAlignment,
|
||||
anchorStart: anchor.dx,
|
||||
anchorSize: size.width,
|
||||
menuSize: menu.width,
|
||||
);
|
||||
top = anchor.dy + size.height + gap + verticalOffset;
|
||||
break;
|
||||
|
||||
case ModalPlacement.top:
|
||||
left = _resolveAlignedPosition(
|
||||
alignment: input.crossAxisAlignment,
|
||||
anchorStart: anchor.dx,
|
||||
anchorSize: size.width,
|
||||
menuSize: menu.width,
|
||||
);
|
||||
top = anchor.dy - menu.height - gap + verticalOffset;
|
||||
break;
|
||||
case ModalPlacement.left:
|
||||
left = anchor.dx - menu.width - gap;
|
||||
top = _resolveAlignedPosition(
|
||||
alignment: input.crossAxisAlignment,
|
||||
anchorStart: anchor.dy,
|
||||
anchorSize: size.height,
|
||||
menuSize: menu.height,
|
||||
) +
|
||||
verticalOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
left = left.clamp(padding, screen.width - menu.width - padding);
|
||||
top = top.clamp(padding, screen.height - menu.height - padding);
|
||||
|
||||
return AnchoredModalLayoutResult(
|
||||
position: Offset(left, top),
|
||||
placement: placement,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _canPlace({
|
||||
required ModalPlacement placement,
|
||||
required Size screen,
|
||||
required Offset anchor,
|
||||
required Size anchorSize,
|
||||
required Size menu,
|
||||
required double gap,
|
||||
}) {
|
||||
switch (placement) {
|
||||
case ModalPlacement.right:
|
||||
return screen.width - (anchor.dx + anchorSize.width) >=
|
||||
menu.width + gap;
|
||||
case ModalPlacement.left:
|
||||
return anchor.dx >= menu.width + gap;
|
||||
case ModalPlacement.bottom:
|
||||
return screen.height - (anchor.dy + anchorSize.height) >=
|
||||
menu.height + gap;
|
||||
case ModalPlacement.top:
|
||||
return anchor.dy >= menu.height + gap;
|
||||
}
|
||||
}
|
||||
|
||||
static double _resolveAlignedPosition({
|
||||
required ModalCrossAxisAlignment alignment,
|
||||
required double anchorStart,
|
||||
required double anchorSize,
|
||||
required double menuSize,
|
||||
}) {
|
||||
switch (alignment) {
|
||||
case ModalCrossAxisAlignment.start:
|
||||
return anchorStart;
|
||||
case ModalCrossAxisAlignment.center:
|
||||
return anchorStart + anchorSize / 2 - menuSize / 2;
|
||||
case ModalCrossAxisAlignment.end:
|
||||
return anchorStart + anchorSize - menuSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:scribe/scribe/ai/domain/state/generate_ai_text_state.dart';
|
||||
import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_bar.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_menu.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart';
|
||||
|
||||
typedef AIScribeResultCallback = void Function(String result);
|
||||
|
||||
Future<void> showAIScribeDialog({
|
||||
required BuildContext context,
|
||||
required ImagePaths imagePaths,
|
||||
required String content,
|
||||
required AIScribeResultCallback onInsertText,
|
||||
required GenerateAITextInteractor interactor,
|
||||
List<AIScribeMenuCategory>? availableCategories,
|
||||
Offset? buttonPosition,
|
||||
}) async {
|
||||
final hasContent = content.isNotEmpty;
|
||||
|
||||
final selectedAction = await showDialog<AIAction>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) {
|
||||
final dialogContent = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasContent) ...[
|
||||
Material(
|
||||
color: Colors.white,
|
||||
elevation: AIScribeSizes.dialogElevation,
|
||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
|
||||
child: SizedBox(
|
||||
width: AIScribeSizes.menuWidth,
|
||||
child: AIScribeMenu(
|
||||
onActionSelected: (action) {
|
||||
Navigator.of(context).pop(PredefinedAction(action));
|
||||
},
|
||||
availableCategories: availableCategories,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AIScribeSizes.fieldSpacing),
|
||||
],
|
||||
Material(
|
||||
color: Colors.white,
|
||||
elevation: AIScribeSizes.dialogElevation,
|
||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
|
||||
child: SizedBox(
|
||||
width: AIScribeSizes.barWidth,
|
||||
child: AIScribeBar(
|
||||
onCustomPrompt: (customPrompt) {
|
||||
Navigator.of(context).pop(CustomPromptAction(customPrompt));
|
||||
},
|
||||
imagePaths: imagePaths
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (buttonPosition != null) {
|
||||
final categories = availableCategories ?? AIScribeMenuCategory.values;
|
||||
final modalHeight = hasContent
|
||||
? (categories.length * AIScribeSizes.menuItemHeight) + AIScribeSizes.fieldSpacing + AIScribeSizes.barHeight
|
||||
: AIScribeSizes.barHeight;
|
||||
|
||||
final position = calculateModalPosition(
|
||||
context: context,
|
||||
buttonPosition: buttonPosition,
|
||||
modalWidth: AIScribeSizes.barWidth,
|
||||
modalHeight: modalHeight,
|
||||
);
|
||||
|
||||
return PointerInterceptor(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: position.left,
|
||||
bottom: position.bottom,
|
||||
child: dialogContent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(child: dialogContent);
|
||||
},
|
||||
);
|
||||
|
||||
if (selectedAction == null) {
|
||||
log('showAIScribeDialog: No action selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) {
|
||||
final suggestionFuture = _executeAIRequest(interactor, selectedAction, content);
|
||||
final title = selectedAction.getLabel(context);
|
||||
|
||||
final modalContent = AIScribeSuggestion(
|
||||
title: title,
|
||||
suggestionFuture: suggestionFuture,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
onInsert: (result) {
|
||||
onInsertText(result);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
imagePaths: imagePaths,
|
||||
);
|
||||
|
||||
if (buttonPosition != null) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final modalWidth = screenSize.width < AIScribeSizes.mobileBreakpoint
|
||||
? screenSize.width * AIScribeSizes.mobileWidthPercentage
|
||||
: AIScribeSizes.modalMaxWidthLargeScreen;
|
||||
|
||||
final position = calculateModalPosition(
|
||||
context: context,
|
||||
buttonPosition: buttonPosition,
|
||||
modalWidth: modalWidth,
|
||||
modalHeight: AIScribeSizes.modalMaxHeight,
|
||||
);
|
||||
|
||||
return PointerInterceptor(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: position.left,
|
||||
bottom: position.bottom,
|
||||
child: modalContent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(child: modalContent);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
double calculateMenuDialogHeight({
|
||||
required bool hasContent,
|
||||
required int categoryCount,
|
||||
}) {
|
||||
return hasContent
|
||||
? (categoryCount * AIScribeSizes.menuItemHeight) + AIScribeSizes.fieldSpacing + AIScribeSizes.barHeight
|
||||
: AIScribeSizes.barHeight;
|
||||
}
|
||||
|
||||
({double left, double bottom}) calculateModalPosition({
|
||||
required BuildContext context,
|
||||
required Offset buttonPosition,
|
||||
required double modalWidth,
|
||||
double? modalHeight,
|
||||
}) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
// Calculate position above the button
|
||||
double left = buttonPosition.dx;
|
||||
double bottom = screenSize.height - buttonPosition.dy + AIScribeSizes.fieldSpacing;
|
||||
|
||||
// Ensure modal doesn't go off screen horizontally
|
||||
if (left + modalWidth > screenSize.width) {
|
||||
left = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding;
|
||||
}
|
||||
if (left < AIScribeSizes.screenEdgePadding) {
|
||||
left = AIScribeSizes.screenEdgePadding;
|
||||
}
|
||||
|
||||
// Ensure modal doesn't go off screen vertically (if height is provided)
|
||||
if (modalHeight != null) {
|
||||
if (bottom + modalHeight > screenSize.height) {
|
||||
bottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding;
|
||||
}
|
||||
if (bottom < AIScribeSizes.screenEdgePadding) {
|
||||
bottom = AIScribeSizes.screenEdgePadding;
|
||||
}
|
||||
}
|
||||
|
||||
return (left: left, bottom: bottom);
|
||||
}
|
||||
|
||||
Future<String> _executeAIRequest(
|
||||
GenerateAITextInteractor interactor,
|
||||
AIAction action,
|
||||
String content,
|
||||
) async {
|
||||
final result = await interactor.execute(action, content);
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
throw Exception('Failed to generate AI response: $failure');
|
||||
},
|
||||
(success) {
|
||||
if (success is GenerateAITextSuccess) {
|
||||
return success.response.result;
|
||||
} else {
|
||||
throw Exception('Unexpected success state: $success');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
class AIScribeButton extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const AIScribeButton({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TMailButtonWidget.fromIcon(
|
||||
icon: imagePaths.icSparkle,
|
||||
padding: AIScribeSizes.scribeButtonPadding,
|
||||
backgroundColor: Colors.white,
|
||||
iconSize: AIScribeSizes.scribeIconSize,
|
||||
borderRadius: AIScribeSizes.scribeButtonBorderRadius,
|
||||
onTapActionCallback: onTap,
|
||||
boxShadow: AIScribeShadows.elevation8,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
class AIScribeMenu extends StatefulWidget {
|
||||
final Function(AIScribeMenuAction) onActionSelected;
|
||||
final bool useSubmenuItemStyle;
|
||||
final List<AIScribeMenuCategory>? availableCategories;
|
||||
|
||||
const AIScribeMenu({
|
||||
super.key,
|
||||
required this.onActionSelected,
|
||||
this.useSubmenuItemStyle = true,
|
||||
this.availableCategories,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AIScribeMenu> createState() => _AIScribeMenuContentState();
|
||||
}
|
||||
|
||||
class _AIScribeMenuContentState extends State<AIScribeMenu> {
|
||||
AIScribeMenuCategory? _hoveredCategory;
|
||||
final GlobalKey _menuKey = GlobalKey();
|
||||
OverlayEntry? _submenuOverlay;
|
||||
Timer? _closeTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_closeTimer?.cancel();
|
||||
_removeSubmenu();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _removeSubmenu() {
|
||||
_submenuOverlay?.remove();
|
||||
_submenuOverlay = null;
|
||||
}
|
||||
|
||||
void _showSubmenu(AIScribeMenuCategory category) {
|
||||
if (!category.hasSubmenu) return;
|
||||
|
||||
_removeSubmenu();
|
||||
|
||||
// Get the entire menu's bounds
|
||||
final menuRenderBox = _menuKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if(menuRenderBox == null) return;
|
||||
|
||||
final menuPosition = menuRenderBox.localToGlobal(Offset.zero);
|
||||
final menuSize = menuRenderBox.size;
|
||||
|
||||
_submenuOverlay = OverlayEntry(
|
||||
builder: (context) => _SubmenuPanel(
|
||||
category: category,
|
||||
position: menuPosition,
|
||||
parentSize: menuSize,
|
||||
onActionSelected: (action) {
|
||||
_closeTimer?.cancel();
|
||||
_removeSubmenu();
|
||||
widget.onActionSelected(action);
|
||||
},
|
||||
onHover: _cancelClose,
|
||||
onDismiss: () {
|
||||
setState(() {
|
||||
_hoveredCategory = null;
|
||||
});
|
||||
_removeSubmenu();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_submenuOverlay!);
|
||||
}
|
||||
|
||||
void _handleCategoryHover(AIScribeMenuCategory category, bool isHovering) {
|
||||
if (isHovering) {
|
||||
_closeTimer?.cancel();
|
||||
_closeTimer = null;
|
||||
|
||||
setState(() {
|
||||
_hoveredCategory = category;
|
||||
_showSubmenu(category);
|
||||
});
|
||||
} else {
|
||||
// Delay closing to allow mouse to reach submenu
|
||||
_closeTimer?.cancel();
|
||||
_closeTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
if (mounted && _hoveredCategory == category) {
|
||||
setState(() {
|
||||
_hoveredCategory = null;
|
||||
_removeSubmenu();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelClose() {
|
||||
_closeTimer?.cancel();
|
||||
_closeTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categories = widget.availableCategories ?? AIScribeMenuCategory.values;
|
||||
return Column(
|
||||
key: _menuKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: categories.map(_buildCategoryItem).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryItem(AIScribeMenuCategory category) {
|
||||
if (category.hasSubmenu) {
|
||||
return Container(
|
||||
child: _buildMenuItem(
|
||||
label: category.getLabel(context),
|
||||
hasSubmenu: true,
|
||||
isHovered: _hoveredCategory == category,
|
||||
onHover: (isHovering) => _handleCategoryHover(category, isHovering),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// For categories without submenu (like Correct Grammar)
|
||||
return _buildMenuItem(
|
||||
label: category.getLabel(context),
|
||||
onTap: () {
|
||||
if (category.actions.isNotEmpty) {
|
||||
widget.onActionSelected(category.actions.first);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMenuItem({
|
||||
required String label,
|
||||
VoidCallback? onTap,
|
||||
void Function(bool)? onHover,
|
||||
bool hasSubmenu = false,
|
||||
bool isHovered = false
|
||||
}) {
|
||||
return SizedBox(
|
||||
height: AIScribeSizes.menuItemHeight,
|
||||
child: MouseRegion(
|
||||
onEnter: onHover != null ? (_) => onHover(true) : null,
|
||||
onExit: onHover != null ? (_) => onHover(false) : null,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
|
||||
child: Padding(
|
||||
padding: AIScribeSizes.menuItemPadding,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: AIScribeTextStyles.menuItem,
|
||||
),
|
||||
),
|
||||
if (hasSubmenu)
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: AIScribeSizes.iconSize,
|
||||
color: AIScribeColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submenu panel that appears to the right of the main menu
|
||||
class _SubmenuPanel extends StatefulWidget {
|
||||
final AIScribeMenuCategory category;
|
||||
final Offset position;
|
||||
final Size parentSize;
|
||||
final Function(AIScribeMenuAction) onActionSelected;
|
||||
final VoidCallback onHover;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const _SubmenuPanel({
|
||||
required this.category,
|
||||
required this.position,
|
||||
required this.parentSize,
|
||||
required this.onActionSelected,
|
||||
required this.onHover,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SubmenuPanel> createState() => _SubmenuPanelState();
|
||||
}
|
||||
|
||||
class _SubmenuPanelState extends State<_SubmenuPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final submenuHeight = widget.category.actions.length * AIScribeSizes.menuItemHeight;
|
||||
|
||||
// Position submenu to the right of the parent item
|
||||
final left = widget.position.dx + widget.parentSize.width + AIScribeSizes.submenuSpacing;
|
||||
final top = widget.position.dy + widget.parentSize.height - submenuHeight;
|
||||
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
// If we exceed screen to the right, we position left instead of right
|
||||
final adjustedLeft = (left + AIScribeSizes.menuWidth > screenSize.width)
|
||||
? widget.position.dx - AIScribeSizes.menuWidth - AIScribeSizes.submenuSpacing
|
||||
: left;
|
||||
|
||||
// If we exceed screen to the top, we position at top
|
||||
final adjustedTop = (top < 0.0)
|
||||
? 0.0
|
||||
: top;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: adjustedLeft,
|
||||
top: adjustedTop,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => widget.onHover(),
|
||||
onExit: (_) => widget.onDismiss(),
|
||||
child: PointerInterceptor(
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
elevation: AIScribeSizes.dialogElevation,
|
||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
|
||||
child: Container(
|
||||
width: AIScribeSizes.menuWidth,
|
||||
constraints: const BoxConstraints(maxHeight: AIScribeSizes.submenuMaxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: widget.category.actions.map((action) {
|
||||
return SizedBox(
|
||||
height: AIScribeSizes.menuItemHeight,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onActionSelected(action),
|
||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
|
||||
child: Padding(
|
||||
padding: AIScribeSizes.menuItemPadding,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
action.getLabel(context),
|
||||
style: AIScribeTextStyles.menuItem,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
typedef OnInsertTextCallback = void Function(String text);
|
||||
|
||||
class AIScribeSuggestion extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<String> suggestionFuture;
|
||||
final VoidCallback onClose;
|
||||
final OnInsertTextCallback onInsert;
|
||||
final ImagePaths imagePaths;
|
||||
|
||||
const AIScribeSuggestion({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.suggestionFuture,
|
||||
required this.onClose,
|
||||
required this.onInsert,
|
||||
required this.imagePaths,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AIScribeSuggestion> createState() => _AIScribeSuggestionModalState();
|
||||
}
|
||||
|
||||
class _AIScribeSuggestionModalState extends State<AIScribeSuggestion> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final modalWidth = screenWidth < AIScribeSizes.mobileBreakpoint
|
||||
? screenWidth * AIScribeSizes.mobileWidthPercentage
|
||||
: AIScribeSizes.modalMaxWidthLargeScreen;
|
||||
|
||||
return PointerInterceptor(
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
|
||||
child: Container(
|
||||
width: modalWidth,
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: AIScribeSizes.modalMaxHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AIScribeColors.background,
|
||||
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
|
||||
),
|
||||
child: FutureBuilder<String>(
|
||||
future: widget.suggestionFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return _buildLoadingState();
|
||||
} else if (snapshot.hasError) {
|
||||
return _buildErrorState();
|
||||
} else if (snapshot.hasData) {
|
||||
return _buildSuccessState(snapshot.data!);
|
||||
} else {
|
||||
return _buildErrorState();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Container(
|
||||
height: AIScribeSizes.infoHeight,
|
||||
padding: AIScribeSizes.suggestionInfoPadding,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuccessState(String suggestion) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Container(
|
||||
padding: AIScribeSizes.suggestionContentPadding,
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
suggestion,
|
||||
style: AIScribeTextStyles.suggestionContent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildFooter(suggestion),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Container(
|
||||
height: AIScribeSizes.infoHeight,
|
||||
padding: AIScribeSizes.suggestionInfoPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: AIScribeSizes.iconSize,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: AIScribeSizes.fieldSpacing),
|
||||
Text(
|
||||
ScribeLocalizations.of(context)!.failedToGenerate,
|
||||
style: AIScribeTextStyles.suggestionContent,
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: AIScribeSizes.suggestionHeaderPadding,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: AIScribeTextStyles.suggestionTitle
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: AIScribeSizes.iconSize,
|
||||
),
|
||||
iconSize: AIScribeSizes.iconSize,
|
||||
color: Colors.grey[600],
|
||||
onPressed: widget.onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(String suggestion) {
|
||||
return Container(
|
||||
padding: AIScribeSizes.suggestionFooterPadding,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.content_copy,
|
||||
size: AIScribeSizes.iconSize,
|
||||
),
|
||||
color: Colors.grey[600],
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: suggestion));
|
||||
},
|
||||
),
|
||||
TMailButtonWidget(
|
||||
text: ScribeLocalizations.of(context)!.insertButton,
|
||||
textStyle: AIScribeButtonStyles.mainActionButtonText,
|
||||
padding: AIScribeButtonStyles.mainActionButtonPadding,
|
||||
backgroundColor: AIScribeButtonStyles.mainActionButtonBackgroundColor,
|
||||
onTapActionCallback: () => widget.onInsert(suggestion),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
typedef OnOpenAiAssistantModal = void Function(
|
||||
Offset? position,
|
||||
Size? size,
|
||||
);
|
||||
|
||||
class AiAssistantButton extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
final Color? iconColor;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final OnOpenAiAssistantModal onOpenAiAssistantModal;
|
||||
|
||||
const AiAssistantButton({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
required this.onOpenAiAssistantModal,
|
||||
this.iconColor,
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TMailButtonWidget.fromIcon(
|
||||
icon: imagePaths.icGradientSparkle,
|
||||
padding: AIScribeSizes.aiAssistantIconPadding,
|
||||
backgroundColor: Colors.transparent,
|
||||
iconSize: AIScribeSizes.aiAssistantIcon,
|
||||
iconColor: iconColor,
|
||||
margin: margin,
|
||||
borderRadius: AIScribeSizes.aiAssistantIconRadius,
|
||||
tooltipMessage: ScribeLocalizations.of(context).aiAssistant,
|
||||
onTapActionCallback: () => _onTapActionCallback(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _onTapActionCallback(BuildContext context) {
|
||||
final renderBox = context.findRenderObject();
|
||||
|
||||
Offset? position;
|
||||
Size? size;
|
||||
|
||||
if (renderBox != null && renderBox is RenderBox) {
|
||||
position = renderBox.localToGlobal(Offset.zero);
|
||||
size = renderBox.size;
|
||||
}
|
||||
|
||||
onOpenAiAssistantModal(position, size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart';
|
||||
|
||||
class InlineAiAssistButton extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
final String? selectedText;
|
||||
final OnAiScribeResultCallback onAiScribeResultCallback;
|
||||
|
||||
const InlineAiAssistButton({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
required this.onAiScribeResultCallback,
|
||||
this.selectedText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TMailButtonWidget.fromIcon(
|
||||
icon: imagePaths.icSparkle,
|
||||
padding: AIScribeSizes.scribeButtonPadding,
|
||||
backgroundColor: AIScribeColors.background,
|
||||
iconSize: AIScribeSizes.scribeIcon,
|
||||
iconColor: AIScribeColors.scribeIcon,
|
||||
borderRadius: AIScribeSizes.scribeButtonRadius,
|
||||
boxShadow: AIScribeShadows.sparkleIcon,
|
||||
onTapActionCallback: () => _onTapActionCallback(context),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onTapActionCallback(BuildContext context) async {
|
||||
final renderBox = context.findRenderObject();
|
||||
|
||||
Offset? position;
|
||||
Size? size;
|
||||
|
||||
if (renderBox != null && renderBox is RenderBox) {
|
||||
position = renderBox.localToGlobal(Offset.zero);
|
||||
size = renderBox.size;
|
||||
}
|
||||
|
||||
final aiResult = await AiScribeModalManager.showAIScribeMenuModal(
|
||||
imagePaths: imagePaths,
|
||||
availableCategories: AIScribeMenuCategory.values,
|
||||
buttonPosition: position,
|
||||
content: selectedText,
|
||||
buttonSize: size,
|
||||
);
|
||||
|
||||
if (aiResult != null) {
|
||||
onAiScribeResultCallback(aiResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu_item.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu.dart';
|
||||
|
||||
class AiScribeContextMenu extends StatefulWidget {
|
||||
final ImagePaths imagePaths;
|
||||
final List<AiScribeContextMenuAction> menuActions;
|
||||
final ValueChanged<AiScribeContextMenuAction> onActionSelected;
|
||||
final PopupSubmenuController? submenuController;
|
||||
|
||||
const AiScribeContextMenu({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
required this.menuActions,
|
||||
required this.onActionSelected,
|
||||
this.submenuController,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AiScribeContextMenu> createState() =>
|
||||
_AiScribeContextMenuContentState();
|
||||
}
|
||||
|
||||
class _AiScribeContextMenuContentState extends State<AiScribeContextMenu> {
|
||||
final GlobalKey _contextMenuKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: _contextMenuKey,
|
||||
width: AIScribeSizes.contextMenuWidth,
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: AIScribeSizes.submenuMaxHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(AIScribeSizes.menuRadius),
|
||||
),
|
||||
color: AIScribeColors.background,
|
||||
boxShadow: AIScribeShadows.modal,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: AIScribeSizes.contextMenuPadding,
|
||||
itemCount: widget.menuActions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final menuAction = widget.menuActions[index];
|
||||
return AiScribeContextMenuItem(
|
||||
menuAction: menuAction,
|
||||
imagePaths: widget.imagePaths,
|
||||
onSelectAction: (menuAction) {
|
||||
widget.submenuController?.hide();
|
||||
widget.onActionSelected(menuAction);
|
||||
},
|
||||
onHoverShowSubmenu: (itemKey) =>
|
||||
menuAction.submenuActions?.isNotEmpty == true
|
||||
? _showSubmenu(
|
||||
context: context,
|
||||
itemKey: itemKey,
|
||||
contextMenuKey: _contextMenuKey,
|
||||
actions: menuAction.submenuActions!,
|
||||
)
|
||||
: null,
|
||||
onHoverOtherItem: widget.submenuController?.hide,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Rect? _generateRectByKey(GlobalKey key) {
|
||||
final renderObject = key.currentContext?.findRenderObject();
|
||||
if (renderObject is! RenderBox) return null;
|
||||
final renderBox = renderObject;
|
||||
|
||||
final offset = renderBox.localToGlobal(Offset.zero);
|
||||
return offset & renderBox.size;
|
||||
}
|
||||
|
||||
void _showSubmenu({
|
||||
required BuildContext context,
|
||||
required GlobalKey itemKey,
|
||||
required GlobalKey contextMenuKey,
|
||||
required List<AiScribeContextMenuAction> actions,
|
||||
}) {
|
||||
final itemRect = _generateRectByKey(itemKey);
|
||||
final contextMenuRect = _generateRectByKey(contextMenuKey);
|
||||
|
||||
if (itemRect == null || contextMenuRect == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
widget.submenuController?.show(
|
||||
context: context,
|
||||
anchor: itemRect,
|
||||
anchorMenu: contextMenuRect,
|
||||
submenuMaxHeight: AIScribeSizes.submenuMaxHeight,
|
||||
submenuWidth: AIScribeSizes.submenuWidth,
|
||||
menuFieldSpacing: AIScribeSizes.fieldSpacing,
|
||||
submenu: AiScribeSubmenu(
|
||||
menuActions: actions,
|
||||
onSelectAction: (action) {
|
||||
widget.submenuController?.hide();
|
||||
widget.onActionSelected(action);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.submenuController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/context_menu/hover_submenu_controller.dart';
|
||||
|
||||
class AiScribeContextMenuItem extends StatefulWidget {
|
||||
final AiScribeContextMenuAction menuAction;
|
||||
final ImagePaths imagePaths;
|
||||
final ValueChanged<AiScribeContextMenuAction> onSelectAction;
|
||||
final OnHoverShowSubmenu? onHoverShowSubmenu;
|
||||
final VoidCallback? onHoverOtherItem;
|
||||
|
||||
const AiScribeContextMenuItem({
|
||||
super.key,
|
||||
required this.menuAction,
|
||||
required this.imagePaths,
|
||||
required this.onSelectAction,
|
||||
this.onHoverShowSubmenu,
|
||||
this.onHoverOtherItem,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AiScribeContextMenuItem> createState() =>
|
||||
_AiScribeContextMenuItemState();
|
||||
}
|
||||
|
||||
class _AiScribeContextMenuItemState extends State<AiScribeContextMenuItem> {
|
||||
GlobalKey? _itemKey;
|
||||
HoverSubmenuController? _hoverController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.menuAction.hasSubmenu) {
|
||||
_itemKey = GlobalKey();
|
||||
_hoverController = HoverSubmenuController();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final childWidget = Container(
|
||||
key: _itemKey,
|
||||
height: AIScribeSizes.menuItemHeight,
|
||||
padding: AIScribeSizes.menuCategoryItemPadding,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.menuAction.actionIcon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: SvgPicture.asset(
|
||||
widget.menuAction.actionIcon!,
|
||||
width: 20,
|
||||
height: 20,
|
||||
fit: BoxFit.fill,
|
||||
colorFilter:
|
||||
AppColor.gray424244.withValues(alpha: 0.72).asFilter(),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.menuAction.actionName,
|
||||
style: AIScribeTextStyles.menuItem,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.menuAction.hasSubmenu)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 3),
|
||||
child: SvgPicture.asset(
|
||||
widget.imagePaths.icArrowRight,
|
||||
width: 16,
|
||||
height: 16,
|
||||
fit: BoxFit.fill,
|
||||
colorFilter: AppColor.gray777778.asFilter(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.menuAction.hasSubmenu) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) {
|
||||
_hoverController?.enter();
|
||||
|
||||
if (_itemKey != null) {
|
||||
widget.onHoverShowSubmenu?.call(_itemKey!);
|
||||
} else {
|
||||
widget.onHoverOtherItem?.call();
|
||||
}
|
||||
},
|
||||
onExit: (_) {
|
||||
_hoverController?.exit();
|
||||
},
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onSelectAction(widget.menuAction),
|
||||
hoverColor: AppColor.grayBackgroundColor,
|
||||
child: childWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onSelectAction(widget.menuAction),
|
||||
hoverColor: AppColor.grayBackgroundColor,
|
||||
onHover: (_) {
|
||||
_hoverController?.exit();
|
||||
widget.onHoverOtherItem?.call();
|
||||
},
|
||||
child: childWidget,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_hoverController != null) {
|
||||
_hoverController?.dispose();
|
||||
_hoverController = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu_item.dart';
|
||||
|
||||
class AiScribeSubmenu extends StatelessWidget {
|
||||
final List<AiScribeContextMenuAction> menuActions;
|
||||
final ValueChanged<AiScribeContextMenuAction> onSelectAction;
|
||||
|
||||
const AiScribeSubmenu({
|
||||
super.key,
|
||||
required this.menuActions,
|
||||
required this.onSelectAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AIScribeColors.background,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(AIScribeSizes.menuRadius),
|
||||
),
|
||||
boxShadow: AIScribeShadows.modal,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: menuActions.length,
|
||||
itemBuilder: (_, index) {
|
||||
final action = menuActions[index];
|
||||
return AiScribeSubmenuItem(
|
||||
menuAction: action,
|
||||
onSelectAction: onSelectAction,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
class AiScribeSubmenuItem extends StatelessWidget {
|
||||
final AiScribeContextMenuAction menuAction;
|
||||
final ValueChanged<AiScribeContextMenuAction> onSelectAction;
|
||||
|
||||
const AiScribeSubmenuItem({
|
||||
super.key,
|
||||
required this.menuAction,
|
||||
required this.onSelectAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () => onSelectAction(menuAction),
|
||||
hoverColor: AppColor.grayBackgroundColor,
|
||||
child: Container(
|
||||
height: AIScribeSizes.menuItemHeight,
|
||||
padding: AIScribeSizes.menuItemPadding,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Row(
|
||||
children: [
|
||||
if (menuAction.actionIcon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: SvgPicture.asset(
|
||||
menuAction.actionIcon!,
|
||||
width: 20,
|
||||
height: 20,
|
||||
fit: BoxFit.fill,
|
||||
colorFilter:
|
||||
AppColor.gray424244.withValues(alpha: 0.72).asFilter(),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
menuAction.actionName,
|
||||
style: AIScribeTextStyles.menuItem,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_category_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/search/ai_scribe_bar.dart';
|
||||
|
||||
class AiScribeModalWidget extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
final String? content;
|
||||
final List<AIScribeMenuCategory> availableCategories;
|
||||
final Offset? buttonPosition;
|
||||
final Size? buttonSize;
|
||||
final ModalPlacement? preferredPlacement;
|
||||
final ModalCrossAxisAlignment crossAxisAlignment;
|
||||
final PopupSubmenuController? submenuController;
|
||||
|
||||
const AiScribeModalWidget({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
required this.availableCategories,
|
||||
this.content,
|
||||
this.buttonPosition,
|
||||
this.buttonSize,
|
||||
this.preferredPlacement,
|
||||
this.crossAxisAlignment = ModalCrossAxisAlignment.center,
|
||||
this.submenuController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasContent = content?.isNotEmpty ?? false;
|
||||
final scribeLocalizations = ScribeLocalizations.of(context);
|
||||
final menuActions = availableCategories
|
||||
.map((category) => AiScribeCategoryContextMenuAction(
|
||||
category,
|
||||
scribeLocalizations,
|
||||
imagePaths,
|
||||
))
|
||||
.toList();
|
||||
|
||||
final dialogContent = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: AIScribeSizes.fieldSpacing,
|
||||
children: [
|
||||
if (hasContent)
|
||||
Flexible(
|
||||
child: AiScribeContextMenu(
|
||||
imagePaths: imagePaths,
|
||||
menuActions: menuActions,
|
||||
submenuController: submenuController,
|
||||
onActionSelected: (menuAction) => _onActionSelected(
|
||||
context,
|
||||
menuAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
onEnter: (_) => submenuController?.hide(),
|
||||
child: AIScribeBar(
|
||||
imagePaths: imagePaths,
|
||||
onCustomPrompt: (customPrompt) {
|
||||
Navigator.of(context).pop(CustomPromptAction(customPrompt));
|
||||
submenuController?.hide();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (buttonPosition != null && buttonSize != null) {
|
||||
final maxHeightModal = hasContent
|
||||
? AIScribeSizes.searchBarMinHeight +
|
||||
AIScribeSizes.fieldSpacing +
|
||||
min(menuActions.length * AIScribeSizes.menuItemHeight,
|
||||
AIScribeSizes.submenuMaxHeight)
|
||||
: AIScribeSizes.searchBarMinHeight;
|
||||
|
||||
final layoutResult = AnchoredModalLayoutCalculator.calculate(
|
||||
input: AnchoredModalLayoutInput(
|
||||
screenSize: MediaQuery.of(context).size,
|
||||
anchorPosition: buttonPosition!,
|
||||
anchorSize: buttonSize!,
|
||||
menuSize: Size(
|
||||
AIScribeSizes.modalMaxWidth,
|
||||
maxHeightModal,
|
||||
),
|
||||
preferredPlacement: preferredPlacement,
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
),
|
||||
padding: AIScribeSizes.screenEdgePadding,
|
||||
);
|
||||
|
||||
final position = layoutResult.position;
|
||||
|
||||
final top = preferredPlacement == ModalPlacement.top
|
||||
? position.dy -
|
||||
(hasContent
|
||||
? AIScribeSizes.modalSpacing
|
||||
: AIScribeSizes.modalWithoutContentSpacing)
|
||||
: position.dy;
|
||||
|
||||
return PointerInterceptor(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: Navigator.of(context).pop,
|
||||
child: Stack(
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
start: position.dx,
|
||||
top: top,
|
||||
child: dialogContent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(child: dialogContent);
|
||||
}
|
||||
|
||||
void _onActionSelected(
|
||||
BuildContext context,
|
||||
AiScribeContextMenuAction menuAction,
|
||||
) {
|
||||
final navigator = Navigator.of(context);
|
||||
if (menuAction is AiScribeCategoryContextMenuAction) {
|
||||
final firstAiScribeAction = menuAction.action.actions.firstOrNull;
|
||||
if (firstAiScribeAction != null) {
|
||||
navigator.pop(PredefinedAction(firstAiScribeAction));
|
||||
} else {
|
||||
navigator.pop();
|
||||
}
|
||||
} else if (menuAction is AiScribeActionContextMenuAction) {
|
||||
navigator.pop(PredefinedAction(menuAction.action));
|
||||
} else {
|
||||
navigator.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:dartz/dartz.dart' as dartz;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:scribe/scribe/ai/domain/state/generate_ai_text_state.dart';
|
||||
import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_error.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_header.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_loading.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success.dart';
|
||||
|
||||
typedef OnAiScribeResultCallback = void Function(String result);
|
||||
|
||||
class AiScribeSuggestionWidget extends StatefulWidget {
|
||||
final AIAction aiAction;
|
||||
final String? content;
|
||||
final ImagePaths imagePaths;
|
||||
final Offset? buttonPosition;
|
||||
final Size? buttonSize;
|
||||
final ModalPlacement? preferredPlacement;
|
||||
final ModalCrossAxisAlignment crossAxisAlignment;
|
||||
|
||||
const AiScribeSuggestionWidget({
|
||||
super.key,
|
||||
required this.aiAction,
|
||||
required this.imagePaths,
|
||||
this.content,
|
||||
this.buttonPosition,
|
||||
this.buttonSize,
|
||||
this.preferredPlacement,
|
||||
this.crossAxisAlignment = ModalCrossAxisAlignment.center,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AiScribeSuggestionWidget> createState() =>
|
||||
_AiScribeSuggestionWidgetModalState();
|
||||
}
|
||||
|
||||
class _AiScribeSuggestionWidgetModalState
|
||||
extends State<AiScribeSuggestionWidget> {
|
||||
GenerateAITextInteractor? _generateAITextInteractor;
|
||||
final ValueNotifier<dynamic> _valueNotifier =
|
||||
ValueNotifier(dartz.Right(GenerateAITextLoading()));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (Get.isRegistered<GenerateAITextInteractor>()) {
|
||||
_generateAITextInteractor = Get.find<GenerateAITextInteractor>();
|
||||
_getAiSuggestion(
|
||||
_generateAITextInteractor!,
|
||||
widget.aiAction,
|
||||
widget.content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _getAiSuggestion(
|
||||
GenerateAITextInteractor interactor,
|
||||
AIAction action,
|
||||
String? content,
|
||||
) async {
|
||||
final result = await interactor.execute(action, content);
|
||||
|
||||
result.fold((failure) {
|
||||
_valueNotifier.value = failure;
|
||||
}, (success) {
|
||||
_valueNotifier.value = success;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localizations = ScribeLocalizations.of(context);
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final modalWidth = min(
|
||||
screenSize.width * AIScribeSizes.mobileFactor,
|
||||
AIScribeSizes.suggestionModalMaxWidth,
|
||||
);
|
||||
final modalHeight = min(
|
||||
screenSize.height * AIScribeSizes.mobileFactor,
|
||||
AIScribeSizes.suggestionModalMaxHeight,
|
||||
);
|
||||
|
||||
final dialogContent = Container(
|
||||
width: modalWidth,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: AIScribeSizes.suggestionModalMinHeight,
|
||||
maxHeight: modalHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AIScribeColors.background,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(AIScribeSizes.menuRadius),
|
||||
),
|
||||
boxShadow: AIScribeShadows.modal,
|
||||
),
|
||||
padding: AIScribeSizes.suggestionContentPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
AiScribeSuggestionHeader(
|
||||
title: widget.aiAction.getLabel(localizations),
|
||||
imagePaths: widget.imagePaths,
|
||||
),
|
||||
Flexible(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _valueNotifier,
|
||||
builder: (_, value, __) {
|
||||
if (value is GenerateAITextSuccess) {
|
||||
return AiScribeSuggestionSuccess(
|
||||
imagePaths: widget.imagePaths,
|
||||
suggestionText: value.response.result,
|
||||
aiAction: widget.aiAction,
|
||||
);
|
||||
} else if (value is GenerateAITextFailure) {
|
||||
return AiScribeSuggestionError(imagePaths: widget.imagePaths);
|
||||
} else {
|
||||
return AiScribeSuggestionLoading(
|
||||
imagePaths: widget.imagePaths,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.buttonPosition != null && widget.buttonSize != null) {
|
||||
final layoutResult = AnchoredModalLayoutCalculator.calculate(
|
||||
input: AnchoredModalLayoutInput(
|
||||
screenSize: MediaQuery.of(context).size,
|
||||
anchorPosition: widget.buttonPosition!,
|
||||
anchorSize: widget.buttonSize!,
|
||||
menuSize: Size(
|
||||
modalWidth,
|
||||
AIScribeSizes.suggestionModalMinHeight,
|
||||
),
|
||||
preferredPlacement: widget.preferredPlacement,
|
||||
crossAxisAlignment: widget.crossAxisAlignment,
|
||||
),
|
||||
padding: AIScribeSizes.screenEdgePadding,
|
||||
);
|
||||
|
||||
final position = layoutResult.position;
|
||||
|
||||
final top = widget.preferredPlacement == ModalPlacement.top
|
||||
? position.dy - AIScribeSizes.modalWithoutContentSpacing
|
||||
: position.dy;
|
||||
|
||||
return PointerInterceptor(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: Navigator.of(context).pop,
|
||||
child: Stack(
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
start: position.dx,
|
||||
top: top,
|
||||
child: dialogContent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(child: dialogContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
class AiScribeSuggestionError extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
|
||||
const AiScribeSuggestionError({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(bottom: 8, start: 8),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
imagePaths.icWarning,
|
||||
width: 22,
|
||||
height: 22,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
ScribeLocalizations.of(context).failedToGenerate,
|
||||
style: AIScribeTextStyles.suggestionLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
class AiScribeSuggestionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final ImagePaths imagePaths;
|
||||
|
||||
const AiScribeSuggestionHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.imagePaths,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: AIScribeTextStyles.suggestionTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TMailButtonWidget.fromIcon(
|
||||
icon: imagePaths.icCloseDialog,
|
||||
iconSize: 20,
|
||||
iconColor: AppColor.gray424244.withValues(alpha: 0.72),
|
||||
padding: const EdgeInsets.all(3),
|
||||
borderRadius: 20,
|
||||
backgroundColor: Colors.transparent,
|
||||
onTapActionCallback: Navigator.of(context).pop,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
class AiScribeSuggestionLoading extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
|
||||
const AiScribeSuggestionLoading({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(bottom: 8, start: 8),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_SparklePulseIcon(
|
||||
asset: imagePaths.icSparkle,
|
||||
color: AppColor.blueFF00B7FF,
|
||||
),
|
||||
Expanded(
|
||||
child: _AnimatedEllipsisText(
|
||||
text: ScribeLocalizations.of(context).generatingResponse,
|
||||
style: AIScribeTextStyles.suggestionLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SparklePulseIcon extends StatefulWidget {
|
||||
final String asset;
|
||||
final Color color;
|
||||
|
||||
const _SparklePulseIcon({
|
||||
required this.asset,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SparklePulseIcon> createState() => _SparklePulseIconState();
|
||||
}
|
||||
|
||||
class _SparklePulseIconState extends State<_SparklePulseIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _scale;
|
||||
late final Animation<double> _opacity;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_scale = Tween(begin: 0.95, end: 1.05).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_opacity = Tween(begin: 0.7, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: ScaleTransition(
|
||||
scale: _scale,
|
||||
child: SvgPicture.asset(
|
||||
widget.asset,
|
||||
width: 22,
|
||||
height: 22,
|
||||
colorFilter: widget.color.asFilter(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedEllipsisText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle style;
|
||||
|
||||
const _AnimatedEllipsisText({
|
||||
required this.text,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AnimatedEllipsisText> createState() => _AnimatedEllipsisTextState();
|
||||
}
|
||||
|
||||
class _AnimatedEllipsisTextState extends State<_AnimatedEllipsisText> {
|
||||
static const _dotStates = ['', '.', '..', '...'];
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tick();
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (!mounted) return;
|
||||
setState(() => _index = (_index + 1) % _dotStates.length);
|
||||
_tick();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
'${widget.text}${_dotStates[_index]}',
|
||||
style: widget.style,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart';
|
||||
|
||||
class AiScribeSuggestionSuccess extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
final String suggestionText;
|
||||
final AIAction aiAction;
|
||||
|
||||
const AiScribeSuggestionSuccess({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
required this.suggestionText,
|
||||
required this.aiAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(bottom: 8),
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
suggestionText,
|
||||
style: AIScribeTextStyles.suggestionContent,
|
||||
),
|
||||
),
|
||||
AiScribeSuggestionSuccessListActions(
|
||||
imagePaths: imagePaths,
|
||||
suggestionText: suggestionText,
|
||||
aiAction: aiAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
class AiScribeSuggestionSuccessListActions extends StatelessWidget {
|
||||
final ImagePaths imagePaths;
|
||||
final String suggestionText;
|
||||
final AIAction aiAction;
|
||||
|
||||
const AiScribeSuggestionSuccessListActions({
|
||||
super.key,
|
||||
required this.imagePaths,
|
||||
required this.suggestionText,
|
||||
required this.aiAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(bottom: 8),
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
suggestionText,
|
||||
style: AIScribeTextStyles.suggestionContent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/text_selection_model.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart';
|
||||
|
||||
class AiSelectionOverlay extends StatelessWidget {
|
||||
const AiSelectionOverlay({
|
||||
super.key,
|
||||
required this.selection,
|
||||
required this.imagePaths,
|
||||
required this.onAiScribeResultCallback,
|
||||
});
|
||||
|
||||
final TextSelectionModel? selection;
|
||||
final ImagePaths imagePaths;
|
||||
final OnAiScribeResultCallback onAiScribeResultCallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selection == null ||
|
||||
!selection!.hasSelection ||
|
||||
selection!.coordinates == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final coordinates = selection!.coordinates!;
|
||||
final selectedText = selection!.selectedText;
|
||||
|
||||
return PositionedDirectional(
|
||||
start: coordinates.dx,
|
||||
top: coordinates.dy,
|
||||
child: PointerInterceptor(
|
||||
child: InlineAiAssistButton(
|
||||
imagePaths: imagePaths,
|
||||
selectedText: selectedText,
|
||||
onAiScribeResultCallback: onAiScribeResultCallback,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
|
||||
@@ -53,39 +53,54 @@ class _AIScribeBarState extends State<AIScribeBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: AIScribeSizes.barHeight,
|
||||
padding: AIScribeSizes.barPadding,
|
||||
width: AIScribeSizes.searchBarWidth,
|
||||
padding: AIScribeSizes.searchBarPadding,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(AIScribeSizes.searchBarRadius),
|
||||
),
|
||||
color: AIScribeColors.background,
|
||||
boxShadow: AIScribeShadows.modal,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: AIScribeSizes.searchBarMaxHeight,
|
||||
minHeight: AIScribeSizes.searchBarMinHeight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: ScribeLocalizations.of(context)!.inputPlaceholder,
|
||||
hintStyle: AIScribeTextStyles.menuHint,
|
||||
hintText: ScribeLocalizations.of(context).inputPlaceholder,
|
||||
hintStyle: AIScribeTextStyles.searchBarHint,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
isDense: true,
|
||||
),
|
||||
style: AIScribeTextStyles.menuItem,
|
||||
style: AIScribeTextStyles.searchBar,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
cursorHeight: 16,
|
||||
onSubmitted: (_) => _onSendPressed(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AIScribeSizes.fieldSpacing),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isButtonEnabled,
|
||||
builder: (context, isEnabled, child) {
|
||||
builder: (_, isEnabled, __) {
|
||||
return TMailButtonWidget.fromIcon(
|
||||
icon: widget.imagePaths.icSend,
|
||||
iconSize: AIScribeSizes.sendIconSize,
|
||||
iconColor: Colors.white,
|
||||
iconSize: AIScribeSizes.sendIcon,
|
||||
padding: AIScribeSizes.sendIconPadding,
|
||||
iconColor: AIScribeColors.background,
|
||||
backgroundColor: isEnabled
|
||||
? AIScribeButtonStyles.sendCustomPromptBackgroundColor
|
||||
: AIScribeButtonStyles.sendCustomPromptBackgroundColorDisabled,
|
||||
? AIScribeColors.sendPromptBackground
|
||||
: AIScribeColors.sendPromptBackgroundDisabled,
|
||||
onTapActionCallback: isEnabled ? _onSendPressed : null,
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -539,7 +539,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
|
||||
|
||||
@@ -36,6 +36,8 @@ dependencies:
|
||||
|
||||
json_annotation: 4.8.0
|
||||
|
||||
flutter_svg: 2.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
|
||||
|
||||
void main() {
|
||||
const screen = Size(800, 600);
|
||||
const menuSize = Size(300, 200);
|
||||
const anchorSize = Size(24, 24);
|
||||
|
||||
group('AnchoredModalLayoutCalculator – placement strategy', () {
|
||||
test('places menu to the RIGHT when there is enough horizontal space', () {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: screen,
|
||||
anchorPosition: Offset(100, 200),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.placement, ModalPlacement.right);
|
||||
expect(result.position.dx, greaterThan(100));
|
||||
});
|
||||
|
||||
test(
|
||||
'places menu at the BOTTOM when right space is insufficient but bottom has enough space',
|
||||
() {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: screen,
|
||||
anchorPosition: Offset(750, 200),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.placement, ModalPlacement.bottom);
|
||||
expect(result.position.dy, greaterThan(200));
|
||||
});
|
||||
|
||||
test(
|
||||
'places menu at the TOP when right and bottom space are insufficient but top has space',
|
||||
() {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: screen,
|
||||
anchorPosition: Offset(750, 500),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.placement, ModalPlacement.top);
|
||||
expect(result.position.dy, lessThan(500));
|
||||
});
|
||||
|
||||
test(
|
||||
'places menu to the LEFT when neither top nor bottom has enough vertical space',
|
||||
() {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: Size(800, 260),
|
||||
anchorPosition: Offset(750, 120),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.placement, ModalPlacement.left);
|
||||
expect(result.position.dx, lessThan(750));
|
||||
});
|
||||
|
||||
test('falls back to LEFT placement when no direction has sufficient space',
|
||||
() {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: Size(320, 480),
|
||||
anchorPosition: Offset(150, 200),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: Size(300, 400),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.placement, ModalPlacement.left);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnchoredModalLayoutCalculator – screen clamping', () {
|
||||
test('clamps LEFT position to screen padding', () {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: screen,
|
||||
anchorPosition: Offset(5, 200),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
padding: 16,
|
||||
);
|
||||
|
||||
expect(result.position.dx, greaterThanOrEqualTo(16));
|
||||
});
|
||||
|
||||
test('clamps TOP position to screen padding', () {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: screen,
|
||||
anchorPosition: Offset(200, 5),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
padding: 16,
|
||||
);
|
||||
|
||||
expect(result.position.dy, greaterThanOrEqualTo(16));
|
||||
});
|
||||
|
||||
test('does not overflow RIGHT screen edge', () {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: screen,
|
||||
anchorPosition: Offset(780, 200),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
padding: 16,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.position.dx + menuSize.width,
|
||||
lessThanOrEqualTo(screen.width - 16),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not overflow BOTTOM screen edge', () {
|
||||
final result = AnchoredModalLayoutCalculator.calculate(
|
||||
input: const AnchoredModalLayoutInput(
|
||||
screenSize: screen,
|
||||
anchorPosition: Offset(200, 580),
|
||||
anchorSize: anchorSize,
|
||||
menuSize: menuSize,
|
||||
),
|
||||
padding: 16,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.position.dy + menuSize.height,
|
||||
lessThanOrEqualTo(screen.height - 16),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
|
||||
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe.dart';
|
||||
|
||||
void main() {
|
||||
group('calculateMenuDialogHeight::', () {
|
||||
test('should calculate correct height with content and default 4 categories', () {
|
||||
// Arrange
|
||||
const hasContent = true;
|
||||
const categoryCount = 4;
|
||||
|
||||
// Act
|
||||
final height = calculateMenuDialogHeight(
|
||||
hasContent: hasContent,
|
||||
categoryCount: categoryCount,
|
||||
);
|
||||
|
||||
// Assert
|
||||
// 4 categories × 40px + 8px spacing + 48px bar = 216px
|
||||
const expectedHeight = (4 * AIScribeSizes.menuItemHeight) +
|
||||
AIScribeSizes.fieldSpacing +
|
||||
AIScribeSizes.barHeight;
|
||||
expect(height, expectedHeight);
|
||||
expect(height, 216.0);
|
||||
});
|
||||
|
||||
test('should calculate correct height with content and custom categories', () {
|
||||
// Arrange
|
||||
const hasContent = true;
|
||||
const categoryCount = 2;
|
||||
|
||||
// Act
|
||||
final height = calculateMenuDialogHeight(
|
||||
hasContent: hasContent,
|
||||
categoryCount: categoryCount,
|
||||
);
|
||||
|
||||
// Assert
|
||||
// 2 categories × 40px + 8px spacing + 48px bar = 136px
|
||||
const expectedHeight = (2 * AIScribeSizes.menuItemHeight) +
|
||||
AIScribeSizes.fieldSpacing +
|
||||
AIScribeSizes.barHeight;
|
||||
expect(height, expectedHeight);
|
||||
expect(height, 136.0);
|
||||
});
|
||||
|
||||
test('should calculate correct height without content (bar only)', () {
|
||||
// Arrange
|
||||
const hasContent = false;
|
||||
const categoryCount = 4; // Should be ignored when hasContent is false
|
||||
|
||||
// Act
|
||||
final height = calculateMenuDialogHeight(
|
||||
hasContent: hasContent,
|
||||
categoryCount: categoryCount,
|
||||
);
|
||||
|
||||
// Assert
|
||||
// Only the bar height
|
||||
expect(height, AIScribeSizes.barHeight);
|
||||
expect(height, 48.0);
|
||||
});
|
||||
});
|
||||
|
||||
group('calculateModalPosition::', () {
|
||||
testWidgets('should adjust left position when modal would go off-screen to the right', (tester) async {
|
||||
// Arrange
|
||||
const screenSize = Size(800, 600);
|
||||
const buttonPosition = Offset(700, 300); // Close to right edge
|
||||
const modalWidth = 440.0;
|
||||
BuildContext? capturedContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(size: screenSize),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Act
|
||||
final position = calculateModalPosition(
|
||||
context: capturedContext!,
|
||||
buttonPosition: buttonPosition,
|
||||
modalWidth: modalWidth,
|
||||
);
|
||||
|
||||
// Assert
|
||||
final expectedLeft = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding;
|
||||
expect(position.left, expectedLeft);
|
||||
});
|
||||
|
||||
testWidgets('should adjust left position to screenEdgePadding when modal would go off-screen to the left', (tester) async {
|
||||
// Arrange
|
||||
const screenSize = Size(800, 600);
|
||||
const buttonPosition = Offset(5, 300); // Very close to left edge
|
||||
const modalWidth = 440.0;
|
||||
BuildContext? capturedContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(size: screenSize),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Act
|
||||
final position = calculateModalPosition(
|
||||
context: capturedContext!,
|
||||
buttonPosition: buttonPosition,
|
||||
modalWidth: modalWidth,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(position.left, AIScribeSizes.screenEdgePadding);
|
||||
});
|
||||
|
||||
testWidgets('should adjust bottom position when modal height is provided and would go off-screen to the top', (tester) async {
|
||||
// Arrange
|
||||
const screenSize = Size(800, 600);
|
||||
const buttonPosition = Offset(100, 50); // Close to top
|
||||
const modalWidth = 440.0;
|
||||
const modalHeight = 400.0;
|
||||
BuildContext? capturedContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(size: screenSize),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Act
|
||||
final position = calculateModalPosition(
|
||||
context: capturedContext!,
|
||||
buttonPosition: buttonPosition,
|
||||
modalWidth: modalWidth,
|
||||
modalHeight: modalHeight,
|
||||
);
|
||||
|
||||
// Assert
|
||||
final expectedBottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding;
|
||||
expect(position.bottom, expectedBottom);
|
||||
});
|
||||
|
||||
testWidgets('should adjust bottom position to screenEdgePadding when calculated bottom is too small', (tester) async {
|
||||
// Arrange
|
||||
const screenSize = Size(800, 600);
|
||||
const buttonPosition = Offset(100, 595); // Very close to bottom edge
|
||||
const modalWidth = 440.0;
|
||||
const modalHeight = 400.0;
|
||||
BuildContext? capturedContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(size: screenSize),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Act
|
||||
final position = calculateModalPosition(
|
||||
context: capturedContext!,
|
||||
buttonPosition: buttonPosition,
|
||||
modalWidth: modalWidth,
|
||||
modalHeight: modalHeight,
|
||||
);
|
||||
|
||||
// Assert
|
||||
// bottom = 600 - 595 + 8 = 13, which is < 16, so it should be adjusted to 16
|
||||
expect(position.bottom, AIScribeSizes.screenEdgePadding);
|
||||
});
|
||||
});
|
||||
}
|
||||