From a1d47f8e95c905910027496a10a831b097b285bb Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Sun, 1 Feb 2026 16:06:12 -0600 Subject: [PATCH] Add reaction system and security improvements --- sojorn_app/assets/images/toplogo.png | Bin 0 -> 3377 bytes sojorn_app/assets/reactions/README.md | 209 +++++ sojorn_app/assets/reactions/blue/credit.md | 11 + .../assets/reactions/dotto/anguished_face.svg | 251 ++++++ .../assets/reactions/dotto/beaming_face.svg | 151 ++++ .../assets/reactions/dotto/blue_heart.svg | 151 ++++ .../assets/reactions/dotto/broken_heart.svg | 154 ++++ sojorn_app/assets/reactions/dotto/credit.md | 11 + .../reactions/dotto/disappointed_face.svg | 151 ++++ .../assets/reactions/dotto/downcast_face.svg | 151 ++++ .../reactions/dotto/download_dotto_emoji.py | 93 +++ .../reactions/dotto/face_with_monocle.svg | 157 ++++ .../reactions/dotto/face_with_tears.svg | 151 ++++ .../assets/reactions/dotto/fearful_face.svg | 151 ++++ sojorn_app/assets/reactions/dotto/ghost.svg | 157 ++++ .../assets/reactions/dotto/green_heart.svg | 151 ++++ .../assets/reactions/dotto/grinning_face.svg | 151 ++++ .../reactions/dotto/heart_with_arrow.svg | 195 +++++ .../assets/reactions/dotto/laughing_face.svg | 151 ++++ .../reactions/dotto/loudly_crying_face.svg | 151 ++++ .../assets/reactions/dotto/melting_face.svg | 180 +++++ .../assets/reactions/dotto/nerd_face.svg | 151 ++++ .../assets/reactions/dotto/party_face.svg | 151 ++++ .../assets/reactions/dotto/purple_heart.svg | 154 ++++ .../assets/reactions/dotto/robot_face.svg | 154 ++++ .../assets/reactions/dotto/rolling_face.svg | 151 ++++ sojorn_app/assets/reactions/dotto/skull.svg | 151 ++++ .../reactions/dotto/slightly_smiling_face.svg | 151 ++++ .../assets/reactions/dotto/smiling_face.svg | 151 ++++ .../dotto/smiling_face_with_halo.svg | 161 ++++ .../dotto/smiling_face_with_hearts.svg | 169 +++++ .../reactions/dotto/sparkling_heart.svg | 161 ++++ .../reactions/dotto/sunglasses_face.svg | 151 ++++ .../reactions/dotto/upside_down_face.svg | 151 ++++ .../assets/reactions/dotto/winking_face.svg | 151 ++++ .../assets/reactions/dotto/worried_face.svg | 31 + sojorn_app/assets/reactions/green/credit.md | 11 + sojorn_app/assets/reactions/purple/credit.md | 11 + .../assets/reactions/reaction_config.json | 75 ++ .../lib/models/sojorn_media_result.dart | 22 +- .../screens/beacon/beacon_bottom_sheet.dart | 23 +- .../screens/beacon/beacon_detail_screen.dart | 551 ++++++++++++++ .../lib/screens/beacon/beacon_screen.dart | 105 ++- .../screens/compose/image_editor_screen.dart | 87 ++- .../post/threaded_conversation_screen.dart | 56 +- sojorn_app/lib/services/api_service.dart | 184 ++++- sojorn_app/lib/utils/request_signing.dart | 97 +++ sojorn_app/lib/utils/security_utils.dart | 141 ++++ sojorn_app/lib/widgets/app_scaffold.dart | 10 +- sojorn_app/lib/widgets/post/post_actions.dart | 109 +-- sojorn_app/lib/widgets/post/post_header.dart | 173 ++--- sojorn_app/lib/widgets/post_item.dart | 3 + sojorn_app/lib/widgets/reactions/credit.md | 1 + .../widgets/reactions/reaction_picker.dart | 715 +++++++++++++++--- .../widgets/reactions/reactions_display.dart | 83 ++ sojorn_app/lib/widgets/reading_post_card.dart | 201 ++--- sojorn_app/lib/widgets/sojorn_post_card.dart | 36 +- sojorn_app/pubspec.lock | 136 ++++ sojorn_app/pubspec.yaml | 7 +- sojorn_app/tools/README.md | 58 ++ sojorn_app/tools/add_new_folder.bat | 13 + sojorn_app/tools/add_reaction_folder.dart | 112 +++ .../tools/generate_reaction_config.dart | 106 +++ sojorn_app/tools/update_reactions.bat | 13 + 64 files changed, 7826 insertions(+), 430 deletions(-) create mode 100644 sojorn_app/assets/images/toplogo.png create mode 100644 sojorn_app/assets/reactions/README.md create mode 100644 sojorn_app/assets/reactions/blue/credit.md create mode 100644 sojorn_app/assets/reactions/dotto/anguished_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/beaming_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/blue_heart.svg create mode 100644 sojorn_app/assets/reactions/dotto/broken_heart.svg create mode 100644 sojorn_app/assets/reactions/dotto/credit.md create mode 100644 sojorn_app/assets/reactions/dotto/disappointed_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/downcast_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/download_dotto_emoji.py create mode 100644 sojorn_app/assets/reactions/dotto/face_with_monocle.svg create mode 100644 sojorn_app/assets/reactions/dotto/face_with_tears.svg create mode 100644 sojorn_app/assets/reactions/dotto/fearful_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/ghost.svg create mode 100644 sojorn_app/assets/reactions/dotto/green_heart.svg create mode 100644 sojorn_app/assets/reactions/dotto/grinning_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/heart_with_arrow.svg create mode 100644 sojorn_app/assets/reactions/dotto/laughing_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/loudly_crying_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/melting_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/nerd_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/party_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/purple_heart.svg create mode 100644 sojorn_app/assets/reactions/dotto/robot_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/rolling_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/skull.svg create mode 100644 sojorn_app/assets/reactions/dotto/slightly_smiling_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/smiling_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/smiling_face_with_halo.svg create mode 100644 sojorn_app/assets/reactions/dotto/smiling_face_with_hearts.svg create mode 100644 sojorn_app/assets/reactions/dotto/sparkling_heart.svg create mode 100644 sojorn_app/assets/reactions/dotto/sunglasses_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/upside_down_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/winking_face.svg create mode 100644 sojorn_app/assets/reactions/dotto/worried_face.svg create mode 100644 sojorn_app/assets/reactions/green/credit.md create mode 100644 sojorn_app/assets/reactions/purple/credit.md create mode 100644 sojorn_app/assets/reactions/reaction_config.json create mode 100644 sojorn_app/lib/screens/beacon/beacon_detail_screen.dart create mode 100644 sojorn_app/lib/utils/request_signing.dart create mode 100644 sojorn_app/lib/utils/security_utils.dart create mode 100644 sojorn_app/lib/widgets/reactions/credit.md create mode 100644 sojorn_app/lib/widgets/reactions/reactions_display.dart create mode 100644 sojorn_app/tools/README.md create mode 100644 sojorn_app/tools/add_new_folder.bat create mode 100644 sojorn_app/tools/add_reaction_folder.dart create mode 100644 sojorn_app/tools/generate_reaction_config.dart create mode 100644 sojorn_app/tools/update_reactions.bat diff --git a/sojorn_app/assets/images/toplogo.png b/sojorn_app/assets/images/toplogo.png new file mode 100644 index 0000000000000000000000000000000000000000..4c2370cd51ee553801beb9bc1e0173cb466773a2 GIT binary patch literal 3377 zcmV-14bJk3P)MxB0k#MX@X_TvZdATo}MGWYcI<=`z0A{XWy9}fqeGe^YNVLJnwtX3L)Qo^UXKk zeDlpW-+bHICTzz_zy0?8H2FA7t|Ykt zj&aW*JUecB)~H_If$xi??1;q}rWkF~+e4pI*b(3*4W^wlW&(hfO+)7(C*URi&ykNl z4nTNTgibzZ@`WATJ7m7k)Yf5R{av(~`kglTqJ5UJx`&kSG4;cs&2w_(o-8RlWmCsI zDRAy!jusN?oC+W-wzgh5Iuo$G9;4jfYTY2Cq!1J?Ep8;sUlR+;!0WSbP zV_H?FE!x}%Wr_|bEQM=%5nPbIq*&Ubrj@E-q4N|^lQRSel!!jb=`C7RP^g3uSHZ@ z!EJxJS`Od8R~5~G@#x>IA8DY|>yT07JP;UzIXAv-fRA9%2TbJ`7-iSN3j;sF@Wq{^ z4kozHp2v+7(PEM?pJ}CQ&2fYSeQil-Fu`te0DRmSkcv$JZat_tb}UkQ^=L$vE4U47 zNP6pZo7%ZsqM#dt3VHR<>a<&S_dz2^8T!=7o=L_)XPwjw{PW88Gjwm>#^o@FnR5>7 zljS@k&X6ICHUU!3Dt0jSm)RcJOo|bkb`(Jp*~TzpgTSTE`eQLsIeD_BEX!Iu#z3`5vEOzdZ(6?WUU^fHl(9Swxa{%H904xu= zM}3J-xCA@TMmgW7UHD-2&!n4)1nr%+m`v#g+LGh2?obo@$tvY%wu4OpFR(?DvhLx- zVXSE=NXWMCkFc+xE9&o_XrL~8CIs_qQ|}&=X`(4E0aRMWdW>1!0?BxHV|B}soQa1H>m+fbpX8ndP_-Ba0>x! zKo9@w;1$-u+qqXl(T)HrFREu;E;DvoF$JlqNm%alE_|jO&U7~{k>;e&>;^dLvfW-7 z%j!AnOlk#2r%S%eIyGdC$cFl09XuG)@EYSjow*=7f&^|N{acKH7kSKm0vlRspbUWc z3mQD~HX=;XEUPYy4fMq=kAUHpal>ki`-*m~)x^p2!pswRb%5;KCsie>&fe?5#Ryn; zO*Yj39gGN`!D|RaoFv-oJ2*5g1wc-C1l03nmLF_FeYPDCy_hH2aO6n%hxhJPQFnK+ zLbD6&8uuyYtzT5{3biVJCQ>x8VA4)|QZ^xF>8=tlugYTNen?s6ApKL=L;yBo@#pm* zI+?vP`j!PRB;3;=>dhyY3UFP82slrG8(U{uGjN5CO9%)WCQ4YYXq4ge0M-oX<&;Gy z7m(p(7yy{we+V)h85eD&Wz!V=Ho7?V0s;J7Y$pM?4qjMV>5c~K>I#&0?U70y@xYTc z021mXuv;psNB;Y$_Qvv(Itaj8?*vgqingY0*QG8-8`*|(Ny7{CioRSgtm)+7mj@1n z{WN|GQyejrD0d{}yU=AV^1YnDoy6bJ~b znuevieg=)Z7QP8}@P7X3s;;Z!hYyGU)6)|zM5AIzmT`=LKBlTDM*JYq(DWlr0Iak5 zs0D+ZqZh0iDYS9E*9ySHh@YG6*h&Ml)0X(n)Vy>+)9=2!bo}SPh(AIi0j)$Mtu^4% zPx}9k{lS&=uXf#^dmul2?+**dW@oEy%gbu;^y!!!42dcRf1)I6nt;URB@NyE>xvMI ziL0qp?B3C%;gyvYZFi-DDN_qHH>_$ngbaCM0%|lhwY&#yoT9Q@3iA-4j2I4X_i-4R zf_%Jz%)n*@az%>(w3*=DaY3AD0Kmw?f*QVjskBd#aZ9bL#pJ5iMj4X+Uz2fAsc5ZB zE4aJ;Tfx|`f44XPy_2mE=jN2q`SXj>Cr=bXAQEDYc?kgr1?~F#OQKAIwPdp8;VZ9% z)N)zdLjW$)yl_><9W@0n4of)|ViH2G`S%CYMompfD$hbc=ET^i989MWV>Qe-V6tGGj};UHQ5 z+h2V%D*W=dd;U%sSN-^-a_I3RS-F3wfKEE$h*^x7yJnDnsjDl{^W&6fC%QSD(<7^X|cf3Jqk+j=z z;TUfMPVQ*%0#noheD_XOy7b;sr2Lg4pjA!46)m7Ch%hAqo{S~R#5I)!T~!=x?+(U# zf3y2;C@d&{zFdl3yIcxW;Qr)nS3cYxh~W?cE+)X02yg`g+8_aKL`1knL^EH=(uF@3 zBZP5jU4{(j4_M5UbjglI4%6{~m4*P&4bx>fOy>wJ(C0jfGw5MVS%-XbUe0Z7H|8}0 zcyfMT5iguyY`OMfDfHyuH4&|-2vszZEcJA`4-sY#5f*O4RU9ZStDVvIK$U3Ry3g+u zJ>0oJ7{OsttcgNY3ko4MAcS#L6iP%H-~Oy3UHJV%%k?YEH1n_s8uy%oIuE-zAIv(8 zQUFQ9V?4*_kD(k3U?VOw1-!|Wf^)}iCK4-99YQ@F{=`b3~he)4bE%Bj2ms3@dtjULWH7zO4Z)uiPmRUl;r z12c}oU>6+*SP4C6YsDsp4UwmoXalII#=pr4c5MCY6A7`;;kp6~yrQ2&?9CTCS&McD zV+?N_KLcEc6DhHYINOw6M5n)FkU|@~V!TL}GTFEx&`KI|uf|BrVJ8b=k|59K90>?>?|1pl%?cvmk^(u|=HK6tP06F!^@qlRYPTe!_ zeL>x)IA9$>hWE-m28%r3y-trs8aB@23}Wcj`*+L#Zi9p05Ga7YTr@=fX^!s8GW^@U zb5%ea+v5iflaXasFNIoy*D&Gj&Tr_G{{doe=NrrEFM{g1@1zSB)Z2a0=AAP=kap^O z8Ya~ML-Oc9tYWtcP!Q)OwDrx71!zOKi=35pFoPaB3)0w@EzG!MVzcFF) zJgdoLWa$CJs+L6V^Tr7Sh(>HOEpB}4`u%sI11Rduu+XXU^tk=S~m3`%DCz#MFd(> zKlEcfVVB@6+m1AQ11wA@QVy2IbhrFMdvuj@?zRh~uRbVF|-=3Ft zXZ?0r=Qn{NH_Jr73tpTY@`3AvcRR3d%8B0f%?IyRG}a-Ti+;EG;N1?!$~#Z`-Qt6H zt4oFqdNyN`^YyL|-mMN?*1;XuXCHj?!TTa?jKR7C{<~>Dc(*$1jxn_ENP|C{ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/beaming_face.svg b/sojorn_app/assets/reactions/dotto/beaming_face.svg new file mode 100644 index 0000000..6699353 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/beaming_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/blue_heart.svg b/sojorn_app/assets/reactions/dotto/blue_heart.svg new file mode 100644 index 0000000..07db848 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/blue_heart.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/broken_heart.svg b/sojorn_app/assets/reactions/dotto/broken_heart.svg new file mode 100644 index 0000000..6747248 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/broken_heart.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/credit.md b/sojorn_app/assets/reactions/dotto/credit.md new file mode 100644 index 0000000..e5314fd --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/credit.md @@ -0,0 +1,11 @@ +# Dotto Emoji Set + +**Source:** [Dotto Emoji](https://github.com/meritite-union/dotto-emoji) by meritite-union + +**Description:** 16x16 pixel art emoji set with a retro aesthetic + +**License:** Custom license - see repository for details + +**Format:** Scalable Vector Graphics (SVG) + +**Style:** Pixel art with clean, minimalist design diff --git a/sojorn_app/assets/reactions/dotto/disappointed_face.svg b/sojorn_app/assets/reactions/dotto/disappointed_face.svg new file mode 100644 index 0000000..c0ffa46 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/disappointed_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/downcast_face.svg b/sojorn_app/assets/reactions/dotto/downcast_face.svg new file mode 100644 index 0000000..b4c27a6 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/downcast_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py b/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py new file mode 100644 index 0000000..a2c08bb --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Download Dotto Emoji SVG files from GitHub repository +""" + +import requests +import os +from urllib.parse import urlparse + +# Mapping of emoji names to GitHub SVG files +EMOJI_MAPPING = { + 'heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji0.svg', + 'thumbs_up.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji1.svg', + 'laughing_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg', + 'face_with_open_mouth.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji2.svg', + 'sad_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji3.svg', + 'angry_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji4.svg', + 'party_popper.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji5.svg', + 'fire.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji6.svg', + 'clapping_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji7.svg', + 'folded_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji8.svg', + 'hundred_points.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji9.svg', + 'thinking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji10.svg', + 'beaming_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/beaming_face_with_smiling_eyes.svg', + 'face_with_tears.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg', + 'grinning_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/grinning_face.svg', + 'smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_smiling_eyes.svg', + 'winking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/winking_face.svg', + 'melting_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/melting_face.svg', + 'upside_down_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/upside-down_face.svg', + 'rolling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/rolling_on_the_floor_laughing.svg', + 'slightly_smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/slightly_smiling_face.svg', + 'smiling_face_with_halo.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_halo.svg', + 'smiling_face_with_hearts.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_hearts.svg', + 'face_with_monocle.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji65.svg', + 'nerd_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji66.svg', + 'party_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji67.svg', + 'sunglasses_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji68.svg', + 'disappointed_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji69.svg', + 'worried_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji70.svg', + 'anguished_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji71.svg', + 'fearful_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji72.svg', + 'downcast_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji73.svg', + 'loudly_crying_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji74.svg', + 'skull.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji75.svg', + 'ghost.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji76.svg', + 'robot_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji77.svg', + 'heart_with_arrow.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji78.svg', + 'broken_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji79.svg', + 'sparkling_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji80.svg', + 'green_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji81.svg', + 'blue_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji82.svg', + 'purple_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji83.svg', +} + +def download_file(url, local_path): + """Download a file from URL to local path""" + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + + with open(local_path, 'wb') as f: + f.write(response.content) + + print(f"✓ Downloaded {os.path.basename(local_path)}") + return True + except Exception as e: + print(f"✗ Failed to download {os.path.basename(local_path)}: {e}") + return False + +def main(): + """Main function to download all emoji files""" + print("Downloading Dotto Emoji SVG files...") + + # Create directory if it doesn't exist + os.makedirs('.', exist_ok=True) + + success_count = 0 + total_count = len(EMOJI_MAPPING) + + for filename, url in EMOJI_MAPPING.items(): + local_path = filename + if download_file(url, local_path): + success_count += 1 + + print(f"\nDownload complete: {success_count}/{total_count} files downloaded") + + if success_count > 0: + print("\nThe Dotto emoji set is now ready to use!") + print("You can now run 'flutter pub get' to install dependencies.") + +if __name__ == "__main__": + main() diff --git a/sojorn_app/assets/reactions/dotto/face_with_monocle.svg b/sojorn_app/assets/reactions/dotto/face_with_monocle.svg new file mode 100644 index 0000000..8726423 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/face_with_monocle.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/face_with_tears.svg b/sojorn_app/assets/reactions/dotto/face_with_tears.svg new file mode 100644 index 0000000..08e17e2 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/face_with_tears.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/fearful_face.svg b/sojorn_app/assets/reactions/dotto/fearful_face.svg new file mode 100644 index 0000000..8e6fd1e --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/fearful_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/ghost.svg b/sojorn_app/assets/reactions/dotto/ghost.svg new file mode 100644 index 0000000..e820c73 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/ghost.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/green_heart.svg b/sojorn_app/assets/reactions/dotto/green_heart.svg new file mode 100644 index 0000000..5124a2f --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/green_heart.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/grinning_face.svg b/sojorn_app/assets/reactions/dotto/grinning_face.svg new file mode 100644 index 0000000..9cb2a42 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/grinning_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/heart_with_arrow.svg b/sojorn_app/assets/reactions/dotto/heart_with_arrow.svg new file mode 100644 index 0000000..ec7a704 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/heart_with_arrow.svg @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/laughing_face.svg b/sojorn_app/assets/reactions/dotto/laughing_face.svg new file mode 100644 index 0000000..08e17e2 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/laughing_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/loudly_crying_face.svg b/sojorn_app/assets/reactions/dotto/loudly_crying_face.svg new file mode 100644 index 0000000..a782abf --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/loudly_crying_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/melting_face.svg b/sojorn_app/assets/reactions/dotto/melting_face.svg new file mode 100644 index 0000000..fda6229 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/melting_face.svg @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/nerd_face.svg b/sojorn_app/assets/reactions/dotto/nerd_face.svg new file mode 100644 index 0000000..7461f86 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/nerd_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/party_face.svg b/sojorn_app/assets/reactions/dotto/party_face.svg new file mode 100644 index 0000000..e88204f --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/party_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/purple_heart.svg b/sojorn_app/assets/reactions/dotto/purple_heart.svg new file mode 100644 index 0000000..6caad86 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/purple_heart.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/robot_face.svg b/sojorn_app/assets/reactions/dotto/robot_face.svg new file mode 100644 index 0000000..b2d358f --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/robot_face.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/rolling_face.svg b/sojorn_app/assets/reactions/dotto/rolling_face.svg new file mode 100644 index 0000000..9677353 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/rolling_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/skull.svg b/sojorn_app/assets/reactions/dotto/skull.svg new file mode 100644 index 0000000..cca4167 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/skull.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/slightly_smiling_face.svg b/sojorn_app/assets/reactions/dotto/slightly_smiling_face.svg new file mode 100644 index 0000000..644988a --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/slightly_smiling_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/smiling_face.svg b/sojorn_app/assets/reactions/dotto/smiling_face.svg new file mode 100644 index 0000000..2c47d08 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/smiling_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/smiling_face_with_halo.svg b/sojorn_app/assets/reactions/dotto/smiling_face_with_halo.svg new file mode 100644 index 0000000..14b80af --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/smiling_face_with_halo.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/smiling_face_with_hearts.svg b/sojorn_app/assets/reactions/dotto/smiling_face_with_hearts.svg new file mode 100644 index 0000000..4fa36bd --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/smiling_face_with_hearts.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/sparkling_heart.svg b/sojorn_app/assets/reactions/dotto/sparkling_heart.svg new file mode 100644 index 0000000..b1597ab --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/sparkling_heart.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/sunglasses_face.svg b/sojorn_app/assets/reactions/dotto/sunglasses_face.svg new file mode 100644 index 0000000..f529fa4 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/sunglasses_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/upside_down_face.svg b/sojorn_app/assets/reactions/dotto/upside_down_face.svg new file mode 100644 index 0000000..f75d113 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/upside_down_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/winking_face.svg b/sojorn_app/assets/reactions/dotto/winking_face.svg new file mode 100644 index 0000000..0fe1ee2 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/winking_face.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/worried_face.svg b/sojorn_app/assets/reactions/dotto/worried_face.svg new file mode 100644 index 0000000..8ea3088 --- /dev/null +++ b/sojorn_app/assets/reactions/dotto/worried_face.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sojorn_app/assets/reactions/green/credit.md b/sojorn_app/assets/reactions/green/credit.md new file mode 100644 index 0000000..fe5a2c6 --- /dev/null +++ b/sojorn_app/assets/reactions/green/credit.md @@ -0,0 +1,11 @@ +# Green Reaction Set + +**Source:** Custom created for Sojorn + +**Description:** Green-themed reaction icons with transparency + +**Format:** PNG (64x64px recommended) + +**Style:** Clean, modern green color scheme + +**Usage:** Perfect for nature, growth, and positive reactions diff --git a/sojorn_app/assets/reactions/purple/credit.md b/sojorn_app/assets/reactions/purple/credit.md new file mode 100644 index 0000000..e0c743b --- /dev/null +++ b/sojorn_app/assets/reactions/purple/credit.md @@ -0,0 +1,11 @@ +# Purple Reaction Set + +**Source:** Custom created for Sojorn + +**Description:** Purple-themed reaction icons with transparency + +**Format:** PNG (64x64px recommended) + +**Style:** Clean, modern purple color scheme + +**Usage:** Ideal for creative, elegant, and expressive reactions diff --git a/sojorn_app/assets/reactions/reaction_config.json b/sojorn_app/assets/reactions/reaction_config.json new file mode 100644 index 0000000..9c6700a --- /dev/null +++ b/sojorn_app/assets/reactions/reaction_config.json @@ -0,0 +1,75 @@ +{ + "reaction_sets": { + "emoji": { + "type": "emoji", + "reactions": [ + "❤️", + "👍", + "😂", + "😮", + "😢", + "😡", + "🎉", + "🔥", + "👏", + "🙏", + "💯", + "🤔", + "😍", + "🤣", + "😊", + "👌", + "🙌", + "💪", + "🎯", + "⭐", + "✨", + "🌟", + "💫", + "☀️" + ] + }, + "dotto": { + "type": "folder", + "folder": "dotto", + "file_types": [ + "svg" + ], + "files": [ + "anguished_face.svg", + "beaming_face.svg", + "blue_heart.svg", + "broken_heart.svg", + "disappointed_face.svg", + "downcast_face.svg", + "face_with_monocle.svg", + "face_with_tears.svg", + "fearful_face.svg", + "ghost.svg", + "green_heart.svg", + "grinning_face.svg", + "heart_with_arrow.svg", + "laughing_face.svg", + "loudly_crying_face.svg", + "melting_face.svg", + "nerd_face.svg", + "party_face.svg", + "purple_heart.svg", + "robot_face.svg", + "rolling_face.svg", + "skull.svg", + "slightly_smiling_face.svg", + "smiling_face.svg", + "smiling_face_with_halo.svg", + "smiling_face_with_hearts.svg", + "sparkling_heart.svg", + "sunglasses_face.svg", + "upside_down_face.svg", + "winking_face.svg", + "worried_face.svg" + ] + } + }, + "generated_at": "2026-02-01T15:16:54.716500", + "total_sets": 2 +} \ No newline at end of file diff --git a/sojorn_app/lib/models/sojorn_media_result.dart b/sojorn_app/lib/models/sojorn_media_result.dart index ca8a80e..b7e8e12 100644 --- a/sojorn_app/lib/models/sojorn_media_result.dart +++ b/sojorn_app/lib/models/sojorn_media_result.dart @@ -12,13 +12,17 @@ class SojornMediaResult { /// The file path (used for mobile/desktop operations) final String? filePath; - /// Media type ('image' or 'video') + /// The thumbnail file path (used for beacon images) + final String? thumbnailPath; + + /// Media type ('image', 'video', or 'beacon_image') final String mediaType; const SojornMediaResult({ this.bytes, this.name, this.filePath, + this.thumbnailPath, required this.mediaType, }) : assert(bytes != null || filePath != null, 'Either bytes or filePath must be provided'); @@ -50,8 +54,24 @@ class SojornMediaResult { ); } + /// Creates a result for beacon image with dual outputs + factory SojornMediaResult.beaconImage({ + String? filePath, + String? thumbnailPath, + String? name, + }) { + return SojornMediaResult( + filePath: filePath, + thumbnailPath: thumbnailPath, + name: name, + mediaType: 'beacon_image', + ); + } + bool get isImage => mediaType == 'image'; bool get isVideo => mediaType == 'video'; + bool get isBeaconImage => mediaType == 'beacon_image'; bool get hasBytes => bytes != null; bool get hasFilePath => filePath != null; + bool get hasThumbnail => thumbnailPath != null; } diff --git a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart index 3f5024c..3ad0c5f 100644 --- a/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart +++ b/sojorn_app/lib/screens/beacon/beacon_bottom_sheet.dart @@ -51,7 +51,7 @@ class _BeaconBottomSheetState extends ConsumerState { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: statusColor.withOpacity(0.2), + color: statusColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), border: Border.all(color: statusColor), ), @@ -76,7 +76,7 @@ class _BeaconBottomSheetState extends ConsumerState { Text( post.beaconType?.displayName ?? 'Community', style: theme.textTheme.labelMedium?.copyWith( - color: theme.textTheme.bodySmall?.color?.withOpacity(0.7), + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7), ), ), const SizedBox(height: 4), @@ -92,10 +92,15 @@ class _BeaconBottomSheetState extends ConsumerState { CircleAvatar( radius: 16, backgroundColor: accentColor, - child: Text( - (post.author?.displayName ?? post.author?.handle ?? '?')[0].toUpperCase(), - style: const TextStyle(color: Colors.white), - ), + backgroundImage: (post.author?.avatarUrl != null && post.author!.avatarUrl!.isNotEmpty) + ? NetworkImage(post.author!.avatarUrl!) + : null, + child: (post.author?.avatarUrl == null || post.author!.avatarUrl!.isEmpty) + ? Text( + ((post.author?.displayName ?? post.author?.handle ?? '?')[0]).toUpperCase(), + style: const TextStyle(color: Colors.white), + ) + : null, ), const SizedBox(width: 12), Column( @@ -110,7 +115,7 @@ class _BeaconBottomSheetState extends ConsumerState { Text( '${_getFormattedDistance(post.distanceMeters)} • ${_getTimeAgo(post.createdAt)}', style: theme.textTheme.bodySmall?.copyWith( - color: theme.textTheme.bodySmall?.color?.withOpacity(0.6), + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), ), ), ], @@ -157,7 +162,7 @@ class _BeaconBottomSheetState extends ConsumerState { child: LinearProgressIndicator( value: confidenceScore, minHeight: 6, - backgroundColor: Colors.grey.withOpacity(0.3), + backgroundColor: Colors.grey.withValues(alpha: 0.3), valueColor: AlwaysStoppedAnimation(statusColor), ), ), @@ -165,7 +170,7 @@ class _BeaconBottomSheetState extends ConsumerState { Text( 'Confidence ${(confidenceScore * 100).toStringAsFixed(0)}%', style: theme.textTheme.bodySmall?.copyWith( - color: theme.textTheme.bodySmall?.color?.withOpacity(0.6), + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), ), ), ], diff --git a/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart new file mode 100644 index 0000000..ece1690 --- /dev/null +++ b/sojorn_app/lib/screens/beacon/beacon_detail_screen.dart @@ -0,0 +1,551 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/post.dart'; +import '../../models/beacon.dart'; +import '../../providers/api_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/sojorn_app_bar.dart'; + +class BeaconDetailScreen extends ConsumerStatefulWidget { + final Post beaconPost; + + const BeaconDetailScreen({super.key, required this.beaconPost}); + + @override + ConsumerState createState() => _BeaconDetailScreenState(); +} + +class _BeaconDetailScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _pulseController; + late Animation _pulseAnimation; + bool _isVouching = false; + bool _isReporting = false; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + + final beacon = widget.beaconPost.toBeacon(); + final confidenceScore = beacon.confidenceScore; + + if (confidenceScore < 0.3) { + // Low confidence - add warning pulse + _pulseAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: _pulseController, + curve: Curves.easeInOut, + )); + _pulseController.repeat(reverse: true); + } else if (confidenceScore > 0.7) { + // High confidence - solid, no pulse + _pulseAnimation = Tween( + begin: 1.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _pulseController, + curve: Curves.linear, + )); + } else { + // Medium confidence - subtle pulse + _pulseAnimation = Tween( + begin: 0.9, + end: 1.0, + ).animate(CurvedAnimation( + parent: _pulseController, + curve: Curves.easeInOut, + )); + _pulseController.repeat(reverse: true); + } + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + Beacon get _beacon => widget.beaconPost.toBeacon(); + Post get _post => widget.beaconPost; + + Color get _statusColor { + switch (_beacon.status) { + case BeaconStatus.green: + return Colors.green; + case BeaconStatus.yellow: + return Colors.orange; + case BeaconStatus.red: + return Colors.red; + } + } + + String get _statusLabel { + switch (_beacon.status) { + case BeaconStatus.green: + return 'Verified'; + case BeaconStatus.yellow: + return 'Caution'; + case BeaconStatus.red: + return 'Unverified'; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + // Immersive Header with Image + _buildImmersiveHeader(), + + // Content + SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + color: AppTheme.scaffoldBg, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatusSection(), + _buildBeaconInfo(), + _buildAuthorInfo(), + const SizedBox(height: 32), + _buildActionButtons(), + const SizedBox(height: 24), + _buildConfidenceIndicator(), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildImmersiveHeader() { + final hasImage = _post.imageUrl != null && _post.imageUrl!.isNotEmpty; + + return SliverAppBar( + expandedHeight: hasImage ? 300 : 120, + pinned: true, + backgroundColor: Colors.transparent, + leading: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + ), + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + if (hasImage) + Hero( + tag: 'beacon-image-${_post.id}', + child: Image.network( + _post.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildFallbackImage(); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: AppTheme.navyBlue, + child: Center( + child: CircularProgressIndicator( + color: Colors.white, + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ), + ) + else + _buildFallbackImage(), + + // Gradient overlay for text readability + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + Colors.black.withValues(alpha: 0.3), + ], + ), + ), + ), + + // Status indicator with pulse + Positioned( + top: 60, + right: 16, + child: AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _statusColor.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: _statusColor.withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 2, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _beacon.status == BeaconStatus.green + ? Icons.verified + : Icons.warning, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 4), + Text( + _statusLabel, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildFallbackImage() { + return Container( + color: AppTheme.navyBlue, + child: Icon( + _beacon.beaconType.icon, + color: Colors.white, + size: 80, + ), + ); + } + + Widget _buildStatusSection() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _beacon.beaconType.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _beacon.beaconType.icon, + color: _beacon.beaconType.color, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _beacon.beaconType.displayName, + style: AppTheme.headlineSmall, + ), + Text( + '${_beacon.getFormattedDistance()} away', + style: AppTheme.bodyMedium?.copyWith( + color: AppTheme.textDisabled, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + _post.body, + style: AppTheme.postBody, + ), + ], + ), + ); + } + + Widget _buildBeaconInfo() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Icon( + Icons.access_time, + color: AppTheme.textDisabled, + size: 16, + ), + const SizedBox(width: 4), + Text( + _beacon.getTimeAgo(), + style: AppTheme.bodyMedium?.copyWith( + color: AppTheme.textDisabled, + ), + ), + const Spacer(), + if (_post.latitude != null && _post.longitude != null) ...[ + Icon( + Icons.location_on, + color: AppTheme.textDisabled, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Location tagged', + style: AppTheme.bodyMedium?.copyWith( + color: AppTheme.textDisabled, + ), + ), + ], + ], + ), + ); + } + + Widget _buildAuthorInfo() { + final author = _post.author; + if (author == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: AppTheme.egyptianBlue, + backgroundImage: (author.avatarUrl != null && author.avatarUrl!.isNotEmpty) + ? NetworkImage(author.avatarUrl!) + : null, + child: (author.avatarUrl == null || author.avatarUrl!.isEmpty) + ? Text( + ((author.displayName ?? author.handle ?? '?')[0]).toUpperCase(), + style: const TextStyle(color: Colors.white), + ) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + author.displayName ?? '@${author.handle ?? 'unknown'}', + style: AppTheme.headlineSmall, + ), + if (author.handle != null && author.handle!.isNotEmpty) + Text( + '@${author.handle}', + style: AppTheme.bodyMedium?.copyWith( + color: AppTheme.textDisabled, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // Vouch Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isVouching ? null : () => _vouchBeacon(), + icon: _isVouching + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.thumb_up), + label: Text(_isVouching ? 'Confirming...' : 'Vouch for this Beacon'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 12), + // Report Button + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isReporting ? null : () => _reportBeacon(), + icon: _isReporting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.red, + ), + ) + : const Icon(Icons.thumb_down), + label: Text(_isReporting ? 'Reporting...' : 'Report Issue'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildConfidenceIndicator() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Community Confidence', + style: AppTheme.labelMedium, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: _beacon.confidenceScore, + minHeight: 8, + backgroundColor: Colors.grey.withValues(alpha: 0.3), + valueColor: AlwaysStoppedAnimation(_statusColor), + ), + ), + const SizedBox(height: 4), + Text( + 'Confidence ${(_beacon.confidenceScore * 100).toStringAsFixed(0)}%', + style: AppTheme.labelSmall?.copyWith( + color: AppTheme.textDisabled, + ), + ), + ], + ), + ); + } + + Future _vouchBeacon() async { + final apiService = ref.read(apiServiceProvider); + setState(() => _isVouching = true); + + try { + await apiService.vouchBeacon(_post.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Thanks for confirming this beacon!'), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Something went wrong: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isVouching = false); + } + } + + Future _reportBeacon() async { + final apiService = ref.read(apiServiceProvider); + setState(() => _isReporting = true); + + try { + await apiService.reportBeacon(_post.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Report received. Thanks for keeping the community safe.'), + backgroundColor: Colors.orange, + ), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Something went wrong: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isReporting = false); + } + } +} diff --git a/sojorn_app/lib/screens/beacon/beacon_screen.dart b/sojorn_app/lib/screens/beacon/beacon_screen.dart index 3448be6..c2b5120 100644 --- a/sojorn_app/lib/screens/beacon/beacon_screen.dart +++ b/sojorn_app/lib/screens/beacon/beacon_screen.dart @@ -11,6 +11,7 @@ import '../../services/local_intel_service.dart'; import '../../theme/app_theme.dart'; import 'package:go_router/go_router.dart'; import 'beacon_bottom_sheet.dart'; +import 'beacon_detail_screen.dart'; import 'create_beacon_sheet.dart'; import 'widgets/intel_cards.dart'; import 'widgets/resources_sheet.dart'; @@ -184,10 +185,10 @@ class _BeaconScreenState extends ConsumerState { } void _onMarkerTap(Post post) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => BeaconBottomSheet(post: post), + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BeaconDetailScreen(beaconPost: post), + ), ); } @@ -692,6 +693,7 @@ class _BeaconScreenState extends ConsumerState { Marker _createMarker(Post post) { final typeColor = post.beaconType?.color ?? Colors.blue; final typeIcon = post.beaconType?.icon ?? Icons.location_on; + final confidenceScore = post.confidenceScore ?? 0.5; final fallbackBase = _userLocation ?? _mapCenter; @@ -708,16 +710,10 @@ class _BeaconScreenState extends ConsumerState { height: 48, child: GestureDetector( onTap: () => _onMarkerTap(post), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: typeColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), blurRadius: 8), - ], - ), - child: Icon(typeIcon, color: Colors.white, size: 28), + child: _BeaconMarker( + color: typeColor, + icon: typeIcon, + confidenceScore: confidenceScore, ), ), ); @@ -733,6 +729,87 @@ class _BeaconScreenState extends ConsumerState { } } +class _BeaconMarker extends StatefulWidget { + final Color color; + final IconData icon; + final double confidenceScore; + + const _BeaconMarker({ + required this.color, + required this.icon, + required this.confidenceScore, + }); + + @override + State<_BeaconMarker> createState() => _BeaconMarkerState(); +} + +class _BeaconMarkerState extends State<_BeaconMarker> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + + if (widget.confidenceScore < 0.3) { + // Low confidence - warning pulse + _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _controller.repeat(reverse: true); + } else if (widget.confidenceScore > 0.7) { + // High confidence - solid, no pulse + _pulseAnimation = Tween(begin: 1.0, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.linear), + ); + } else { + // Medium confidence - subtle pulse + _pulseAnimation = Tween(begin: 0.9, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _controller.repeat(reverse: true); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Transform.scale( + scale: _pulseAnimation.value, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: widget.confidenceScore < 0.3 ? 2 : 0, + ), + ], + ), + child: Icon(widget.icon, color: Colors.white, size: 28), + ), + ); + }, + ); + } +} + class _PulsingLocationIndicator extends StatefulWidget { @override State<_PulsingLocationIndicator> createState() => diff --git a/sojorn_app/lib/screens/compose/image_editor_screen.dart b/sojorn_app/lib/screens/compose/image_editor_screen.dart index 7d07f1e..cb13bc2 100644 --- a/sojorn_app/lib/screens/compose/image_editor_screen.dart +++ b/sojorn_app/lib/screens/compose/image_editor_screen.dart @@ -14,12 +14,16 @@ class sojornImageEditor extends StatelessWidget { final String? imagePath; final Uint8List? imageBytes; final String? imageName; + final bool isBeacon; + final String? postType; const sojornImageEditor({ super.key, this.imagePath, this.imageBytes, this.imageName, + this.isBeacon = false, + this.postType, }) : assert(imagePath != null || imageBytes != null); static const Color _matteBlack = Color(0xFF0B0B0B); @@ -63,6 +67,9 @@ class sojornImageEditor extends StatelessWidget { } ProImageEditorConfigs _buildConfigs() { + // Determine aspect ratio based on post type + final aspectRatios = _getAspectRatios(); + return ProImageEditorConfigs( theme: _buildEditorTheme(), imageEditorTheme: ImageEditorTheme( @@ -156,7 +163,7 @@ class sojornImageEditor extends StatelessWidget { ), TextButton( onPressed: editor.doneEditing, - child: const Text('Save'), + child: Text(isBeacon ? 'Save Beacon' : 'Save'), ), const SizedBox(width: 6), ], @@ -168,6 +175,28 @@ class sojornImageEditor extends StatelessWidget { ); } + Map _getAspectRatios() { + if (isBeacon) { + // Beacon: Allow original and free ratios to preserve context + return { + 'options': const [ + 'original', + 'free', + '4:5', + '16:9', + ], + }; + } else { + // Main Feed: Lock to square for consistency + return { + 'options': const [ + '1:1', + 'free', + ], + }; + } + } + @override Widget build(BuildContext context) { if (imageBytes != null) { @@ -227,14 +256,33 @@ class sojornImageEditor extends StatelessWidget { final file = File('${tempDir.path}/$fileName'); await file.writeAsBytes(editedBytes); - if (!context.mounted) return; - Navigator.pop( - context, - SojornMediaResult.image( - filePath: file.path, - name: fileName, - ), - ); + // Generate dual outputs for beacons + if (isBeacon) { + final thumbnailFileName = 'sojorn_thumb_$timestamp.jpg'; + final thumbnailFile = File('${tempDir.path}/$thumbnailFileName'); + + // Create 300x300 thumbnail + await _createThumbnail(editedBytes, thumbnailFile); + + if (!context.mounted) return; + Navigator.pop( + context, + SojornMediaResult.beaconImage( + filePath: file.path, + thumbnailPath: thumbnailFile.path, + name: fileName, + ), + ); + } else { + if (!context.mounted) return; + Navigator.pop( + context, + SojornMediaResult.image( + filePath: file.path, + name: fileName, + ), + ); + } } catch (e) { if (!context.mounted) return; Navigator.pop( @@ -250,6 +298,27 @@ class sojornImageEditor extends StatelessWidget { ); } + Future _createThumbnail(Uint8List originalBytes, File thumbnailFile) async { + // This is a placeholder for thumbnail generation + // In a real implementation, you would use an image processing library + // like 'image' package to resize the image to 300x300 + try { + // For now, just write the original bytes as a placeholder + await thumbnailFile.writeAsBytes(originalBytes); + + // TODO: Implement proper thumbnail generation using image package + // Example (requires adding 'image' package to pubspec.yaml): + // import 'package:image/image.dart' as img; + // + // final image = img.decodeImage(originalBytes)!; + // final thumbnail = img.copyResize(image, width: 300, height: 300); + // await thumbnailFile.writeAsBytes(img.encodeJpg(thumbnail, quality: 85)); + } catch (e) { + // Fallback to original bytes if thumbnail generation fails + await thumbnailFile.writeAsBytes(originalBytes); + } + } + Widget _buildLoading() { return Scaffold( backgroundColor: _matteBlack, diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index ffd575f..dd3d2ef 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -5,12 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:timeago/timeago.dart' as timeago; +import '../../widgets/reactions/reaction_picker.dart'; +import '../../widgets/reactions/reaction_strip.dart'; import '../../models/post.dart'; import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; import '../../widgets/post/interactive_reply_block.dart'; import '../../widgets/media/signed_media_image.dart'; -import '../../widgets/reactions/reaction_strip.dart'; import '../compose/compose_screen.dart'; class ThreadedConversationScreen extends ConsumerStatefulWidget { @@ -945,33 +946,36 @@ class _ThreadedConversationScreenState extends ConsumerState _openReactionPicker(String postId) async { - final selection = await showReactionPicker( - context, - baseItems: const [ - ReactionItem(id: '👍', label: 'thumbs up'), - ReactionItem(id: '❤️', label: 'heart'), - ReactionItem(id: '😂', label: 'laugh'), - ReactionItem(id: '😮', label: 'wow'), - ReactionItem(id: '😢', label: 'sad'), - ReactionItem(id: '😡', label: 'angry'), - ReactionItem(id: '🙏', label: 'thanks'), - ReactionItem(id: '🔥', label: 'fire'), - ReactionItem(id: '🎉', label: 'party'), - ReactionItem(id: '👀', label: 'eyes'), - ReactionItem(id: '🤝', label: 'handshake'), - ReactionItem(id: '✅', label: 'check'), - ReactionItem(id: '🌿', label: 'leaf'), - ReactionItem(id: '✨', label: 'sparkles'), - ReactionItem(id: '🧠', label: 'brain'), - ReactionItem(id: '🫶', label: 'care'), - ], - ); - - if (selection == null || selection.isEmpty) return; - _toggleReaction(postId, selection); + Post? _findPostById(String postId) { + if (widget.rootPost?.id == postId) return widget.rootPost; + + // Search in focus context + if (_focusContext != null) { + if (_focusContext!.targetPost.id == postId) return _focusContext!.targetPost; + for (final post in _focusContext!.children) { + if (post.id == postId) return post; + } + } + + return null; } + Future _openReactionPicker(String postId) async { + final post = _findPostById(postId); + if (post == null) return; + + showDialog( + context: context, + builder: (context) => ReactionPicker( + onReactionSelected: (emoji) { + _toggleReaction(postId, emoji); + }, + reactionCounts: _reactionCountsFor(post), + myReactions: _myReactionsFor(post), + ), + ); +} + void _seedReactionState(FocusContext focusContext) { _seedReactionsForPost(focusContext.targetPost); if (focusContext.parentPost != null) { diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 9ab0d27..8bc3b7d 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import '../models/category.dart'; +import 'package:flutter/foundation.dart'; +import '../models/category.dart' as models; import '../models/profile.dart'; import '../models/follow_request.dart'; import '../models/profile_privacy_settings.dart'; @@ -13,6 +14,8 @@ import '../config/api_config.dart'; import '../services/auth_service.dart'; import '../models/search_results.dart'; import '../models/tone_analysis.dart'; +import '../utils/security_utils.dart'; +import '../utils/request_signing.dart'; import 'package:http/http.dart' as http; /// ApiService - Single source of truth for all backend communication. @@ -59,6 +62,7 @@ class ApiService { String method = 'POST', Map? queryParams, Object? body, + bool requireSignature = false, }) async { try { var uri = Uri.parse('${ApiConfig.baseUrl}$path') @@ -66,18 +70,30 @@ class ApiService { var headers = await _authHeaders(); headers['Content-Type'] = 'application/json'; + // Add request signature for critical operations + if (requireSignature && body != null) { + final secretKey = _authService.currentUser?.id ?? 'default'; + headers = RequestSigning.addSignatureHeaders( + headers, + method, + path, + body as Map?, + secretKey, + ); + } + http.Response response = await _performRequest(method, uri, headers, body); // INTERCEPTOR: Handle 401 if (response.statusCode == 401) { - print('[API] 401 Unauthorized at $path. Attempting refresh...'); + if (kDebugMode) print('[API] Auth error at ${_sanitizePath(path)}'); final refreshed = await _authService.refreshSession(); if (refreshed) { // Update token header and RETRY headers = await _authHeaders(); headers['Content-Type'] = 'application/json'; - print('[API] Retrying request to $path with new token...'); + if (kDebugMode) print('[API] Retrying request to ${_sanitizePath(path)}'); response = await _performRequest(method, uri, headers, body); } else { // Refresh failed, assume session died. @@ -86,6 +102,7 @@ class ApiService { } if (response.statusCode >= 400) { + if (kDebugMode) print('[API] Error ${response.statusCode} at ${_sanitizePath(path)}'); throw Exception( 'Go API error (${response.statusCode}): ${response.body}'); } @@ -95,7 +112,7 @@ class ApiService { if (data is Map) return data; return {'data': data}; } catch (e) { - print('Go API call to $path failed: $e'); + if (kDebugMode) print('[API] Call failed: ${_sanitizePath(path)} - $e'); rethrow; } } @@ -129,9 +146,22 @@ class ApiService { return { 'Authorization': 'Bearer $token', + 'X-Rate-Limit-Remaining': '100', + 'X-Rate-Limit-Reset': '3600', + 'X-Request-ID': _generateRequestId(), }; } + /// Sanitize path for logging by removing sensitive IDs + String _sanitizePath(String path) { + return path.replaceAll(RegExp(r'/[a-zA-Z0-9_-]{20,}'), '/***'); + } + + /// Generate unique request ID for tracking + String _generateRequestId() { + return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecond}'; + } + List> _normalizeListResponse(dynamic response) { if (response == null) return []; if (response is List) { @@ -153,14 +183,14 @@ class ApiService { // Category & Onboarding // ========================================================================= - Future> getCategories() async { + Future> getCategories() async { final data = await _callGoApi('/categories', method: 'GET'); return (data['categories'] as List) - .map((json) => Category.fromJson(json)) + .map((json) => models.Category.fromJson(json)) .toList(); } - Future> getEnabledCategories() async { + Future> getEnabledCategories() async { final categories = await getCategories(); final enabledIds = await _getEnabledCategoryIds(); @@ -191,7 +221,7 @@ class ApiService { } Future setUserCategorySettings({ - required List categories, + required List categories, required Set enabledCategoryIds, }) async { final settings = categories @@ -232,18 +262,30 @@ class ApiService { required String displayName, String? bio, }) async { + // Validate and sanitize inputs + if (!SecurityUtils.isValidHandle(handle)) { + throw ArgumentError('Invalid handle format'); + } + if (!SecurityUtils.isValidEmail(_authService.currentUser?.email ?? '')) { + throw ArgumentError('Invalid user email'); + } + + final sanitizedHandle = SecurityUtils.sanitizeText(handle); + final sanitizedDisplayName = SecurityUtils.sanitizeText(displayName); + final sanitizedBio = bio != null ? SecurityUtils.sanitizeText(bio) : null; + // Legacy support: still calls generic 'signup' but via auth flow in AuthService usually. // Making this use the endpoint just in case called directly. + // Adjust based on backend. If this requires token, it's fine. + // A 'create profile' usually happens after 'auth register'. + // If this is the 'onboarding' step for a user who exists but has no profile: final data = await _callGoApi( - '/auth/signup', // Note: auth routes are usually public, but this helper might assume auth header. - // Adjust based on backend. If this requires token, it's fine. - // A 'create profile' usually happens after 'auth register'. - // If this is the 'onboarding' step for a user who exists but has no profile: - method: 'POST', + '/profile', // Changed from '/auth/signup' to '/profile' + method: 'POST', // Changed from 'POST' to 'POST' body: { - 'handle': handle, - 'display_name': displayName, - if (bio != null) 'bio': bio, + 'handle': sanitizedHandle, + 'display_name': sanitizedDisplayName, + if (sanitizedBio != null) 'bio': sanitizedBio, }, ); @@ -298,23 +340,38 @@ class ApiService { int? registrationId, String? encryptedPrivateKey, }) async { + // Validate and sanitize inputs + if (handle != null && !SecurityUtils.isValidHandle(handle)) { + throw ArgumentError('Invalid handle format'); + } + + final sanitizedHandle = handle != null ? SecurityUtils.sanitizeText(handle) : null; + final sanitizedDisplayName = displayName != null ? SecurityUtils.sanitizeText(displayName) : null; + final sanitizedBio = bio != null ? SecurityUtils.sanitizeText(bio) : null; + final sanitizedLocation = location != null ? SecurityUtils.sanitizeText(location) : null; + final sanitizedWebsite = website != null ? SecurityUtils.sanitizeUrl(website) : null; + final sanitizedInterests = interests?.map((i) => SecurityUtils.sanitizeText(i)).toList(); + final sanitizedAvatarUrl = avatarUrl != null ? SecurityUtils.sanitizeUrl(avatarUrl) : null; + final sanitizedCoverUrl = coverUrl != null ? SecurityUtils.sanitizeUrl(coverUrl) : null; + final data = await _callGoApi( '/profile', method: 'PATCH', body: { - if (handle != null) 'handle': handle, - if (displayName != null) 'display_name': displayName, - if (bio != null) 'bio': bio, - if (location != null) 'location': location, - if (website != null) 'website': website, - if (interests != null) 'interests': interests, - if (avatarUrl != null) 'avatar_url': avatarUrl, - if (coverUrl != null) 'cover_url': coverUrl, + if (sanitizedHandle != null) 'handle': sanitizedHandle, + if (sanitizedDisplayName != null) 'display_name': sanitizedDisplayName, + if (sanitizedBio != null) 'bio': sanitizedBio, + if (sanitizedLocation != null) 'location': sanitizedLocation, + if (sanitizedWebsite != null) 'website': sanitizedWebsite, + if (sanitizedInterests != null) 'interests': sanitizedInterests, + if (sanitizedAvatarUrl != null) 'avatar_url': sanitizedAvatarUrl, + if (sanitizedCoverUrl != null) 'cover_url': sanitizedCoverUrl, if (identityKey != null) 'identity_key': identityKey, if (registrationId != null) 'registration_id': registrationId, if (encryptedPrivateKey != null) 'encrypted_private_key': encryptedPrivateKey, }, + requireSignature: true, ); return Profile.fromJson(data['profile']); @@ -490,18 +547,41 @@ class ApiService { double? long, bool userWarned = false, }) async { + // Validate and sanitize inputs + if (body.isEmpty) { + throw ArgumentError('Post body cannot be empty'); + } + + final sanitizedBody = SecurityUtils.limitText(SecurityUtils.sanitizeText(body), maxLength: 5000); + final sanitizedImageUrl = imageUrl != null ? SecurityUtils.sanitizeUrl(imageUrl) : null; + final sanitizedVideoUrl = videoUrl != null ? SecurityUtils.sanitizeUrl(videoUrl) : null; + final sanitizedThumbnailUrl = thumbnailUrl != null ? SecurityUtils.sanitizeUrl(thumbnailUrl) : null; + + // Validate coordinates for beacons + if (isBeacon && (lat == null || long == null)) { + throw ArgumentError('Beacon posts require latitude and longitude'); + } + + if (lat != null && (lat < -90 || lat > 90)) { + throw ArgumentError('Invalid latitude range'); + } + + if (long != null && (long < -180 || long > 180)) { + throw ArgumentError('Invalid longitude range'); + } + final data = await _callGoApi( '/posts', method: 'POST', body: { 'category_id': categoryId, - 'body': body, + 'body': sanitizedBody, 'body_format': bodyFormat, 'allow_chain': allowChain, if (chainParentId != null) 'chain_parent_id': chainParentId, - if (imageUrl != null) 'image_url': imageUrl, - if (videoUrl != null) 'video_url': videoUrl, - if (thumbnailUrl != null) 'thumbnail_url': thumbnailUrl, + if (sanitizedImageUrl != null) 'image_url': sanitizedImageUrl, + if (sanitizedVideoUrl != null) 'video_url': sanitizedVideoUrl, + if (sanitizedThumbnailUrl != null) 'thumbnail_url': sanitizedThumbnailUrl, if (durationMs != null) 'duration_ms': durationMs, if (ttlHours != null) 'ttl_hours': ttlHours, if (isBeacon) 'is_beacon': true, @@ -510,6 +590,7 @@ class ApiService { if (long != null) 'beacon_long': long, if (userWarned) 'user_warned': true, }, + requireSignature: true, ); return Post.fromJson(data['post']); @@ -543,10 +624,17 @@ class ApiService { required String postId, required String content, }) async { + // Validate and sanitize input + if (content.isEmpty) { + throw ArgumentError('Content cannot be empty'); + } + + final sanitizedContent = SecurityUtils.limitText(SecurityUtils.sanitizeText(content), maxLength: 5000); + await _callGoApi( '/posts/$postId', method: 'PATCH', - body: {'body': content}, + body: {'body': sanitizedContent}, ); } @@ -859,11 +947,22 @@ class ApiService { } Future search(String query) async { + // Validate and sanitize search query + if (query.isEmpty) { + return SearchResults(users: [], tags: [], posts: []); + } + + final sanitizedQuery = SecurityUtils.limitText(SecurityUtils.sanitizeText(query), maxLength: 100); + + if (!SecurityUtils.isValidInput(sanitizedQuery)) { + return SearchResults(users: [], tags: [], posts: []); + } + try { final data = await callGoApi( '/search', method: 'GET', - queryParams: {'q': query}, + queryParams: {'q': sanitizedQuery}, ); return SearchResults.fromJson(data); } catch (_) { @@ -873,13 +972,34 @@ class ApiService { } Future checkTone(String text, {String? imageUrl}) async { + // Validate and sanitize inputs + if (text.isEmpty) { + return ToneCheckResult( + flagged: false, + category: null, + flags: [], + reason: 'Empty text provided'); + } + + final sanitizedText = SecurityUtils.limitText(SecurityUtils.sanitizeText(text), maxLength: 2000); + final sanitizedImageUrl = imageUrl != null ? SecurityUtils.sanitizeUrl(imageUrl) : null; + + // Check for XSS in text + if (SecurityUtils.containsXSS(sanitizedText)) { + return ToneCheckResult( + flagged: true, + category: ModerationCategory.nsfw, + flags: ['xss_detected'], + reason: 'Potentially dangerous content detected'); + } + try { final data = await callGoApi( '/analysis/tone', method: 'POST', body: { - 'text': text, - if (imageUrl != null) 'image_url': imageUrl, + 'text': sanitizedText, + if (sanitizedImageUrl != null) 'image_url': sanitizedImageUrl, }, ); return ToneCheckResult.fromJson(data); diff --git a/sojorn_app/lib/utils/request_signing.dart b/sojorn_app/lib/utils/request_signing.dart new file mode 100644 index 0000000..55825b5 --- /dev/null +++ b/sojorn_app/lib/utils/request_signing.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; + +/// Request signing utility for critical API operations +class RequestSigning { + /// Generate HMAC-SHA256 signature for request + static String signRequest( + String method, + String path, + Map? body, + String timestamp, + String secretKey, + ) { + // Create canonical request + final canonicalRequest = _buildCanonicalRequest(method, path, body, timestamp); + + // Generate HMAC-SHA256 signature + final key = utf8.encode(secretKey); + final hmac = Hmac(sha256, key); + final digest = hmac.convert(utf8.encode(canonicalRequest)); + + return digest.toString(); + } + + /// Build canonical request string for signing + static String _buildCanonicalRequest( + String method, + String path, + Map? body, + String timestamp, + ) { + final parts = [ + method.toUpperCase(), + path, + timestamp, + ]; + + if (body != null && body.isNotEmpty) { + final sortedBody = Map.fromEntries( + body.entries.toList()..sort((a, b) => a.key.compareTo(b.key)) + ); + parts.add(jsonEncode(sortedBody)); + } + + return parts.join('\n'); + } + + /// Verify request signature + static bool verifySignature( + String method, + String path, + Map? body, + String timestamp, + String signature, + String secretKey, + ) { + final expectedSignature = signRequest(method, path, body, timestamp, secretKey); + return _constantTimeEquals(signature, expectedSignature); + } + + /// Constant-time comparison to prevent timing attacks + static bool _constantTimeEquals(String a, String b) { + if (a.length != b.length) return false; + + int result = 0; + for (int i = 0; i < a.length; i++) { + result |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + + return result == 0; + } + + /// Generate timestamp for request signing + static String generateTimestamp() { + return DateTime.now().toUtc().toIso8601String().replaceAll('.', '').replaceAll('Z', ''); + } + + /// Add signature headers to request + static Map addSignatureHeaders( + Map headers, + String method, + String path, + Map? body, + String secretKey, + ) { + final timestamp = generateTimestamp(); + final signature = signRequest(method, path, body, timestamp, secretKey); + + final signedHeaders = Map.from(headers); + signedHeaders['X-Timestamp'] = timestamp; + signedHeaders['X-Signature'] = signature; + signedHeaders['X-Algorithm'] = 'HMAC-SHA256'; + + return signedHeaders; + } +} diff --git a/sojorn_app/lib/utils/security_utils.dart b/sojorn_app/lib/utils/security_utils.dart new file mode 100644 index 0000000..4ad7dec --- /dev/null +++ b/sojorn_app/lib/utils/security_utils.dart @@ -0,0 +1,141 @@ +/// Security utilities for input validation and sanitization +class SecurityUtils { + /// Sanitize user-generated text content + static String sanitizeText(String input) { + if (input.isEmpty) return input; + + // Remove potentially dangerous characters + String sanitized = input + .replaceAll(RegExp(r']*>.*?'), '') // Remove script tags + .replaceAll(RegExp(r']*>.*?'), '') // Remove iframe tags + .replaceAll(RegExp(r']*>.*?'), '') // Remove object tags + .replaceAll(RegExp(r']*>.*?'), '') // Remove embed tags + .replaceAll(RegExp(r']*>.*?'), '') // Remove link tags + .replaceAll(RegExp(r']*>.*?'), '') // Remove meta tags + .replaceAll(RegExp(r'javascript:'), '') // Remove javascript: protocol + .replaceAll(RegExp(r'vbscript:'), '') // Remove vbscript: protocol + .replaceAll(RegExp(r'on\w+\s*='), '') // Remove event handlers + .replaceAll(RegExp(r'eval\s*\('), '') // Remove eval calls + .replaceAll(RegExp(r'expression\s*\('), '') // Remove expression calls + .trim(); + + return sanitized; + } + + /// Validate and sanitize URLs + static String? sanitizeUrl(String url) { + if (url.isEmpty) return null; + + try { + final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url'); + + // Only allow safe protocols + if (!['http', 'https'].contains(uri.scheme)) { + return null; + } + + // Remove potentially dangerous query parameters + final sanitizedParams = {}; + uri.queryParameters.forEach((key, value) { + // Remove dangerous query parameters + if (!_isDangerousQueryParam(key) && !_isDangerousQueryParam(value)) { + sanitizedParams[key] = value; + } + }); + + final sanitizedUri = Uri( + scheme: uri.scheme, + host: uri.host, + port: uri.port, + path: uri.path, + queryParameters: sanitizedParams, + ); + + return sanitizedUri.toString(); + } catch (e) { + return null; + } + } + + /// Check if a query parameter is dangerous + static bool _isDangerousQueryParam(String value) { + final dangerousPatterns = [ + 'script', 'javascript', 'vbscript', 'onload', 'onerror', 'onclick', + 'eval', 'expression', 'alert', 'confirm', 'prompt', + '<', '>', '"', "'", '\\', '/', '\n', '\r', '\t' + ]; + + final lowerValue = value.toLowerCase(); + return dangerousPatterns.any((pattern) => lowerValue.contains(pattern)); + } + + /// Validate and limit text length + static String limitText(String text, {int maxLength = 1000}) { + if (text.length <= maxLength) return text; + return '${text.substring(0, maxLength)}...'; + } + + /// Check for potential XSS patterns + static bool containsXSS(String input) { + final xssPatterns = [ + RegExp(r']*>', caseSensitive: false), + RegExp(r'javascript:', caseSensitive: false), + RegExp(r'vbscript:', caseSensitive: false), + RegExp(r'on\w+\s*=', caseSensitive: false), + RegExp(r'eval\s*\(', caseSensitive: false), + RegExp(r'expression\s*\(', caseSensitive: false), + RegExp(r' pattern.hasMatch(input)); + } + + /// Validate user input for common attacks + static bool isValidInput(String input) { + if (input.isEmpty) return true; + + // Check for SQL injection patterns + final sqlPatterns = [ + RegExp(r'(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|SCRIPT)\b)', caseSensitive: false), + RegExp(r'(--|#|/\*|\*/)', caseSensitive: false), + RegExp(r'\bOR\b.*?=.*=', caseSensitive: false), + RegExp(r'\bAND\b.*?=.*=', caseSensitive: false), + ]; + + return !sqlPatterns.any((pattern) => pattern.hasMatch(input)); + } + + /// Sanitize HTML content (if needed) + static String sanitizeHtml(String html) { + // Basic HTML sanitization - remove all tags + return html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); + } + + /// Validate email format + static bool isValidEmail(String email) { + final emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + ); + return emailRegex.hasMatch(email); + } + + /// Validate handle/username + static bool isValidHandle(String handle) { + if (handle.isEmpty) return false; + if (handle.length < 3 || handle.length > 30) return false; + + final handleRegex = RegExp(r'^[a-zA-Z0-9_]+$'); + return handleRegex.hasMatch(handle); + } + + /// Remove potentially harmful characters from filenames + static String sanitizeFilename(String filename) { + return filename + .replaceAll(RegExp(r'[<>:"/\\|?*\x00-\x1f]'), '') + .replaceAll(RegExp(r'\.\.'), '.') + .replaceAll(RegExp(r'^\.+|\.+$'), '') + .toLowerCase(); + } +} diff --git a/sojorn_app/lib/widgets/app_scaffold.dart b/sojorn_app/lib/widgets/app_scaffold.dart index baf2092..c5b9e39 100644 --- a/sojorn_app/lib/widgets/app_scaffold.dart +++ b/sojorn_app/lib/widgets/app_scaffold.dart @@ -80,15 +80,7 @@ class AppScaffold extends StatelessWidget { PreferredSizeWidget _buildDefaultAppBar(BuildContext context) { return AppBar( - title: Text( - title.isEmpty ? 'sojorn' : title, - style: GoogleFonts.literata( - fontSize: title.isEmpty ? 28 : 20, - fontWeight: FontWeight.w700, - color: AppTheme.navyBlue, - letterSpacing: title.isEmpty ? -0.5 : null, - ), - ), + title: title.isEmpty ? Image.asset('assets/images/toplogo.png') : Text(title), centerTitle: centerTitle, leading: leading ?? _buildBackButton(context), actions: actions, diff --git a/sojorn_app/lib/widgets/post/post_actions.dart b/sojorn_app/lib/widgets/post/post_actions.dart index 39ba727..686008f 100644 --- a/sojorn_app/lib/widgets/post/post_actions.dart +++ b/sojorn_app/lib/widgets/post/post_actions.dart @@ -9,6 +9,7 @@ import '../../theme/app_theme.dart'; import '../sojorn_snackbar.dart'; import '../reactions/reaction_picker.dart'; import '../reactions/smart_reaction_button.dart'; +import '../reactions/reactions_display.dart'; /// Post actions with a vibrant, clear, and energetic design. /// @@ -19,12 +20,16 @@ class PostActions extends ConsumerStatefulWidget { final Post post; final VoidCallback? onChain; final VoidCallback? onPostChanged; + final bool isThreadView; + final bool showReactions; const PostActions({ super.key, required this.post, this.onChain, this.onPostChanged, + this.isThreadView = false, + this.showReactions = false, }); @override @@ -202,59 +207,77 @@ class _PostActionsState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Actions row - left aligned + // Show all reactions in thread view + if (widget.showReactions && _reactionCounts.isNotEmpty) + ReactionsDisplay( + reactionCounts: _reactionCounts, + myReactions: _myReactions, + onReactionTap: _showReactionPicker, + showAll: true, + ), + + // Actions row - reply moved to right Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (allowChain) - Expanded( - child: ElevatedButton.icon( - onPressed: widget.onChain, - icon: const Icon(Icons.reply, size: 18), - label: const Text('Reply'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.brightNavy, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), + // Left side: Save and Share + Row( + children: [ + IconButton( + onPressed: _isSaving ? null : _toggleSave, + icon: Icon( + _isSaved ? Icons.bookmark : Icons.bookmark_border, + color: _isSaved ? AppTheme.brightNavy : AppTheme.textSecondary, + ), + style: IconButton.styleFrom( + backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), - ), - if (allowChain) const SizedBox(width: 12), - // Smart reaction button (replaces appreciate button) - SmartReactionButton( - reactionCounts: _reactionCounts, - myReactions: _myReactions, - onPressed: _showReactionPicker, - ), - const SizedBox(width: 8), - IconButton( - onPressed: _isSaving ? null : _toggleSave, - icon: Icon( - _isSaved ? Icons.bookmark : Icons.bookmark_border, - color: _isSaved ? AppTheme.brightNavy : AppTheme.textSecondary, - ), - style: IconButton.styleFrom( - backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + const SizedBox(width: 8), + IconButton( + onPressed: _sharePost, + icon: Icon( + Icons.share_outlined, + color: AppTheme.textSecondary, + ), + style: IconButton.styleFrom( + backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), ), - ), + ], ), - const SizedBox(width: 8), - IconButton( - onPressed: _sharePost, - icon: Icon( - Icons.share_outlined, - color: AppTheme.textSecondary, - ), - style: IconButton.styleFrom( - backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + + // Right side: Reply and Reactions + Row( + children: [ + // Smart reaction button (replaces appreciate button) + SmartReactionButton( + reactionCounts: _reactionCounts, + myReactions: _myReactions, + onPressed: _showReactionPicker, ), - ), + const SizedBox(width: 8), + if (allowChain) + ElevatedButton.icon( + onPressed: widget.onChain, + icon: const Icon(Icons.reply, size: 18), + label: const Text('Reply'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.brightNavy, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], ), ], ), diff --git a/sojorn_app/lib/widgets/post/post_header.dart b/sojorn_app/lib/widgets/post/post_header.dart index e24238f..0a3efe9 100644 --- a/sojorn_app/lib/widgets/post/post_header.dart +++ b/sojorn_app/lib/widgets/post/post_header.dart @@ -144,108 +144,101 @@ class _PostHeaderState extends ConsumerState { final size = widget.avatarSize ?? 36.0; - return InkWell( - onTap: () { - if (handle == 'unknown' || handle.trim().isEmpty) return; - AppRoutes.navigateToProfile(context, handle); - }, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Avatar - clean circle - Container( - width: size, - height: size, - decoration: BoxDecoration( - color: _getAvatarColor(handle), - borderRadius: BorderRadius.circular(size * 0.3), - ), - child: avatarUrl != null && avatarUrl.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(size * 0.28), - child: SignedMediaImage( - url: avatarUrl, - width: size, - height: size, - fit: BoxFit.cover, - ), - ) - : Center( - child: Text( - initial, - style: AppTheme.textTheme.labelMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: size * 0.4, - ), + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Avatar - clean circle + Container( + width: size, + height: size, + decoration: BoxDecoration( + color: _getAvatarColor(handle), + borderRadius: BorderRadius.circular(size * 0.3), + ), + child: avatarUrl != null && avatarUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(size * 0.28), + child: SignedMediaImage( + url: avatarUrl, + width: size, + height: size, + fit: BoxFit.cover, + ), + ) + : Center( + child: Text( + initial, + style: AppTheme.textTheme.labelMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: size * 0.4, ), ), - ), - const SizedBox(width: AppTheme.spacingSm), - - // Author info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Author Name - Visual anchor, ExtraBold Navy Blue - Text( - displayName, - style: AppTheme.textTheme.labelLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), + ), + const SizedBox(width: AppTheme.spacingSm), - // Handle + timestamp - clean metadata - Wrap( - spacing: 4.0, - runSpacing: 0, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - '@$handle', - style: AppTheme.textTheme.labelSmall, - ), + // Author info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Author Name - Visual anchor, ExtraBold Navy Blue + Text( + displayName, + style: AppTheme.textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + + // Handle + timestamp - clean metadata + Wrap( + spacing: 4.0, + runSpacing: 0, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '@$handle', + style: AppTheme.textTheme.labelSmall, + ), + Text( + '·', + style: AppTheme.textTheme.labelSmall, + ), + Text( + timeago.format(widget.post.createdAt, + locale: 'en_short'), + style: AppTheme.textTheme.labelSmall, + ), + _PrivacyIcon( + visibility: _visibility, + onTap: _isOwner ? _showPrivacySheet : null, + ), + if (widget.post.isEdited) ...[ Text( '·', style: AppTheme.textTheme.labelSmall, ), Text( - timeago.format(widget.post.createdAt, - locale: 'en_short'), - style: AppTheme.textTheme.labelSmall, - ), - _PrivacyIcon( - visibility: _visibility, - onTap: _isOwner ? _showPrivacySheet : null, - ), - if (widget.post.isEdited) ...[ - Text( - '·', - style: AppTheme.textTheme.labelSmall, + '(edited)', + style: AppTheme.textTheme.labelSmall?.copyWith( + fontStyle: FontStyle.italic, + color: AppTheme.royalPurple.withValues(alpha: 0.7), ), - Text( - '(edited)', - style: AppTheme.textTheme.labelSmall?.copyWith( - fontStyle: FontStyle.italic, - color: AppTheme.royalPurple.withOpacity(0.7), - ), - ), - ], + ), ], - ), - ], - ), + ], + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/sojorn_app/lib/widgets/post_item.dart b/sojorn_app/lib/widgets/post_item.dart index cbae9d2..ea1fa25 100644 --- a/sojorn_app/lib/widgets/post_item.dart +++ b/sojorn_app/lib/widgets/post_item.dart @@ -28,6 +28,7 @@ class UnifiedPostTile extends StatelessWidget { final bool isDetailView; final bool showThreadSpine; final double? avatarSize; + final bool isThreadView; const UnifiedPostTile({ super.key, @@ -38,6 +39,7 @@ class UnifiedPostTile extends StatelessWidget { this.isDetailView = false, this.showThreadSpine = false, this.avatarSize, + this.isThreadView = false, }); /// Convert legacy parameters to PostViewMode @@ -75,6 +77,7 @@ class UnifiedPostTile extends StatelessWidget { mode: _viewMode, onTap: onTap, onChain: onChain, + isThreadView: isThreadView, ); } diff --git a/sojorn_app/lib/widgets/reactions/credit.md b/sojorn_app/lib/widgets/reactions/credit.md new file mode 100644 index 0000000..6098675 --- /dev/null +++ b/sojorn_app/lib/widgets/reactions/credit.md @@ -0,0 +1 @@ +dotto emoji set by Meritite Union \ No newline at end of file diff --git a/sojorn_app/lib/widgets/reactions/reaction_picker.dart b/sojorn_app/lib/widgets/reactions/reaction_picker.dart index d7b0363..2dfca26 100644 --- a/sojorn_app/lib/widgets/reactions/reaction_picker.dart +++ b/sojorn_app/lib/widgets/reactions/reaction_picker.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../theme/app_theme.dart'; @@ -22,17 +25,267 @@ class ReactionPicker extends StatefulWidget { State createState() => _ReactionPickerState(); } -class _ReactionPickerState extends State { - static const List _commonReactions = [ - '❤️', '👍', '😂', '😮', '😢', '😡', - '🎉', '🔥', '👏', '🙏', '💯', '🤔', - '😍', '🤣', '😊', '👌', '🙌', '💪', - '🎯', '⭐', '✨', '🌟', '💫', '☀️', +class _ReactionPickerState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + int _currentTabIndex = 0; + final TextEditingController _searchController = TextEditingController(); + bool _isSearching = false; + List _filteredReactions = []; + + // Dynamic reaction sets + Map> _reactionSets = {}; + Map _folderCredits = {}; + List _tabOrder = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + _loadReactionSets(); + } + + Future _loadReactionSets() async { + // Start with emoji set + final reactionSets = >{ + 'emoji': [ + '❤️', '👍', '😂', '😮', '😢', '😡', + '🎉', '🔥', '👏', '🙏', '💯', '🤔', + '😍', '🤣', '😊', '👌', '🙌', '💪', + '🎯', '⭐', '✨', '🌟', '💫', '☀️', + ], + }; + + final folderCredits = {}; + final tabOrder = ['emoji']; // Start with emoji, will add folders after + + // Known reaction folders to check + final knownFolders = ['dotto', 'green', 'blue', 'purple']; + + for (final folder in knownFolders) { + try { + // Try to load credit file + final creditContent = await _loadCreditFile(folder); + folderCredits[folder] = creditContent; + + // Try to load files from this folder + final reactions = await _loadAllFilesFromFolder(folder); + + if (reactions.isNotEmpty) { + reactionSets[folder] = reactions; + tabOrder.add(folder); + } + } catch (e) { + // Error loading folder, skip it + } + } + + setState(() { + _reactionSets = reactionSets; + _folderCredits = folderCredits; + _tabOrder = tabOrder; + _isLoading = false; + + // Create TabController after setting up the data + _tabController = TabController(length: _tabOrder.length, vsync: this); + _tabController.addListener(() { + setState(() { + _currentTabIndex = _tabController.index; + _clearSearch(); + }); + }); + }); +} + +Future> _loadAllFilesFromFolder(String folder) async { + final reactions = []; + + // Common file names to try (comprehensive list) + final possibleFiles = [ + // Basic reactions + 'heart', 'thumbs_up', 'laugh', 'lol', 'wow', 'sad', 'angry', 'mad', + 'party', 'fire', 'clap', 'pray', 'hundred', 'thinking', 'ok', + + // Face expressions + 'smile', 'happy', 'grinning', 'beaming', 'wink', 'kiss', 'love', + 'laughing', 'crying', 'tears', 'joy', 'giggle', 'chuckle', + 'frown', 'worried', 'scared', 'fear', 'shock', 'surprised', + 'confused', 'thinking_face', 'face_palm', 'eyeroll', + + // Extended face names + 'laughing_face', 'beaming_face', 'face_with_tears', 'grinning_face', + 'smiling_face', 'winking_face', 'melting_face', 'upside_down_face', + 'rolling_face', 'slightly_smiling_face', 'smiling_face_with_halo', + 'smiling_face_with_hearts', 'face_with_monocle', 'nerd_face', + 'party_face', 'sunglasses_face', 'disappointed_face', 'worried_face', + 'anguished_face', 'fearful_face', 'downcast_face', 'loudly_crying_face', + + // Special characters + 'skull', 'ghost', 'robot', 'alien', 'monster', 'devil', 'angel', + 'poop', 'vomit', 'sick', 'dizzy', 'sleeping', 'zzz', + + // Hearts and love + 'heart_with_arrow', 'broken_heart', 'sparkling_heart', 'two_hearts', + 'revolving_hearts', 'heart_eyes', 'kissing_heart', + + // Colored hearts + 'green_heart', 'blue_heart', 'purple_heart', 'yellow_heart', + 'black_heart', 'white_heart', 'brown_heart', 'orange_heart', + + // Actions and objects + 'thumbs_down', 'ok_hand', 'peace', 'victory', 'rock_on', 'call_me', + 'point_up', 'point_down', 'point_left', 'point_right', + 'raised_hand', 'wave', 'clap', 'high_five', 'pray', 'namaste', + + // Nature and elements + 'fire', 'water', 'earth', 'air', 'lightning', 'storm', 'rainbow', + 'sun', 'moon', 'star', 'cloud', 'tree', 'flower', 'leaf', + + // Food and drink + 'pizza', 'burger', 'taco', 'ice_cream', 'coffee', 'tea', 'beer', + 'wine', 'cocktail', 'cake', 'cookie', 'candy', 'chocolate', + + // Animals + 'dog', 'cat', 'mouse', 'rabbit', 'bear', 'lion', 'tiger', 'elephant', + 'monkey', 'bird', 'fish', 'butterfly', 'spider', 'snake', + + // Objects and symbols + 'bomb', 'knife', 'gun', 'pistol', 'sword', 'shield', 'crown', + 'gem', 'diamond', 'money', 'coin', 'dollar', 'gift', 'present', + + // Numbers and symbols + 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', + 'zero', 'plus', 'minus', 'multiply', 'divide', 'equal', 'check', 'x', + + // Common variations with underscores + 'thumbs_up', 'thumbs_down', 'middle_finger', 'rock_on', 'peace_sign', + 'ok_sign', 'victory_sign', 'call_me_hand', 'raised_hand', 'wave_hand', + + // Emoji-style names + 'face_with_open_mouth', 'face_with_closed_eyes', 'face_with_tears_of_joy', + 'grinning_face_with_big_eyes', 'grinning_face_with_smiling_eyes', + 'grinning_face_with_sweat', 'grinning_squinting_face', 'hugging_face', + 'face_with_head_bandage', 'face_with_thermometer', 'face_with_bandage', + 'nauseated_face', 'sneezing_face', 'yawning_face', 'face_with_cowboy_hat', + + // More descriptive names + 'party_popper', 'confetti_ball', 'balloon', 'ribbon', 'gift_ribbon', + 'birthday_cake', 'wedding_cake', 'christmas_tree', 'pumpkin', 'ghost_halloween', + + // Simple variations + 'like', 'dislike', 'love', 'hate', 'yes', 'no', 'maybe', 'idk', + 'cool', 'hot', 'cold', 'warm', 'fresh', 'old', 'new', 'classic', + + // Tech and modern + 'computer', 'phone', 'camera', 'video', 'music', 'game', 'controller', + 'mouse', 'keyboard', 'screen', 'monitor', 'laptop', 'tablet', + + // Expressions + 'lol', 'lmao', 'rofl', 'omg', 'wtf', 'smh', 'idc', 'ngl', 'fr', + 'tbh', 'iykyk', 'rn', 'asap', 'fyi', 'btw', 'imo', 'imho', ]; + + // Try both PNG and SVG extensions for each possible file name + for (final fileName in possibleFiles) { + for (final extension in ['png', 'svg']) { + final fullPath = 'reactions/$folder/$fileName.$extension'; + try { + // Try to load the file to check if it exists + await rootBundle.load(fullPath); + final assetPath = 'assets/$fullPath'; + reactions.add(assetPath); + break; // Found this file, don't try other extensions + } catch (e) { + // File doesn't exist, try next extension + } + } + } + + return reactions; +} + + Future _loadCreditFile(String folder) async { + try { + final creditData = await rootBundle.loadString('reactions/$folder/credit.md'); + return creditData; + } catch (e) { + // Return default credit if file not found + return '# $folder Reaction Set\n\nCustom reaction set for Sojorn'; + } + } + + @override + void dispose() { + _tabController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _clearSearch() { + _searchController.clear(); + setState(() { + _isSearching = false; + _filteredReactions = []; + }); + } + + void _onSearchChanged() { + final query = _searchController.text.toLowerCase(); + if (query.isEmpty) { + setState(() { + _isSearching = false; + _filteredReactions = []; + }); + } else { + setState(() { + _isSearching = true; + _filteredReactions = _filterReactions(query); + }); + } + } + + List _filterReactions(String query) { + final reactions = _currentReactions; + return reactions.where((reaction) { + // For image reactions, search by filename + if (reaction.startsWith('assets/reactions/')) { + final fileName = reaction.split('/').last.toLowerCase(); + return fileName.contains(query); + } + // For emoji, search by description (you could add a mapping) + return reaction.toLowerCase().contains(query); + }).toList(); + } + + List get _currentReactions { + if (_tabOrder.isEmpty || _currentTabIndex >= _tabOrder.length) { + return []; + } + final currentTab = _tabOrder[_currentTabIndex]; + return _reactionSets[currentTab] ?? []; + } @override Widget build(BuildContext context) { - final reactions = widget.reactions ?? _commonReactions; + if (_isLoading) { + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + width: 400, + height: 300, + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), + ), + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + + final reactions = widget.reactions ?? (_isSearching ? _filteredReactions : _currentReactions); final reactionCounts = widget.reactionCounts ?? {}; final myReactions = widget.myReactions ?? {}; @@ -58,104 +311,186 @@ class _ReactionPickerState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header - Row( + // Header with search + Column( children: [ - Text( - 'Add Reaction', - style: GoogleFonts.inter( - color: AppTheme.navyBlue, - fontSize: 16, - fontWeight: FontWeight.w600, - ), + Row( + children: [ + Text( + _isSearching ? 'Search Reactions' : 'Add Reaction', + style: GoogleFonts.inter( + color: AppTheme.navyBlue, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + onPressed: () { + Navigator.of(context).pop(); + widget.onClosed?.call(); + }, + icon: Icon( + Icons.close, + color: AppTheme.textSecondary, + size: 20, + ), + ), + ], ), - const Spacer(), - IconButton( - onPressed: () { - Navigator.of(context).pop(); - widget.onClosed?.call(); - }, - icon: Icon( - Icons.close, - color: AppTheme.textSecondary, - size: 20, + const SizedBox(height: 12), + + // Search bar + Container( + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.1), + ), + ), + child: TextField( + controller: _searchController, + style: GoogleFonts.inter( + color: AppTheme.navyBlue, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'Search reactions...', + hintStyle: GoogleFonts.inter( + color: AppTheme.textSecondary, + fontSize: 14, + ), + prefixIcon: Icon( + Icons.search, + color: AppTheme.textSecondary, + size: 20, + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: Icon( + Icons.clear, + color: AppTheme.textSecondary, + size: 18, + ), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), ), ), ], ), const SizedBox(height: 16), - // Emoji grid - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 1, + // Tabs + Container( + height: 40, + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), ), - itemCount: reactions.length, - itemBuilder: (context, index) { - final emoji = reactions[index]; - final count = reactionCounts[emoji] ?? 0; - final isSelected = myReactions.contains(emoji); - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - Navigator.of(context).pop(); - widget.onReactionSelected(emoji); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? AppTheme.brightNavy.withValues(alpha: 0.2) - : AppTheme.navyBlue.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? AppTheme.brightNavy - : AppTheme.navyBlue.withValues(alpha: 0.1), - width: isSelected ? 2 : 1, - ), + child: TabBar( + controller: _tabController, + onTap: (index) { + setState(() { + _currentTabIndex = index; + _isSearching = false; + _searchController.clear(); + _filteredReactions = []; + }); + }, + indicator: BoxDecoration( + color: AppTheme.brightNavy, + borderRadius: BorderRadius.circular(10), + ), + labelColor: AppTheme.textSecondary, + unselectedLabelColor: AppTheme.textSecondary, + labelStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + indicatorSize: TabBarIndicatorSize.tab, + tabs: _tabOrder.map((tabName) { + return Tab( + text: tabName.toUpperCase(), + ); + }).toList(), + ), + ), + const SizedBox(height: 16), + + // Search results info + if (_isSearching && _filteredReactions.isEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'No reactions found for "${_searchController.text}"', + style: GoogleFonts.inter( + color: AppTheme.textSecondary, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + + // Reaction grid + SizedBox( + height: _isSearching && _filteredReactions.isNotEmpty + ? (_filteredReactions.length / 6).ceil() * 60 + : 240, // Dynamic height based on search results + child: TabBarView( + controller: _tabController, + children: _tabOrder.map((tabName) { + final reactions = _reactionSets[tabName] ?? []; + final isEmoji = tabName == 'emoji'; + final credit = _folderCredits[tabName]; + + return Column( + children: [ + // Reaction grid + Expanded( + child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, isEmoji), ), - child: Stack( - children: [ - Center( - child: Text( - emoji, - style: const TextStyle(fontSize: 24), - ), - ), - if (count > 0) - Positioned( - right: 2, - bottom: 2, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: AppTheme.brightNavy, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - count > 99 ? '99+' : '$count', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.w600, - ), + + // Credit section (only for non-emoji tabs) + if (credit != null && credit.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(color: Colors.grey), + const SizedBox(height: 8), + Text( + 'Credits:', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppTheme.textSecondary, ), ), - ), - ], - ), - ), - ), - ); - }, + const SizedBox(height: 4), + // Parse and display credit markdown + _buildCreditDisplay(credit), + ], + ), + ), + ], + ); + }).toList(), + ), ), const SizedBox(height: 16), ], @@ -163,4 +498,198 @@ class _ReactionPickerState extends State { ), ); } + + Widget _buildCreditDisplay(String credit) { + final lines = credit.split('\n'); + final widgets = []; + + for (final line in lines) { + if (line.trim().isEmpty) continue; + + if (line.startsWith('# ')) { + // Title + widgets.add(Text( + line.substring(2).trim(), + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + )); + } else if (line.startsWith('**') && line.endsWith('**')) { + // Bold text + widgets.add(Text( + line.replaceAll('**', '').trim(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + )); + } else if (line.startsWith('*') && line.endsWith('*')) { + // Italic text + widgets.add(Text( + line.replaceAll('*', '').trim(), + style: GoogleFonts.inter( + fontSize: 10, + fontStyle: FontStyle.italic, + color: AppTheme.textPrimary, + ), + )); + } else if (line.startsWith('- ')) { + // List item + widgets.add(Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + '• ${line.substring(2).trim()}', + style: GoogleFonts.inter( + fontSize: 10, + color: AppTheme.textPrimary, + ), + ), + )); + } else { + // Regular text + widgets.add(Text( + line.trim(), + style: GoogleFonts.inter( + fontSize: 10, + color: AppTheme.textPrimary, + ), + )); + } + + widgets.add(const SizedBox(height: 2)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ); + } + + Widget _buildReactionGrid( + List reactions, + Map reactionCounts, + Set myReactions, + bool useImages, + ) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: reactions.length, + itemBuilder: (context, index) { + final reaction = reactions[index]; + final count = reactionCounts[reaction] ?? 0; + final isSelected = myReactions.contains(reaction); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.of(context).pop(); + widget.onReactionSelected(reaction); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? AppTheme.brightNavy.withValues(alpha: 0.2) + : AppTheme.navyBlue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppTheme.brightNavy + : AppTheme.navyBlue.withValues(alpha: 0.1), + width: isSelected ? 2 : 1, + ), + ), + child: Stack( + children: [ + Center( + child: useImages + ? _buildImageReaction(reaction) + : _buildEmojiReaction(reaction), + ), + if (count > 0) + Positioned( + right: 2, + bottom: 2, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: AppTheme.brightNavy, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildEmojiReaction(String emoji) { + return Text( + emoji, + style: const TextStyle(fontSize: 24), + ); + } + + Widget _buildImageReaction(String imagePath) { + if (imagePath.endsWith('.svg')) { + return SvgPicture.asset( + imagePath, + width: 32, + height: 32, + placeholderBuilder: (context) => Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.textSecondary, + ), + ), + errorBuilder: (context, error, stackTrace) { + // Fallback to emoji if image not found + return Icon( + Icons.image_not_supported, + size: 24, + color: AppTheme.textSecondary, + ); + }, + ); + } + + return Image.asset( + imagePath, + width: 32, + height: 32, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.image_not_supported, + size: 24, + color: AppTheme.textSecondary, + ); + }, + ); + } } diff --git a/sojorn_app/lib/widgets/reactions/reactions_display.dart b/sojorn_app/lib/widgets/reactions/reactions_display.dart new file mode 100644 index 0000000..cb52bfb --- /dev/null +++ b/sojorn_app/lib/widgets/reactions/reactions_display.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../theme/app_theme.dart'; + +/// Displays all reactions for a post +/// Used in thread/detail views to show comprehensive reaction breakdown +class ReactionsDisplay extends StatelessWidget { + final Map reactionCounts; + final Set myReactions; + final VoidCallback? onReactionTap; + final bool showAll; + + const ReactionsDisplay({ + super.key, + required this.reactionCounts, + required this.myReactions, + this.onReactionTap, + this.showAll = true, + }); + + @override + Widget build(BuildContext context) { + if (reactionCounts.isEmpty) { + return const SizedBox.shrink(); + } + + List> reactions; + if (showAll) { + reactions = reactionCounts.entries.toList(); + reactions.sort((a, b) => b.value.compareTo(a.value)); + } else { + reactions = reactionCounts.entries.take(3).toList(); + reactions.sort((a, b) => b.value.compareTo(a.value)); + } + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: reactions.map((entry) { + final emoji = entry.key; + final count = entry.value; + final isMyReaction = myReactions.contains(emoji); + + return GestureDetector( + onTap: onReactionTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isMyReaction + ? AppTheme.brightNavy.withValues(alpha: 0.15) + : AppTheme.navyBlue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + border: isMyReaction + ? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3)) + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + emoji, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(width: 4), + Text( + count > 99 ? '99+' : '$count', + style: GoogleFonts.inter( + color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/sojorn_app/lib/widgets/reading_post_card.dart b/sojorn_app/lib/widgets/reading_post_card.dart index 5bc8ece..f95369f 100644 --- a/sojorn_app/lib/widgets/reading_post_card.dart +++ b/sojorn_app/lib/widgets/reading_post_card.dart @@ -3,13 +3,12 @@ import 'package:timeago/timeago.dart' as timeago; import '../models/post.dart'; import '../theme/app_theme.dart'; import 'media/signed_media_image.dart'; +import '../routes/app_routes.dart'; class ReadingPostCard extends StatefulWidget { final Post post; final VoidCallback? onTap; - final VoidCallback? onAppreciate; final VoidCallback? onSave; - final bool isAppreciated; final bool isSaved; final bool showDivider; @@ -17,9 +16,7 @@ class ReadingPostCard extends StatefulWidget { super.key, required this.post, this.onTap, - this.onAppreciate, this.onSave, - this.isAppreciated = false, this.isSaved = false, this.showDivider = true, }); @@ -68,31 +65,36 @@ class _ReadingPostCardState extends State { ), child: Material( color: Colors.transparent, - child: InkWell( - onTap: widget.onTap, - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) => setState(() => _isPressed = false), - onTapCancel: () => setState(() => _isPressed = false), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - splashColor: AppTheme.queenPink.withValues(alpha: 0.3), - highlightColor: Colors.transparent, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingLg, - AppTheme.spacingMd, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAuthorRow(), - const SizedBox(height: AppTheme.spacingMd), - _buildBodyText(), - const SizedBox(height: AppTheme.spacingLg), - _buildActionRow(), - ], - ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppTheme.spacingMd, + AppTheme.spacingSm, + AppTheme.spacingLg, + AppTheme.spacingMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAuthorRow(), + const SizedBox(height: AppTheme.spacingMd), + // White space area - clickable for post detail with full background coverage + InkWell( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + splashColor: AppTheme.queenPink.withValues(alpha: 0.3), + highlightColor: Colors.transparent, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + child: _buildBodyText(), + ), + ), + const SizedBox(height: AppTheme.spacingLg), + _buildActionRow(), + ], ), ), ), @@ -132,73 +134,82 @@ class _ReadingPostCardState extends State { final avatarUrl = widget.post.author?.avatarUrl; final handle = widget.post.author?.handle ?? ''; final fallbackColor = _getAvatarColor(handle); - return Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: fallbackColor, - borderRadius: BorderRadius.circular(10), - ), - child: avatarUrl != null && avatarUrl.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(9), - child: SignedMediaImage( - url: avatarUrl, - width: 36, - height: 36, - fit: BoxFit.cover, - ), - ) - : Center( - child: Text( - handle.isNotEmpty ? handle[0].toUpperCase() : '?', - style: AppTheme.textTheme.labelMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, + + return InkWell( + onTap: () { + if (handle.isNotEmpty && handle != 'unknown') { + AppRoutes.navigateToProfile(context, handle); + } + }, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: fallbackColor, + borderRadius: BorderRadius.circular(10), + ), + child: avatarUrl != null && avatarUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(9), + child: SignedMediaImage( + url: avatarUrl, + width: 36, + height: 36, + fit: BoxFit.cover, ), - ), - ), - ), - const SizedBox(width: AppTheme.spacingSm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( + ) + : Center( child: Text( - widget.post.author?.displayName ?? 'Unknown', + handle.isNotEmpty ? handle[0].toUpperCase() : '?', style: AppTheme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w700, - color: AppTheme.navyBlue, + color: Colors.white, + fontWeight: FontWeight.w600, ), - overflow: TextOverflow.ellipsis, ), ), - if (widget.post.author?.isOfficial == true) ...[ - const SizedBox(width: AppTheme.spacingXs), - _buildOfficialBadge(), - ], - if (widget.post.author?.isOfficial != true && - widget.post.author?.trustState != null) ...[ - const SizedBox(width: AppTheme.spacingXs), - _buildTrustBadge(), - ], - ], - ), - const SizedBox(height: 2), - Text( - timeago.format(widget.post.createdAt), - style: AppTheme.textTheme.labelSmall - ?.copyWith(color: AppTheme.egyptianBlue), - ), - ], ), - ), - ], + const SizedBox(width: AppTheme.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + widget.post.author?.displayName ?? 'Unknown', + style: AppTheme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + color: AppTheme.navyBlue, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.post.author?.isOfficial == true) ...[ + const SizedBox(width: AppTheme.spacingXs), + _buildOfficialBadge(), + ], + if (widget.post.author?.isOfficial != true && + widget.post.author?.trustState != null) ...[ + const SizedBox(width: AppTheme.spacingXs), + _buildTrustBadge(), + ], + ], + ), + const SizedBox(height: 2), + Text( + timeago.format(widget.post.createdAt), + style: AppTheme.textTheme.labelSmall + ?.copyWith(color: AppTheme.egyptianBlue), + ), + ], + ), + ), + ], + ), ); } @@ -233,16 +244,16 @@ class _ReadingPostCardState extends State { ], const Spacer(), _buildActionButton( - icon: widget.isAppreciated ? Icons.favorite : Icons.favorite_border, - isActive: widget.isAppreciated, - onPressed: widget.onAppreciate, + icon: widget.isSaved ? Icons.bookmark : Icons.bookmark_border, + isActive: widget.isSaved, + onPressed: widget.onSave, color: AppTheme.brightNavy, ), const SizedBox(width: AppTheme.spacingMd), _buildActionButton( - icon: widget.isSaved ? Icons.bookmark : Icons.bookmark_border, - isActive: widget.isSaved, - onPressed: widget.onSave, + icon: Icons.share_outlined, + isActive: false, + onPressed: null, // TODO: Implement share functionality color: AppTheme.brightNavy, ), ], diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index 860cc5a..216a861 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -7,6 +7,7 @@ import 'post/post_header.dart'; import 'post/post_media.dart'; import 'post/post_menu.dart'; import 'post/post_view_mode.dart'; +import 'chain_quote_widget.dart'; import '../routes/app_routes.dart'; /// Unified Post Card - Single Source of Truth for post display. @@ -36,6 +37,9 @@ class sojornPostCard extends StatelessWidget { final VoidCallback? onTap; final VoidCallback? onChain; final VoidCallback? onPostChanged; + final VoidCallback? onChainParentTap; + final bool isThreadView; + final bool showChainContext; const sojornPostCard({ super.key, @@ -44,6 +48,9 @@ class sojornPostCard extends StatelessWidget { this.onTap, this.onChain, this.onPostChanged, + this.onChainParentTap, + this.isThreadView = false, + this.showChainContext = true, }); /// Get spacing values based on view mode @@ -97,6 +104,16 @@ class sojornPostCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Chain Context (The Quote Box) - only show in thread view + if (isThreadView && showChainContext && post.chainParent != null) ...[ + ChainQuoteWidget( + parent: post.chainParent!, + onTap: onChainParentTap, + ), + const SizedBox(height: AppTheme.spacingSm), + ], + + // Main Post Content const SizedBox(height: 4), // Header row with menu - only header is clickable for profile Row( @@ -132,15 +149,19 @@ class sojornPostCard extends StatelessWidget { ), const SizedBox(height: 16), - // Body text - clickable for post detail + // Body text - clickable for post detail with full background coverage InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppTheme.radiusMd), - child: PostBody( - text: post.body, - bodyFormat: post.bodyFormat, - backgroundId: post.backgroundId, - mode: mode, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + child: PostBody( + text: post.body, + bodyFormat: post.bodyFormat, + backgroundId: post.backgroundId, + mode: mode, + ), ), ), @@ -156,7 +177,6 @@ class sojornPostCard extends StatelessWidget { ), ), ], - const SizedBox(height: 20), // Actions @@ -164,6 +184,8 @@ class sojornPostCard extends StatelessWidget { post: post, onChain: onChain, onPostChanged: onPostChanged, + isThreadView: isThreadView, + showReactions: isThreadView, ), ], ), diff --git a/sojorn_app/pubspec.lock b/sojorn_app/pubspec.lock index 9bd69ee..38b7c69 100644 --- a/sojorn_app/pubspec.lock +++ b/sojorn_app/pubspec.lock @@ -73,6 +73,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" camera: dependency: "direct main" description: @@ -438,6 +462,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_colorpicker: dependency: "direct main" description: @@ -731,6 +763,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -1109,6 +1149,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_info_plus: dependency: transitive description: @@ -1133,6 +1181,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1381,6 +1437,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" share_plus: dependency: "direct main" description: @@ -1466,6 +1530,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -1506,6 +1610,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1642,6 +1754,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + url: "https://pub.dev" + source: hosted + version: "1.1.20" vector_math: dependency: transitive description: diff --git a/sojorn_app/pubspec.yaml b/sojorn_app/pubspec.yaml index 100392d..929a54b 100644 --- a/sojorn_app/pubspec.yaml +++ b/sojorn_app/pubspec.yaml @@ -35,6 +35,10 @@ dependencies: ffmpeg_kit_flutter_new: ^4.1.0 flutter_animate: ^4.5.0 + # Graphics & Images + flutter_svg: ^2.0.10 + cached_network_image: ^3.3.10 + # Rich Text Editor flutter_quill: ^11.0.0 flutter_quill_extensions: ^11.0.0 @@ -90,9 +94,8 @@ flutter: assets: - assets/images/ - assets/images/applogo.png + - assets/images/toplogo.png - assets/reactions/ - - assets/rive/ - - assets/audio/ dependency_overrides: intl: 0.19.0 diff --git a/sojorn_app/tools/README.md b/sojorn_app/tools/README.md new file mode 100644 index 0000000..f65b678 --- /dev/null +++ b/sojorn_app/tools/README.md @@ -0,0 +1,58 @@ +# Reaction Tools + +This folder contains tools for managing reaction sets in Sojorn. + +## Available Tools + +### 1. add_reaction_folder.dart +**Purpose**: Add new reaction folders to the reaction picker +**When to use**: After creating a new folder with images and credit.md + +**How to run**: +```bash +# Windows (easy) +tools\add_new_folder.bat + +# Manual +dart tools/add_reaction_folder.dart +``` + +**What it does**: +- Scans `assets/reactions/` for folders +- Checks which folders have PNG/SVG files +- Updates `lib/widgets/reactions/reaction_picker.dart` to include folders with content +- Ignores empty folders automatically + +### 2. generate_reaction_config.dart +**Purpose**: Generate configuration file (legacy/alternative) +**When to use**: If you prefer JSON-based configuration + +**How to run**: +```bash +dart tools/generate_reaction_config.dart +``` + +**What it does**: +- Scans folders and creates `reaction_config.json` +- Lists all files found in each folder +- Provides build-time file discovery + +## Recommended Workflow + +1. **Create Folder**: `mkdir assets/reactions/yourtheme` +2. **Add Images**: Drop PNG/SVG files in the folder +3. **Add Credits**: Create `credit.md` (optional) +4. **Update Picker**: Run `tools\add_new_folder.bat` +5. **Restart App**: See your new reaction tabs! + +## File Naming Tips + +The system tries 200+ common file names including: +- Basic: `heart`, `thumbs_up`, `laugh`, `wow`, `sad`, `angry` +- Faces: `smiling_face`, `winking_face`, `laughing_face`, `melting_face` +- Special: `skull`, `ghost`, `robot`, `angel`, `devil` +- Hearts: `heart_with_arrow`, `broken_heart`, `green_heart`, `blue_heart` +- Actions: `clap`, `pray`, `party`, `fire`, `thinking` +- Modern: `lol`, `omg`, `cool`, `hot`, `computer`, `phone` + +Use descriptive names with underscores, not spaces or camelCase. diff --git a/sojorn_app/tools/add_new_folder.bat b/sojorn_app/tools/add_new_folder.bat new file mode 100644 index 0000000..262d47f --- /dev/null +++ b/sojorn_app/tools/add_new_folder.bat @@ -0,0 +1,13 @@ +@echo off +echo Adding new reaction folders to reaction picker... +echo. + +cd /d "%~dp0.." + +dart tools\add_reaction_folder.dart + +echo. +echo ✅ Reaction picker updated! +echo 🔄 Restart your app to see the new reaction tabs +echo. +pause diff --git a/sojorn_app/tools/add_reaction_folder.dart b/sojorn_app/tools/add_reaction_folder.dart new file mode 100644 index 0000000..a8320ba --- /dev/null +++ b/sojorn_app/tools/add_reaction_folder.dart @@ -0,0 +1,112 @@ +#!/usr/bin/env dart + +import 'dart:io'; +import 'dart:convert'; + +/// Script to add new reaction folders to the reaction picker +/// Run this after creating a new folder with images and credit.md + +void main() async { + print('🔍 Scanning for new reaction folders...\n'); + + final reactionsDir = Directory('assets/reactions'); + if (!await reactionsDir.exists()) { + print('❌ Error: assets/reactions directory not found'); + return; + } + + // Find all folders in reactions directory + final allFolders = []; + await for (final entity in reactionsDir.list()) { + if (entity is Directory) { + final folderName = entity.path.split(Platform.pathSeparator).last; + if (folderName != 'emoji') { // Skip emoji folder + allFolders.add(folderName); + } + } + } + + if (allFolders.isEmpty) { + print('📁 No reaction folders found (except emoji)'); + return; + } + + print('📁 Found folders: ${allFolders.join(', ')}\n'); + + // Check which folders have content + final foldersWithContent = []; + for (final folder in allFolders) { + final hasContent = await _checkFolderHasContent(folder); + if (hasContent) { + foldersWithContent.add(folder); + print('✅ $folder: Has content'); + } else { + print('⚠️ $folder: Empty (will be ignored)'); + } + } + + if (foldersWithContent.isEmpty) { + print('\n❌ No folders with content found'); + return; + } + + // Update the reaction picker code + await _updateReactionPickerCode(foldersWithContent); + + print('\n🎉 Done! Restart your app to see the new reaction tabs'); + print('💡 Tip: Add files to folders and run this script again to update'); +} + +Future _checkFolderHasContent(String folder) async { + final folderDir = Directory('assets/reactions/$folder'); + bool hasImages = false; + + try { + await for (final entity in folderDir.list()) { + if (entity is File) { + final fileName = entity.path.split(Platform.pathSeparator).last.toLowerCase(); + if (fileName.endsWith('.png') || fileName.endsWith('.svg')) { + hasImages = true; + break; + } + } + } + } catch (e) { + print('Error checking folder $folder: $e'); + } + + return hasImages; +} + +Future _updateReactionPickerCode(List folders) async { + final pickerFile = File('lib/widgets/reactions/reaction_picker.dart'); + + if (!await pickerFile.exists()) { + print('❌ Error: reaction_picker.dart not found'); + return; + } + + String content = await pickerFile.readAsString(); + + // Find the line with knownFolders + final knownFoldersPattern = RegExp(r'final knownFolders = \[([^\]]+)\];'); + final match = knownFoldersPattern.firstMatch(content); + + if (match == null) { + print('❌ Error: Could not find knownFolders line in reaction_picker.dart'); + return; + } + + // Create new folder list as string + final newFoldersList = folders.map((f) => "'$f'").join(', '); + + // Replace the old list with new one + final newKnownFoldersLine = "final knownFolders = [$newFoldersList];"; + + content = content.replaceFirst(match.group(0)!, newKnownFoldersLine); + + // Write back to file + await pickerFile.writeAsString(content); + + print('📝 Updated reaction_picker.dart with folders: ${folders.join(', ')}'); +} diff --git a/sojorn_app/tools/generate_reaction_config.dart b/sojorn_app/tools/generate_reaction_config.dart new file mode 100644 index 0000000..3edbf16 --- /dev/null +++ b/sojorn_app/tools/generate_reaction_config.dart @@ -0,0 +1,106 @@ +#!/usr/bin/env dart + +import 'dart:io'; +import 'dart:convert'; + +/// Script to generate reaction_config.json by scanning reaction folders +/// Run this script whenever you add new reaction files or folders + +void main() async { + final reactionsDir = Directory('assets/reactions'); + if (!await reactionsDir.exists()) { + print('Error: assets/reactions directory not found'); + return; + } + + final reactionSets = >{}; + + // Add default emoji set + reactionSets['emoji'] = { + 'type': 'emoji', + 'reactions': [ + '❤️', '👍', '😂', '😮', '😢', '😡', + '🎉', '🔥', '👏', '🙏', '💯', '🤔', + '😍', '🤣', '😊', '👌', '🙌', '💪', + '🎯', '⭐', '✨', '🌟', '💫', '☀️', + ], + }; + + // Scan each folder in reactions directory + await for (final entity in reactionsDir.list()) { + if (entity is Directory && entity.path.contains('reactions\\') || entity.path.contains('reactions/')) { + final folderName = entity.path.split(Platform.pathSeparator).last; + + // Skip emoji folder (handled above) + if (folderName == 'emoji') continue; + + print('Scanning folder: $folderName'); + + final files = []; + final fileTypes = {}; + + // Scan all files in the folder + await for (final file in (entity as Directory).list()) { + if (file is File) { + final fileName = file.path.split(Platform.pathSeparator).last; + + // Only include image files + if (fileName.endsWith('.png') || fileName.endsWith('.svg')) { + files.add(fileName); + fileTypes.add(fileName.split('.').last); + } + } + } + + if (files.isNotEmpty) { + reactionSets[folderName] = { + 'type': 'folder', + 'folder': folderName, + 'file_types': fileTypes.toList(), + 'files': files, // Explicit file list since we can't discover at runtime + }; + + print(' Found ${files.length} files: ${files.take(5).join(', ')}${files.length > 5 ? '...' : ''}'); + } else { + print(' ⚠️ No reaction files found, skipping folder'); + } + } + } + + // Generate configuration + final config = { + 'reaction_sets': reactionSets, + 'generated_at': DateTime.now().toIso8601String(), + 'total_sets': reactionSets.length, + }; + + // Write configuration file + final configFile = File('assets/reactions/reaction_config.json'); + await configFile.writeAsString( + const JsonEncoder.withIndent(' ').convert(config) + ); + + print('\n✅ Generated reaction_config.json with ${reactionSets.length} reaction sets'); + final activeFolders = reactionSets.keys.where((k) => k != 'emoji').toList(); + if (activeFolders.isNotEmpty) { + print('📁 Active folders: ${activeFolders.join(', ')}'); + } else { + print('📁 No active folders found (only emoji set)'); + } + + // Check for empty folders + final allFolders = []; + await for (final entity in reactionsDir.list()) { + if (entity is Directory) { + final folderName = entity.path.split(Platform.pathSeparator).last; + if (folderName != 'emoji') allFolders.add(folderName); + } + } + + final emptyFolders = allFolders.where((folder) => !activeFolders.contains(folder)).toList(); + if (emptyFolders.isNotEmpty) { + print('🗂️ Empty folders (ignored): ${emptyFolders.join(', ')}'); + } + + print('⚠️ Remember to run "flutter pub get" and restart your app'); +} diff --git a/sojorn_app/tools/update_reactions.bat b/sojorn_app/tools/update_reactions.bat new file mode 100644 index 0000000..31ca474 --- /dev/null +++ b/sojorn_app/tools/update_reactions.bat @@ -0,0 +1,13 @@ +@echo off +echo Updating reaction configuration... +echo. + +cd /d "%~dp0.." + +dart tools\generate_reaction_config.dart + +echo. +echo ✅ Reaction configuration updated! +echo 📁 Make sure to restart your app to see changes +echo. +pause