diff --git a/lib/src/visual/components/animate_icon.comp.dart b/lib/src/visual/components/animate_icon.comp.dart index bbc09956..5f721baf 100644 --- a/lib/src/visual/components/animate_icon.comp.dart +++ b/lib/src/visual/components/animate_icon.comp.dart @@ -4,7 +4,7 @@ import 'package:lottie/lottie.dart'; // animations from: https://googlefonts.github.io/noto-emoji-animation/ // from https://github.com/eitanliu/emoji_regex/tree/master -RegExp emojiRegex() => RegExp( +final RegExp _emojiRegex = RegExp( r'[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)', ); @@ -12,7 +12,7 @@ bool isOneEmoji(String character) { if (EmojiAnimationComp.animatedIcons.containsKey(character)) { return true; } - final matches = emojiRegex().allMatches(character); + final matches = _emojiRegex.allMatches(character); if (matches.length == 1) { final match = matches.first; return match.start == 0 && match.end == character.length; @@ -28,6 +28,7 @@ class EmojiAnimationComp extends StatelessWidget { }); final String emoji; final bool repeat; + static final List animatedIconKeys = animatedIcons.keys.toList(); static final Map animatedIcons = { '❤': 'red_heart.lottie', '😂': 'joy.lottie', diff --git a/lib/src/visual/components/flame_counter.comp.dart b/lib/src/visual/components/flame_counter.comp.dart index 73d6491f..011a2877 100644 --- a/lib/src/visual/components/flame_counter.comp.dart +++ b/lib/src/visual/components/flame_counter.comp.dart @@ -98,6 +98,7 @@ class _FlameCounterWidgetState extends State { height: 11, child: EmojiAnimationComp( emoji: '⌛', + repeat: false, ), ), ], diff --git a/lib/src/visual/helpers/media_view_sizing.helper.dart b/lib/src/visual/helpers/media_view_sizing.helper.dart index 001f90cb..82f17d2a 100644 --- a/lib/src/visual/helpers/media_view_sizing.helper.dart +++ b/lib/src/visual/helpers/media_view_sizing.helper.dart @@ -22,15 +22,14 @@ class _MediaViewSizingHelperState extends State { @override Widget build(BuildContext context) { var needToDownSizeImage = false; - var availableHeight = MediaQuery.of(context).size.height; - // Get the screen size and safe area padding - final screenSize = MediaQuery.of(context).size; - final safeAreaPadding = MediaQuery.of(context).padding; + // Use narrow MediaQuery selectors to avoid rebuilding on keyboard inset changes + final screenSize = MediaQuery.sizeOf(context); + final safeAreaPadding = MediaQuery.paddingOf(context); // Calculate the available width and height final availableWidth = screenSize.width; - availableHeight = + final availableHeight = screenSize.height - safeAreaPadding.top - safeAreaPadding.bottom - diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 2b7e3f30..0605b307 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -38,7 +38,7 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie List _groupsPinned = []; List _groupsArchived = []; - bool _hasContacts = false; + final ValueNotifier _hasContacts = ValueNotifier(false); bool _loading = true; bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || @@ -50,6 +50,7 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie int _countContactRequest = 0; int _countAnnouncedUsers = 0; + final ValueNotifier _badgeCount = ValueNotifier(0); late StreamSubscription _countContactRequestStream; late StreamSubscription _countAnnouncedStream; @@ -80,9 +81,7 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie contacts, ) { if (!mounted) return; - setState(() { - _hasContacts = contacts.isNotEmpty; - }); + _hasContacts.value = contacts.isNotEmpty; }); _countContactRequestStream = twonlyDB.contactsDao @@ -90,9 +89,8 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie .listen((update) { if (update != null) { if (!mounted) return; - setState(() { - _countContactRequest = update; - }); + _countContactRequest = update; + _badgeCount.value = _countAnnouncedUsers + _countContactRequest; } }); @@ -100,9 +98,8 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie .watchNewAnnouncementsWithDataCount() .listen((update) { if (!mounted) return; - setState(() { - _countAnnouncedUsers = update; - }); + _countAnnouncedUsers = update; + _badgeCount.value = _countAnnouncedUsers + _countContactRequest; }); _precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) { @@ -141,7 +138,10 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie @override Widget build(BuildContext context) { super.build(context); - final plan = context.watch().plan; + final plan = context.select( + (p) => p.plan, + ); + final dark = isDarkMode(context); return Scaffold( appBar: AppBar( title: Row( @@ -175,7 +175,7 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, - color: isDarkMode(context) ? Colors.black : Colors.white, + color: dark ? Colors.black : Colors.white, ), ), ), @@ -184,40 +184,40 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie ), actions: [ const FeedbackIconButtonComp(), - Stack( - children: [ - if (_countAnnouncedUsers + _countContactRequest > 0) - Positioned.fill( - child: Center( - child: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: primaryColor, - shape: BoxShape.circle, + ValueListenableBuilder( + valueListenable: _badgeCount, + builder: (context, badgeCount, child) { + return Stack( + children: [ + if (badgeCount > 0) + Positioned.fill( + child: Center( + child: Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: primaryColor, + shape: BoxShape.circle, + ), + ), + ), + ), + Center( + child: NotificationBadgeComp( + backgroundColor: dark ? Colors.white : Colors.black, + textColor: dark ? Colors.black : Colors.white, + count: badgeCount.toString(), + child: IconButton( + color: badgeCount > 0 ? Colors.black : null, + key: searchForOtherUsers, + icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), + onPressed: () => context.push(Routes.chatsAddNewUser), ), ), ), - ), - Center( - child: NotificationBadgeComp( - backgroundColor: isDarkMode(context) - ? Colors.white - : Colors.black, - textColor: isDarkMode(context) ? Colors.black : Colors.white, - count: (_countAnnouncedUsers + _countContactRequest) - .toString(), - child: IconButton( - color: (_countAnnouncedUsers + _countContactRequest > 0) - ? Colors.black - : null, - key: searchForOtherUsers, - icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), - onPressed: () => context.push(Routes.chatsAddNewUser), - ), - ), - ), - ], + ], + ); + }, ), IconButton( @@ -303,45 +303,43 @@ class _ChatListViewState extends State with AutomaticKeepAliveClie ), ), floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, - floatingActionButton: !_hasContacts - ? null - : Padding( - padding: const EdgeInsets.only(bottom: 30), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FloatingActionButton( - heroTag: 'qrcode_fab', - elevation: 2, - backgroundColor: isDarkMode(context) - ? Colors.grey[800] - : Colors.grey[200], - foregroundColor: isDarkMode(context) - ? Colors.white - : Colors.black87, - onPressed: () => context.push(Routes.settingsPublicProfile), - child: FaIcon( - FontAwesomeIcons.qrcode, - color: isDarkMode(context) - ? Colors.white - : Colors.black87, - ), + floatingActionButton: ValueListenableBuilder( + valueListenable: _hasContacts, + builder: (context, hasContacts, child) { + if (!hasContacts) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 30), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: 'qrcode_fab', + elevation: 2, + backgroundColor: dark ? Colors.grey[800] : Colors.grey[200], + foregroundColor: dark ? Colors.white : Colors.black87, + onPressed: () => context.push(Routes.settingsPublicProfile), + child: FaIcon( + FontAwesomeIcons.qrcode, + color: dark ? Colors.white : Colors.black87, ), - const SizedBox(height: 12), - FloatingActionButton( - heroTag: 'new_chat_fab', - elevation: 2, - backgroundColor: primaryColor, - foregroundColor: Colors.black87, - onPressed: () => context.push(Routes.chatsStartNewChat), - child: const FaIcon( - FontAwesomeIcons.penToSquare, - color: Colors.black87, - ), + ), + const SizedBox(height: 12), + FloatingActionButton( + heroTag: 'new_chat_fab', + elevation: 2, + backgroundColor: primaryColor, + foregroundColor: Colors.black87, + onPressed: () => context.push(Routes.chatsStartNewChat), + child: const FaIcon( + FontAwesomeIcons.penToSquare, + color: Colors.black87, ), - ], - ), + ), + ], ), + ); + }, + ), ); } } diff --git a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart index 19cf9f51..eb10a7c3 100644 --- a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart @@ -174,19 +174,23 @@ class _UserListItem extends State { /// Fetches any media files referenced by preview messages but not yet in the /// local cache. Fire-and-forget; updates state when results arrive. Future _fetchMissingMediaFiles() async { + final missing = []; for (final message in _previewMessages) { if (message.mediaId != null && !_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) { final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( message.mediaId!, ); - if (mediaFile != null && mounted) { - setState(() { - _previewMediaFiles.add(mediaFile); - }); + if (mediaFile != null) { + missing.add(mediaFile); } } } + if (missing.isNotEmpty && mounted) { + setState(() { + _previewMediaFiles.addAll(missing); + }); + } } Future onTap() async { diff --git a/lib/src/visual/views/chats/chat_list_components/last_message_time.comp.dart b/lib/src/visual/views/chats/chat_list_components/last_message_time.comp.dart index 7cefd901..3dd52298 100644 --- a/lib/src/visual/views/chats/chat_list_components/last_message_time.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/last_message_time.comp.dart @@ -26,11 +26,6 @@ class _LastMessageTimeCompState extends State { void initState() { super.initState(); _loadTargetTime(); - - updateTime = Timer.periodic( - const Duration(milliseconds: 500), - (_) => _updateSeconds(), - ); } @override @@ -66,6 +61,16 @@ class _LastMessageTimeCompState extends State { setState(() { lastMessageInSeconds = seconds < 0 ? 0 : seconds; }); + + var nextTickMs = 1000; + if (lastMessageInSeconds >= 120 && lastMessageInSeconds < 3600) { + nextTickMs = 30000; + } else if (lastMessageInSeconds >= 3600) { + nextTickMs = 60000; + } + + updateTime?.cancel(); + updateTime = Timer(Duration(milliseconds: nextTickMs), _updateSeconds); } @override diff --git a/lib/src/visual/views/chats/chat_list_components/typing_indicator_subtitle.comp.dart b/lib/src/visual/views/chats/chat_list_components/typing_indicator_subtitle.comp.dart index 40c38bcb..81397eaa 100644 --- a/lib/src/visual/views/chats/chat_list_components/typing_indicator_subtitle.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/typing_indicator_subtitle.comp.dart @@ -28,10 +28,6 @@ class _TypingIndicatorSubtitleCompState void initState() { super.initState(); - _periodicUpdate = Timer.periodic(const Duration(seconds: 1), (_) { - filterOpenUsers(_groupMembers); - }); - final membersStream = twonlyDB.groupsDao.watchGroupMembers( widget.groupId, ); @@ -41,11 +37,22 @@ class _TypingIndicatorSubtitleCompState } void filterOpenUsers(List input) { - if (mounted) { - setState(() { - _groupMembers = input.where(isTyping).toList(); + if (!mounted) return; + + final typingMembers = input.where(isTyping).toList(); + + if (typingMembers.isEmpty) { + _periodicUpdate?.cancel(); + _periodicUpdate = null; + } else { + _periodicUpdate ??= Timer.periodic(const Duration(seconds: 1), (_) { + filterOpenUsers(_groupMembers); }); } + + setState(() { + _groupMembers = typingMembers; + }); } @override diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index 6e99ff8b..50916694 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -4,7 +4,6 @@ import 'dart:collection'; import 'package:clock/clock.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:mutex/mutex.dart'; import 'package:screen_protector/screen_protector.dart'; @@ -24,13 +23,13 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/animate_icon.comp.dart'; -import 'package:twonly/src/visual/elements/my_icon_button.element.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/keyboard_dismiss_observer.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart'; +import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart'; @@ -63,7 +62,6 @@ class _MediaViewerViewState extends State { DateTime? canBeSeenUntil; final ValueNotifier progress = ValueNotifier(0); bool showSendTextMessageInput = false; - double maxBottomInset = 0; DateTime? _lastTimeInputClosed; final GlobalKey mediaWidgetKey = GlobalKey(); @@ -505,7 +503,7 @@ class _MediaViewerViewState extends State { advanceToNextMediaOrExit(); } }); - progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) { + progressTimer = Timer.periodic(const Duration(milliseconds: 16), (timer) { final mediaFile = currentMedia?.mediaFile; if (mediaFile == null) return; if (mediaFile.displayLimitInMilliseconds == null || @@ -562,107 +560,49 @@ class _MediaViewerViewState extends State { } Widget bottomNavigation() { - return Row( + return MediaViewerBottomNavigationBar( key: mediaWidgetKey, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (currentMedia != null && - currentMessage != null && - !currentMedia!.mediaFile.requiresAuthentication && - currentMedia!.mediaFile.displayLimitInMilliseconds == null) - MyIconButton( - variant: MyIconButtonVariant.secondary, - onPressed: (currentMedia == null || currentMessage == null) - ? null - : onPressedSaveToGallery, - icon: imageSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - : imageSaved - ? const Icon(Icons.check) - : const FaIcon(FontAwesomeIcons.floppyDisk, size: 20), + currentMedia: currentMedia, + currentMessage: currentMessage, + imageSaving: imageSaving, + imageSaved: imageSaved, + showShortReactions: showShortReactions, + onSaveToGallery: onPressedSaveToGallery, + onToggleReactions: () { + if (!showShortReactions) { + displayShortReactions(); + } else { + setState(() { + showShortReactions = false; + }); + } + }, + onMessagePressed: () { + displayShortReactions(); + setState(() { + showSendTextMessageInput = true; + }); + }, + onCameraPressed: () async { + nextMediaTimer?.cancel(); + progressTimer?.cancel(); + await videoController?.pause(); + if (!mounted) return; + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return CameraSendToView(widget.group); + }, ), - const SizedBox(width: 10), - IconButton( - icon: SizedBox( - width: 30, - height: 30, - child: GridView.count( - crossAxisCount: 2, - children: List.generate( - 4, - (index) { - return SizedBox( - width: 8, - height: 8, - child: Center( - child: EmojiAnimationComp( - emoji: EmojiAnimationComp.animatedIcons.keys - .toList()[index], - ), - ), - ); - }, - ), - ), - ), - onPressed: () { - if (!showShortReactions) { - displayShortReactions(); - } else { - setState(() { - showShortReactions = false; - }); - } - }, - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(vertical: 10, horizontal: 20), - ), - ), - ), - const SizedBox(width: 10), - MyIconButton( - variant: MyIconButtonVariant.secondary, - onPressed: () async { - displayShortReactions(); - setState(() { - showSendTextMessageInput = true; - }); - }, - icon: const FaIcon( - FontAwesomeIcons.message, - size: 20, - ), - ), - const SizedBox(width: 10), - MyIconButton( - onPressed: () async { - nextMediaTimer?.cancel(); - progressTimer?.cancel(); - await videoController?.pause(); - if (!mounted) return; - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return CameraSendToView(widget.group); - }, - ), - ); - if (mounted && - currentMedia!.mediaFile.displayLimitInMilliseconds != null) { - await advanceToNextMediaOrExit(); - } else { - await videoController?.play(); - } - }, - icon: const FaIcon(FontAwesomeIcons.camera, size: 24), - ), - ], + ); + if (mounted && + currentMedia!.mediaFile.displayLimitInMilliseconds != null) { + await advanceToNextMediaOrExit(); + } else { + await videoController?.play(); + } + }, ); } @@ -717,124 +657,118 @@ class _MediaViewerViewState extends State { @override Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - if (bottomInset > maxBottomInset) { - maxBottomInset = bottomInset; - } else if (bottomInset == 0 && maxBottomInset > 0) { - maxBottomInset = 0; - if (showSendTextMessageInput) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - showSendTextMessageInput = false; - showShortReactions = false; - _lastTimeInputClosed = clock.now(); - }); - } + return KeyboardDismissObserver( + showSendTextMessageInput: showSendTextMessageInput, + onKeyboardDismissed: () { + setState(() { + showSendTextMessageInput = false; + showShortReactions = false; + _lastTimeInputClosed = clock.now(); }); - } - } - - return Scaffold( - body: SafeArea( - child: Stack( - fit: StackFit.expand, - children: [ - if (_showDownloadingLoader) _loader(), - if ((currentMedia != null || videoController != null) && - (canBeSeenUntil == null || progress.value >= 0)) - GestureDetector( - onTap: onScreenTapped, - onDoubleTap: (videoController == null) ? null : onScreenTapped, - child: MediaViewSizingHelper( - bottomNavigation: bottomNavigation(), - requiredHeight: 55, - child: MediaContentRenderer( - currentMedia: currentMedia, - videoController: videoController, - loader: _loader(), + }, + child: Scaffold( + body: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + if (_showDownloadingLoader) _loader(), + if ((currentMedia != null || videoController != null) && + (canBeSeenUntil == null || progress.value >= 0)) + GestureDetector( + onTap: onScreenTapped, + onDoubleTap: (videoController == null) + ? null + : onScreenTapped, + child: MediaViewSizingHelper( + bottomNavigation: bottomNavigation(), + requiredHeight: 55, + child: MediaContentRenderer( + currentMedia: currentMedia, + videoController: videoController, + loader: _loader(), + ), + ), + ), + if (displayTwonlyPresent) + TwonlyPresentOverlay( + onTap: () => loadAndDownloadCurrentMedia(showTwonly: true), + ), + if (currentMedia != null && + currentMedia?.mediaFile.downloadState != DownloadState.ready) + Positioned.fill(child: _loader()), + if (canBeSeenUntil != null || progress.value >= 0) + Positioned( + right: 20, + top: 27, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, child) { + return CircularProgressIndicator( + value: value, + strokeWidth: 2, + ); + }, + ), + ), + ], + ), + ), + Positioned( + top: 10, + left: showSendTextMessageInput ? 0 : null, + right: showSendTextMessageInput ? 0 : 15, + child: Text( + _currentMediaSender, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: showSendTextMessageInput ? 24 : 14, + fontWeight: FontWeight.bold, + color: showSendTextMessageInput + ? null + : const Color.fromARGB(255, 126, 126, 126), + shadows: const [ + Shadow( + color: Color.fromARGB(122, 0, 0, 0), + blurRadius: 5, + ), + ], ), ), ), - if (displayTwonlyPresent) - TwonlyPresentOverlay( - onTap: () => loadAndDownloadCurrentMedia(showTwonly: true), - ), - if (currentMedia != null && - currentMedia?.mediaFile.downloadState != DownloadState.ready) - Positioned.fill(child: _loader()), - if (canBeSeenUntil != null || progress.value >= 0) - Positioned( - right: 20, - top: 27, - child: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: ValueListenableBuilder( - valueListenable: progress, - builder: (context, value, child) { - return CircularProgressIndicator( - value: value, - strokeWidth: 2, - ); - }, - ), - ), - ], + if (showSendTextMessageInput) + MediaViewerMessageInput( + controller: textMessageController, + onSubmitted: (value) => _sendTextMessage(), + onSendPressed: _sendTextMessage, ), - ), - Positioned( - top: 10, - left: showSendTextMessageInput ? 0 : null, - right: showSendTextMessageInput ? 0 : 15, - child: Text( - _currentMediaSender, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: showSendTextMessageInput ? 24 : 14, - fontWeight: FontWeight.bold, - color: showSendTextMessageInput - ? null - : const Color.fromARGB(255, 126, 126, 126), - shadows: const [ - Shadow( - color: Color.fromARGB(122, 0, 0, 0), - blurRadius: 5, - ), - ], + if (currentMessage != null) + AdditionalMessageContent(currentMessage!), + if (currentMedia != null) + ReactionButtons( + show: showShortReactions, + textInputFocused: showSendTextMessageInput, + mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom, + groupId: widget.group.groupId, + messageId: currentMessage!.messageId, + emojiKey: emojiKey, + hide: () { + setState(() { + showShortReactions = false; + showSendTextMessageInput = false; + _lastTimeInputClosed = clock.now(); + }); + }, ), + Positioned.fill( + child: EmojiFloatWidget(key: emojiKey), ), - ), - if (showSendTextMessageInput) - MediaViewerMessageInput( - controller: textMessageController, - onSubmitted: (value) => _sendTextMessage(), - onSendPressed: _sendTextMessage, - ), - if (currentMessage != null) - AdditionalMessageContent(currentMessage!), - if (currentMedia != null) - ReactionButtons( - show: showShortReactions, - textInputFocused: showSendTextMessageInput, - mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom, - groupId: widget.group.groupId, - messageId: currentMessage!.messageId, - emojiKey: emojiKey, - hide: () { - setState(() { - showShortReactions = false; - showSendTextMessageInput = false; - _lastTimeInputClosed = clock.now(); - }); - }, - ), - Positioned.fill( - child: EmojiFloatWidget(key: emojiKey), - ), - ], + ], + ), ), ), ); diff --git a/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart b/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart index 4b5884a8..d76692f0 100644 --- a/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart +++ b/lib/src/visual/views/chats/media_viewer_components/emoji_reactions_row.comp.dart @@ -80,11 +80,18 @@ class _EmojiReactionWidgetState extends State { child: SizedBox( width: 40, child: Center( - child: widget.show - ? EmojiAnimationComp( - emoji: widget.emoji, - ) - : const SizedBox.shrink(), + child: AnimatedOpacity( + opacity: widget.show ? 1.0 : 0.0, + duration: widget.show + ? const Duration(milliseconds: 150) + : Duration.zero, + child: Offstage( + offstage: !widget.show, + child: EmojiAnimationComp( + emoji: widget.emoji, + ), + ), + ), ), ), ); diff --git a/lib/src/visual/views/chats/media_viewer_components/keyboard_dismiss_observer.comp.dart b/lib/src/visual/views/chats/media_viewer_components/keyboard_dismiss_observer.comp.dart new file mode 100644 index 00000000..afa9ec98 --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/keyboard_dismiss_observer.comp.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +/// Observes keyboard dismiss via viewInsets without causing the parent to +/// rebuild on every keyboard animation frame. +class KeyboardDismissObserver extends StatefulWidget { + const KeyboardDismissObserver({ + required this.showSendTextMessageInput, + required this.onKeyboardDismissed, + required this.child, + super.key, + }); + + final bool showSendTextMessageInput; + final VoidCallback onKeyboardDismissed; + final Widget child; + + @override + State createState() => + _KeyboardDismissObserverState(); +} + +class _KeyboardDismissObserverState extends State { + double _maxBottomInset = 0; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + if (bottomInset > _maxBottomInset) { + _maxBottomInset = bottomInset; + } else if (bottomInset == 0 && _maxBottomInset > 0) { + _maxBottomInset = 0; + if (widget.showSendTextMessageInput) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + widget.onKeyboardDismissed(); + } + }); + } + } + return widget.child; + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart b/lib/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart new file mode 100644 index 00000000..45c0de41 --- /dev/null +++ b/lib/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/visual/components/animate_icon.comp.dart'; +import 'package:twonly/src/visual/elements/my_icon_button.element.dart'; + +/// Extracted bottom navigation bar widget to provide subtree isolation. +/// Flutter can skip rebuilding this widget when only the parent's unrelated +/// state changes (e.g. progress timer, keyboard insets). +class MediaViewerBottomNavigationBar extends StatelessWidget { + const MediaViewerBottomNavigationBar({ + required this.currentMedia, + required this.currentMessage, + required this.imageSaving, + required this.imageSaved, + required this.showShortReactions, + required this.onSaveToGallery, + required this.onToggleReactions, + required this.onMessagePressed, + required this.onCameraPressed, + super.key, + }); + + final MediaFileService? currentMedia; + final Message? currentMessage; + final bool imageSaving; + final bool imageSaved; + final bool showShortReactions; + final VoidCallback onSaveToGallery; + final VoidCallback onToggleReactions; + final VoidCallback onMessagePressed; + final VoidCallback onCameraPressed; + + /// Pre-built static icon for the emoji grid button so the 4 Lottie + /// animations are created only once. + static final Widget _emojiGridIcon = SizedBox( + width: 30, + height: 30, + child: GridView.count( + crossAxisCount: 2, + children: List.generate( + 4, + (index) { + return SizedBox( + width: 8, + height: 8, + child: Center( + child: EmojiAnimationComp( + emoji: EmojiAnimationComp.animatedIconKeys[index], + ), + ), + ); + }, + ), + ), + ); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (currentMedia != null && + currentMessage != null && + !currentMedia!.mediaFile.requiresAuthentication && + currentMedia!.mediaFile.displayLimitInMilliseconds == null) + MyIconButton( + variant: MyIconButtonVariant.secondary, + onPressed: (currentMedia == null || currentMessage == null) + ? null + : onSaveToGallery, + icon: imageSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : imageSaved + ? const Icon(Icons.check) + : const FaIcon(FontAwesomeIcons.floppyDisk, size: 20), + ), + const SizedBox(width: 10), + IconButton( + icon: _emojiGridIcon, + onPressed: onToggleReactions, + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + ), + ), + ), + const SizedBox(width: 10), + MyIconButton( + variant: MyIconButtonVariant.secondary, + onPressed: onMessagePressed, + icon: const FaIcon( + FontAwesomeIcons.message, + size: 20, + ), + ), + const SizedBox(width: 10), + MyIconButton( + onPressed: onCameraPressed, + icon: const FaIcon(FontAwesomeIcons.camera, size: 24), + ), + ], + ); + } +} diff --git a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart index 1aae402d..a1a18e33 100644 --- a/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart +++ b/lib/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart @@ -335,9 +335,10 @@ class _ParticlePainter extends CustomPainter { _ParticlePainter(this.particles); final List<_Particle> particles; + static final _textPainter = TextPainter(textDirection: TextDirection.ltr); + @override void paint(Canvas canvas, Size size) { - final textPainter = TextPainter(textDirection: TextDirection.ltr); for (final p in particles) { final tp = TextSpan( text: p.emoji, @@ -350,14 +351,17 @@ class _ParticlePainter extends CustomPainter { : null, ), ); - textPainter + _textPainter ..text = tp ..layout(); canvas ..save() - ..translate(p.x - textPainter.width / 2, p.y - textPainter.height / 2) + ..translate( + p.x - _textPainter.width / 2, + p.y - _textPainter.height / 2, + ) ..rotate(p.rotation); - textPainter.paint(canvas, Offset.zero); + _textPainter.paint(canvas, Offset.zero); canvas.restore(); } }