diff --git a/AGENTS.md b/AGENTS.md index 77e38ed..ff1e7da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,4 @@ ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст') Never leave real data in test files, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead. Не пытайся собирать APK/AAB (flutter build apk, flutter build appbundle, gradle assemble) — сборку запускает пользователь, тебе достаточно flutter analyze +Если делаешь кнопку, где иконка переключается перечёркнутая/неперечёркнутая (вспышка, микрофон, звук, уведомления) — она должна анимироваться lottie-иконкой, а не подменяться мгновенно: добавь спеку в SLASH_SPECS в tool/make_morph_icons.py (fill=1.0, если кнопка рисует Icon(..., fill: 1)), прогони python3 tool/make_morph_icons.py и выводи через LottieSlashIcon. Ассеты в assets/lottie/ руками не правь — только через генератор. diff --git a/CLAUDE.md b/CLAUDE.md index dde1e2d..4be5eb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,20 @@ Incoming packets: transport → dispatcher → backend module → state → UI r - When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**. - Quality over quantity. - **Never leave real data in test files**, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead. +- **A button whose icon toggles between plain and slashed** (flash on/off, mic muted, sound, notifications) **must animate with a Lottie icon** — never swap two `Icon`s instantly. See *Animated icons* below. + +## Animated icons + +Everything in `assets/lottie/` is generated from the Material Symbols font by `tool/make_morph_icons.py` (stdlib-only Python, no deps). Never hand-edit the JSON — add a spec and re-run `python3 tool/make_morph_icons.py`. + +| Kind | Spec list | Widget | +|------|-----------|--------| +| Morph between two glyphs | `SPECS` | `ComposerMorphIcon` | +| Plain ↔ slashed toggle | `SLASH_SPECS` | `LottieSlashIcon` | + +A slash spec takes the plain and slashed codepoints; the generator lays both glyphs out as static layers and sweeps a mask across the diagonal, so the slash looks drawn on top of the icon. Pass `fill=1.0` when the button renders `Icon(..., fill: 1)` — contours are then taken from the `FILL=1` instance of the variable font. + +`LottieSlashIcon` plays the asset forward when `slashed` turns true and backward when it turns false, so a single asset covers both directions. The older `AnimatedSlashIcon` (clip wipe over two glyphs) stays where it is already used; new buttons use the Lottie one. ## Localization diff --git a/assets/lottie/ic_flash_on_to_off.json b/assets/lottie/ic_flash_on_to_off.json new file mode 100644 index 0000000..0506aa6 --- /dev/null +++ b/assets/lottie/ic_flash_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_flash_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-14.07,0.0],[-8.52,0.0],[-8.04,0.0],[-7.81,0.0],[-7.67,0.0],[-7.56,0.0],[-7.44,0.0],[-7.29,0.0],[-7.06,0.0],[-6.52,0.0],[0.0,0.0],[3.81,-13.32],[2.32,-8.1],[2.17,-7.61],[2.1,-7.36],[2.04,-7.15],[1.97,-6.89],[1.81,-6.35],[0.0,0.0],[-14.35,0.0],[-8.68,0.0],[-7.64,0.0],[0.0,0.0],[7.27,-10.51],[4.44,-6.41],[4.13,-5.97],[3.92,-5.67],[3.59,-5.19],[0.0,0.0],[8.91,8.91],[0.0,0.0],[0.0,0.0],[8.44,0.0],[0.0,0.0],[7.34,7.34],[0.0,0.0],[-3.61,12.56],[-2.2,7.65],[-2.06,7.17],[-1.98,6.89],[-1.9,6.61],[-1.75,6.07],[0.0,0.0],[13.3,0.0],[8.11,0.0],[7.56,0.0],[7.17,0.0],[6.57,0.0],[0.0,0.0],[0.0,-13.44],[0.0,0.0],[9.18,9.18],[5.36,5.36],[0.0,0.0],[0.0,13.44]],"o":[[0.0,0.0],[6.52,0.0],[7.06,0.0],[7.29,0.0],[7.44,0.0],[7.56,0.0],[7.67,0.0],[7.81,0.0],[8.04,0.0],[8.52,0.0],[14.07,0.0],[0.0,0.0],[-1.81,6.35],[-1.97,6.89],[-2.04,7.15],[-2.1,7.36],[-2.17,7.61],[-2.32,8.1],[-3.81,13.32],[0.0,0.0],[7.64,0.0],[8.68,0.0],[14.35,0.0],[0.0,0.0],[-3.59,5.19],[-3.92,5.67],[-4.13,5.97],[-4.44,6.41],[-7.27,10.51],[0.0,0.0],[-8.91,-8.91],[0.0,0.0],[0.0,0.0],[-8.44,0.0],[0.0,0.0],[-7.34,-7.34],[0.0,0.0],[1.75,-6.07],[1.9,-6.61],[1.98,-6.89],[2.06,-7.17],[2.2,-7.65],[3.61,-12.56],[0.0,0.0],[-6.57,0.0],[-7.17,0.0],[-7.56,0.0],[-8.11,0.0],[-13.3,0.0],[0.0,0.0],[0.0,13.44],[0.0,0.0],[-5.36,-5.36],[-9.18,-9.18],[0.0,0.0],[0.0,-13.44]],"v":[[175.0,50.0],[197.72,50.0],[220.43,50.0],[243.18,50.0],[265.91,50.0],[288.64,50.0],[311.36,50.0],[334.09,50.0],[356.82,50.0],[379.57,50.0],[402.28,50.0],[425.0,50.0],[418.75,71.87],[412.5,93.75],[406.25,115.62],[400.0,137.5],[393.75,159.38],[387.5,181.25],[381.25,203.13],[375.0,225.0],[400.0,225.0],[425.0,225.0],[450.0,225.0],[475.0,225.0],[462.82,242.6],[450.63,260.21],[438.44,277.81],[426.25,295.42],[414.06,313.03],[401.88,330.62],[384.06,312.81],[366.25,295.0],[380.0,275.0],[363.12,275.0],[346.25,275.0],[331.56,260.31],[316.88,245.63],[322.85,224.83],[328.84,204.03],[334.82,183.21],[340.8,162.41],[346.79,141.6],[352.77,120.79],[358.75,100.0],[336.47,100.0],[314.17,100.0],[291.88,100.0],[269.58,100.0],[247.28,100.0],[225.0,100.0],[225.0,126.87],[225.0,153.75],[208.33,137.08],[191.67,120.42],[175.0,103.75],[175.0,76.88]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,17.25],[0.0,10.51],[0.0,9.85],[0.0,9.47],[0.0,9.08],[0.0,8.34],[0.0,0.0],[18.75,0.0],[0.0,0.0],[0.0,19.05],[0.0,11.13],[0.0,0.0],[12.13,12.13],[7.39,7.39],[6.92,6.92],[6.66,6.66],[6.38,6.38],[5.86,5.86],[0.0,0.0],[-8.91,8.91],[0.0,0.0],[-13.86,-13.86],[-8.36,-8.36],[-7.87,-7.87],[-7.68,-7.68],[-7.56,-7.56],[-7.49,-7.49],[-7.44,-7.44],[-7.4,-7.4],[-7.36,-7.36],[-7.33,-7.33],[-7.3,-7.3],[-7.27,-7.27],[-7.24,-7.24],[-7.2,-7.2],[-7.16,-7.16],[-7.1,-7.1],[-7.02,-7.02],[-6.91,-6.91],[-6.7,-6.7],[-6.2,-6.2],[0.0,0.0],[8.91,-8.91],[0.0,0.0],[12.99,12.99],[7.91,7.91],[7.42,7.42],[7.13,7.13],[6.84,6.84],[6.28,6.28],[0.0,0.0],[11.02,-15.86],[6.71,-9.67],[6.18,-8.89],[5.6,-8.06]],"o":[[0.0,0.0],[0.0,-8.34],[0.0,-9.08],[0.0,-9.47],[0.0,-9.85],[0.0,-10.51],[0.0,-17.25],[0.0,0.0],[-18.75,0.0],[0.0,0.0],[0.0,-11.13],[0.0,-19.05],[0.0,0.0],[-5.86,-5.86],[-6.38,-6.38],[-6.66,-6.66],[-6.92,-6.92],[-7.39,-7.39],[-12.13,-12.13],[0.0,0.0],[8.91,-8.91],[0.0,0.0],[6.2,6.2],[6.7,6.7],[6.91,6.91],[7.02,7.02],[7.1,7.1],[7.16,7.16],[7.2,7.2],[7.24,7.24],[7.27,7.27],[7.3,7.3],[7.33,7.33],[7.36,7.36],[7.4,7.4],[7.44,7.44],[7.49,7.49],[7.56,7.56],[7.68,7.68],[7.87,7.87],[8.36,8.36],[13.86,13.86],[0.0,0.0],[-8.91,8.91],[0.0,0.0],[-6.28,-6.28],[-6.84,-6.84],[-7.13,-7.13],[-7.42,-7.42],[-7.91,-7.91],[-12.99,-12.99],[0.0,0.0],[-5.6,8.06],[-6.18,8.89],[-6.71,9.67],[-11.02,15.86]],"v":[[250.0,550.0],[250.0,521.44],[250.0,492.87],[250.0,464.29],[250.0,435.71],[250.0,407.13],[250.0,378.56],[250.0,350.0],[212.5,350.0],[175.0,350.0],[175.0,315.42],[175.0,280.83],[175.0,246.25],[154.92,226.17],[134.83,206.08],[114.73,185.98],[94.64,165.89],[74.55,145.8],[54.46,125.71],[34.38,105.62],[52.19,87.81],[70.0,70.0],[91.84,91.84],[113.78,113.78],[135.68,135.68],[157.6,157.6],[179.5,179.5],[201.4,201.4],[223.32,223.32],[245.23,245.23],[267.14,267.14],[289.05,289.05],[310.95,310.95],[332.86,332.86],[354.77,354.77],[376.68,376.68],[398.6,398.6],[420.5,420.5],[442.4,442.4],[464.32,464.32],[486.22,486.22],[508.16,508.16],[530.0,530.0],[512.19,547.81],[494.38,565.62],[472.87,544.12],[451.35,522.6],[429.82,501.07],[408.3,479.55],[386.78,458.03],[365.26,436.51],[343.75,415.0],[325.01,441.99],[306.25,469.0],[287.5,496.0],[268.74,523.01]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,6.32],[0.0,3.85],[0.0,3.54],[0.0,3.21],[0.0,0.0],[-6.92,0.0],[-4.19,0.0],[-3.96,0.0],[-3.85,0.0],[-3.78,0.0],[-3.73,0.0],[-3.69,0.0],[-3.63,0.0],[-3.56,0.0],[-3.45,0.0],[-3.19,0.0],[0.0,0.0],[2.01,-6.99],[1.21,-4.22],[1.15,-3.98],[1.11,-3.88],[1.1,-3.82],[1.08,-3.77],[1.07,-3.73],[1.06,-3.69],[1.05,-3.64],[1.03,-3.57],[1.0,-3.46],[0.92,-3.2],[0.0,0.0],[-4.21,-4.21],[-2.55,-2.55],[-2.24,-2.24],[0.0,0.0],[-6.2,0.0],[-3.62,0.0],[0.0,0.0],[3.44,-5.0],[0.0,0.0],[5.23,5.23],[3.16,3.16],[2.98,2.98],[2.9,2.9],[2.86,2.86],[2.82,2.82],[2.8,2.8],[2.78,2.78],[2.77,2.77],[2.75,2.75],[2.73,2.73],[2.71,2.71],[2.68,2.68],[2.63,2.63],[2.55,2.55],[2.36,2.36]],"o":[[0.0,0.0],[0.0,-3.21],[0.0,-3.54],[0.0,-3.85],[0.0,-6.32],[0.0,0.0],[3.19,0.0],[3.45,0.0],[3.56,0.0],[3.63,0.0],[3.69,0.0],[3.73,0.0],[3.78,0.0],[3.85,0.0],[3.96,0.0],[4.19,0.0],[6.92,0.0],[0.0,0.0],[-0.92,3.2],[-1.0,3.46],[-1.03,3.57],[-1.05,3.64],[-1.06,3.69],[-1.07,3.73],[-1.08,3.77],[-1.1,3.82],[-1.11,3.88],[-1.15,3.98],[-1.21,4.22],[-2.01,6.99],[0.0,0.0],[2.24,2.24],[2.55,2.55],[4.21,4.21],[0.0,0.0],[3.62,0.0],[6.2,0.0],[0.0,0.0],[-3.44,5.0],[0.0,0.0],[-2.36,-2.36],[-2.55,-2.55],[-2.63,-2.63],[-2.68,-2.68],[-2.71,-2.71],[-2.73,-2.73],[-2.75,-2.75],[-2.77,-2.77],[-2.78,-2.78],[-2.8,-2.8],[-2.82,-2.82],[-2.86,-2.86],[-2.9,-2.9],[-2.98,-2.98],[-3.16,-3.16],[-5.23,-5.23]],"v":[[225.0,153.75],[225.0,143.0],[225.0,132.25],[225.0,121.5],[225.0,110.75],[225.0,100.0],[236.13,100.0],[247.28,100.0],[258.43,100.0],[269.58,100.0],[280.73,100.0],[291.88,100.0],[303.02,100.0],[314.17,100.0],[325.32,100.0],[336.47,100.0],[347.62,100.0],[358.75,100.0],[355.53,111.19],[352.31,122.4],[349.09,133.6],[345.87,144.8],[342.64,156.01],[339.42,167.21],[336.2,178.41],[332.98,189.62],[329.76,200.82],[326.54,212.02],[323.32,223.23],[320.09,234.43],[316.88,245.63],[324.22,252.97],[331.56,260.31],[338.91,267.66],[346.25,275.0],[357.5,275.0],[368.75,275.0],[380.0,275.0],[373.13,285.0],[366.25,295.0],[357.96,286.71],[349.65,278.4],[341.33,270.08],[333.02,261.77],[324.71,253.46],[316.4,245.15],[308.09,236.84],[299.78,228.53],[291.47,220.22],[283.16,211.91],[274.85,203.6],[266.54,195.29],[258.23,186.98],[249.92,178.67],[241.6,170.35],[233.29,162.04]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"slashed","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-5.45,7.83],[-3.31,4.76],[-3.12,4.48],[-3.02,4.34],[-2.95,4.24],[-2.88,4.14],[-2.78,4.0],[-2.57,3.69],[0.0,0.0],[10.22,0.0],[6.19,0.0],[5.44,0.0],[0.0,0.0],[-2.81,9.85],[-1.7,5.97],[-1.61,5.63],[-1.56,5.47],[-1.53,5.37],[-1.51,5.29],[-1.49,5.21],[-1.46,5.1],[-1.41,4.94],[-1.3,4.56],[0.0,0.0],[10.18,0.0],[6.19,0.0],[5.82,0.0],[5.63,0.0],[5.47,0.0],[5.26,0.0],[4.85,0.0],[0.0,0.0],[0.0,-9.59],[0.0,-5.8],[0.0,-5.47],[0.0,-5.32],[0.0,-5.24],[0.0,-5.17],[0.0,-5.12],[0.0,-5.06],[0.0,-5.0],[0.0,-4.9],[0.0,-4.75],[0.0,-4.39],[0.0,0.0],[-8.81,0.0],[-5.37,0.0],[-4.94,0.0],[-4.48,0.0],[0.0,0.0],[0.0,-8.95],[0.0,-5.46],[0.0,-5.09],[0.0,-4.83],[0.0,-4.42]],"o":[[0.0,0.0],[2.57,-3.69],[2.78,-4.0],[2.88,-4.14],[2.95,-4.24],[3.02,-4.34],[3.12,-4.48],[3.31,-4.76],[5.45,-7.83],[0.0,0.0],[-5.44,0.0],[-6.19,0.0],[-10.22,0.0],[0.0,0.0],[1.3,-4.56],[1.41,-4.94],[1.46,-5.1],[1.49,-5.21],[1.51,-5.29],[1.53,-5.37],[1.56,-5.47],[1.61,-5.63],[1.7,-5.97],[2.81,-9.85],[0.0,0.0],[-4.85,0.0],[-5.26,0.0],[-5.47,0.0],[-5.63,0.0],[-5.82,0.0],[-6.19,0.0],[-10.18,0.0],[0.0,0.0],[0.0,4.39],[0.0,4.75],[0.0,4.9],[0.0,5.0],[0.0,5.06],[0.0,5.12],[0.0,5.17],[0.0,5.24],[0.0,5.32],[0.0,5.47],[0.0,5.8],[0.0,9.59],[0.0,0.0],[4.48,0.0],[4.94,0.0],[5.37,0.0],[8.81,0.0],[0.0,0.0],[0.0,4.42],[0.0,4.83],[0.0,5.09],[0.0,5.46],[0.0,8.95]],"v":[[300.0,390.0],[308.88,377.23],[317.77,364.45],[326.66,351.67],[335.55,338.89],[344.45,326.11],[353.34,313.33],[362.23,300.55],[371.12,287.77],[380.0,275.0],[362.19,275.0],[344.37,275.0],[326.56,275.0],[308.75,275.0],[313.29,259.1],[317.84,243.2],[322.39,227.27],[326.93,211.37],[331.48,195.45],[336.02,179.55],[340.57,163.63],[345.11,147.73],[349.66,131.8],[354.21,115.9],[358.75,100.0],[342.04,100.0],[325.32,100.0],[308.6,100.0],[291.88,100.0],[275.15,100.0],[258.43,100.0],[241.71,100.0],[225.0,100.0],[225.0,115.37],[225.0,130.76],[225.0,146.15],[225.0,161.53],[225.0,176.92],[225.0,192.31],[225.0,207.69],[225.0,223.08],[225.0,238.47],[225.0,253.85],[225.0,269.24],[225.0,284.63],[225.0,300.0],[239.99,300.0],[255.0,300.0],[270.0,300.0],[285.01,300.0],[300.0,300.0],[300.0,314.99],[300.0,330.0],[300.0,345.0],[300.0,360.0],[300.0,375.01]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,17.25],[0.0,10.51],[0.0,9.85],[0.0,9.47],[0.0,9.08],[0.0,8.34],[0.0,0.0],[13.77,0.0],[8.05,0.0],[0.0,0.0],[0.0,16.89],[0.0,10.23],[0.0,9.65],[0.0,9.38],[0.0,9.21],[0.0,9.07],[0.0,8.93],[0.0,8.75],[0.0,8.48],[0.0,7.82],[0.0,0.0],[-17.02,0.0],[-10.35,0.0],[-9.74,0.0],[-9.44,0.0],[-9.23,0.0],[-9.01,0.0],[-8.7,0.0],[-8.02,0.0],[0.0,0.0],[4.31,-15.09],[2.63,-9.19],[2.46,-8.62],[2.37,-8.28],[2.27,-7.94],[2.09,-7.3],[0.0,0.0],[-14.35,0.0],[-8.68,0.0],[-7.64,0.0],[0.0,0.0],[9.41,-13.59],[5.67,-8.19],[5.35,-7.73],[5.22,-7.54],[5.14,-7.42],[5.08,-7.34],[5.03,-7.27],[4.99,-7.21],[4.95,-7.16],[4.91,-7.09],[4.85,-7.01],[4.77,-6.88],[4.62,-6.67],[4.26,-6.16]],"o":[[0.0,0.0],[0.0,-8.34],[0.0,-9.08],[0.0,-9.47],[0.0,-9.85],[0.0,-10.51],[0.0,-17.25],[0.0,0.0],[-8.05,0.0],[-13.77,0.0],[0.0,0.0],[0.0,-7.82],[0.0,-8.48],[0.0,-8.75],[0.0,-8.93],[0.0,-9.07],[0.0,-9.21],[0.0,-9.38],[0.0,-9.65],[0.0,-10.23],[0.0,-16.89],[0.0,0.0],[8.02,0.0],[8.7,0.0],[9.01,0.0],[9.23,0.0],[9.44,0.0],[9.74,0.0],[10.35,0.0],[17.02,0.0],[0.0,0.0],[-2.09,7.3],[-2.27,7.94],[-2.37,8.28],[-2.46,8.62],[-2.63,9.19],[-4.31,15.09],[0.0,0.0],[7.64,0.0],[8.68,0.0],[14.35,0.0],[0.0,0.0],[-4.26,6.16],[-4.62,6.67],[-4.77,6.88],[-4.85,7.01],[-4.91,7.09],[-4.95,7.16],[-4.99,7.21],[-5.03,7.27],[-5.08,7.34],[-5.14,7.42],[-5.22,7.54],[-5.35,7.73],[-5.67,8.19],[-9.41,13.59]],"v":[[250.0,550.0],[250.0,521.44],[250.0,492.87],[250.0,464.29],[250.0,435.71],[250.0,407.13],[250.0,378.56],[250.0,350.0],[225.0,350.0],[200.0,350.0],[175.0,350.0],[175.0,322.74],[175.0,295.48],[175.0,268.18],[175.0,240.91],[175.0,213.64],[175.0,186.36],[175.0,159.09],[175.0,131.82],[175.0,104.52],[175.0,77.26],[175.0,50.0],[202.76,50.0],[230.54,50.0],[258.33,50.0],[286.11,50.0],[313.89,50.0],[341.67,50.0],[369.46,50.0],[397.24,50.0],[425.0,50.0],[417.86,74.99],[410.72,99.99],[403.57,125.0],[396.43,150.0],[389.28,175.01],[382.14,200.01],[375.0,225.0],[400.0,225.0],[425.0,225.0],[450.0,225.0],[475.0,225.0],[460.01,246.65],[445.02,268.3],[430.02,289.98],[415.01,311.65],[400.01,333.32],[385.0,354.99],[370.0,376.66],[355.0,398.34],[340.0,420.01],[324.99,441.68],[309.99,463.35],[294.98,485.02],[279.98,506.7],[264.99,528.35]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,8.95],[0.0,5.46],[0.0,5.09],[0.0,4.83],[0.0,4.42],[0.0,0.0],[8.81,0.0],[5.37,0.0],[4.94,0.0],[4.48,0.0],[0.0,0.0],[0.0,9.59],[0.0,5.8],[0.0,5.47],[0.0,5.32],[0.0,5.24],[0.0,5.17],[0.0,5.12],[0.0,5.06],[0.0,5.0],[0.0,4.9],[0.0,4.75],[0.0,4.39],[0.0,0.0],[-10.18,0.0],[-6.19,0.0],[-5.82,0.0],[-5.63,0.0],[-5.47,0.0],[-5.26,0.0],[-4.85,0.0],[0.0,0.0],[2.81,-9.85],[1.7,-5.97],[1.61,-5.63],[1.56,-5.47],[1.53,-5.37],[1.51,-5.29],[1.49,-5.21],[1.46,-5.1],[1.41,-4.94],[1.3,-4.56],[0.0,0.0],[-10.22,0.0],[-6.19,0.0],[-5.44,0.0],[0.0,0.0],[5.45,-7.83],[3.31,-4.76],[3.12,-4.48],[3.02,-4.34],[2.95,-4.24],[2.88,-4.14],[2.78,-4.0],[2.57,-3.69]],"o":[[0.0,0.0],[0.0,-4.42],[0.0,-4.83],[0.0,-5.09],[0.0,-5.46],[0.0,-8.95],[0.0,0.0],[-4.48,0.0],[-4.94,0.0],[-5.37,0.0],[-8.81,0.0],[0.0,0.0],[0.0,-4.39],[0.0,-4.75],[0.0,-4.9],[0.0,-5.0],[0.0,-5.06],[0.0,-5.12],[0.0,-5.17],[0.0,-5.24],[0.0,-5.32],[0.0,-5.47],[0.0,-5.8],[0.0,-9.59],[0.0,0.0],[4.85,0.0],[5.26,0.0],[5.47,0.0],[5.63,0.0],[5.82,0.0],[6.19,0.0],[10.18,0.0],[0.0,0.0],[-1.3,4.56],[-1.41,4.94],[-1.46,5.1],[-1.49,5.21],[-1.51,5.29],[-1.53,5.37],[-1.56,5.47],[-1.61,5.63],[-1.7,5.97],[-2.81,9.85],[0.0,0.0],[5.44,0.0],[6.19,0.0],[10.22,0.0],[0.0,0.0],[-2.57,3.69],[-2.78,4.0],[-2.88,4.14],[-2.95,4.24],[-3.02,4.34],[-3.12,4.48],[-3.31,4.76],[-5.45,7.83]],"v":[[300.0,390.0],[300.0,375.01],[300.0,360.0],[300.0,345.0],[300.0,330.0],[300.0,314.99],[300.0,300.0],[285.01,300.0],[270.0,300.0],[255.0,300.0],[239.99,300.0],[225.0,300.0],[225.0,284.63],[225.0,269.24],[225.0,253.85],[225.0,238.47],[225.0,223.08],[225.0,207.69],[225.0,192.31],[225.0,176.92],[225.0,161.53],[225.0,146.15],[225.0,130.76],[225.0,115.37],[225.0,100.0],[241.71,100.0],[258.43,100.0],[275.15,100.0],[291.88,100.0],[308.6,100.0],[325.32,100.0],[342.04,100.0],[358.75,100.0],[354.21,115.9],[349.66,131.8],[345.11,147.73],[340.57,163.63],[336.02,179.55],[331.48,195.45],[326.93,211.37],[322.39,227.27],[317.84,243.2],[313.29,259.1],[308.75,275.0],[326.56,275.0],[344.38,275.0],[362.19,275.0],[380.0,275.0],[371.12,287.77],[362.23,300.55],[353.34,313.33],[344.45,326.11],[335.55,338.89],[326.66,351.67],[317.77,364.45],[308.88,377.23]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"plain","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/lib/backend/modules/upload_notification_service.dart b/lib/backend/modules/upload_notification_service.dart index c65989b..19240a0 100644 --- a/lib/backend/modules/upload_notification_service.dart +++ b/lib/backend/modules/upload_notification_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -5,7 +7,7 @@ import 'package:flutter/widgets.dart'; import '../../l10n/app_localizations.dart'; import '../../main.dart' show KometApp; -enum UploadKind { photo, video, file } +enum UploadKind { photo, video, videoNote, voice, file } class _NotificationJob { _NotificationJob({required this.kind, required this.count, this.filename}); @@ -41,6 +43,8 @@ class _NotificationJob { return switch (kind) { UploadKind.photo => l10n.uploadNotificationPhotos(count), UploadKind.video => l10n.uploadNotificationVideo, + UploadKind.videoNote => l10n.uploadNotificationVideoNote, + UploadKind.voice => l10n.uploadNotificationVoice, UploadKind.file => name == null || name.isEmpty ? l10n.uploadNotificationFile : name, }; @@ -52,8 +56,10 @@ class UploadNotificationService { 'ru.komet.app/upload_service', ); static const int _minIntervalMs = 350; + static const Duration _startDelay = Duration(milliseconds: 700); static final Map _jobs = {}; + static Timer? _startTimer; static bool _running = false; static String? _lastTitle; static String? _lastBody; @@ -75,7 +81,14 @@ class UploadNotificationService { count: count < 1 ? 1 : count, filename: filename, ); - _push(force: true); + if (_running) { + _push(force: true); + return; + } + _startTimer ??= Timer(_startDelay, () { + _startTimer = null; + _push(force: true); + }); } static void report( @@ -102,16 +115,20 @@ class UploadNotificationService { } static void _stop() { + _startTimer?.cancel(); + _startTimer = null; + final wasRunning = _running; _running = false; _lastTitle = null; _lastBody = null; _lastPercent = -1; _lastPushAt = 0; - _invoke('stop', const {}); + if (wasRunning) _invoke('stop', const {}); } static void _push({bool force = false}) { if (_jobs.isEmpty) return; + if (!_running && _startTimer != null) return; var sumSent = 0; var sumTotal = 0; diff --git a/lib/backend/modules/upload_service.dart b/lib/backend/modules/upload_service.dart index 149a427..893cf09 100644 --- a/lib/backend/modules/upload_service.dart +++ b/lib/backend/modules/upload_service.dart @@ -291,6 +291,106 @@ class UploadService { ); } + Future sendVoice({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required int durationMs, + required Uint8List wave, + CachedMessage? placeholder, + }) { + return _sendRecording( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: file, + kind: UploadKind.voice, + placeholder: placeholder, + requestUpload: () async { + final info = await messagesModule.requestAudioUploadUrl(); + return info == null ? null : (url: info.url, token: info.token); + }, + send: (token) => messagesModule.sendAudioMessage( + chatId, + token, + duration: durationMs, + wave: wave, + ), + ); + } + + Future sendVideoNote({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required int durationMs, + CachedMessage? placeholder, + }) { + return _sendRecording( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: file, + kind: UploadKind.videoNote, + placeholder: placeholder, + requestUpload: () async { + final info = await messagesModule.requestVideoNoteUploadUrl(); + return info == null ? null : (url: info.url, token: info.token); + }, + send: (token) => messagesModule.sendVideoNoteMessage( + chatId, + token, + duration: durationMs, + ), + ); + } + + Future _sendRecording({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required UploadKind kind, + required Future<({String url, String token})?> Function() requestUpload, + required Future?> Function(String token) send, + CachedMessage? placeholder, + }) { + return _run( + UploadJob._( + id: tempId, + accountId: accountId, + chatId: chatId, + kind: kind, + slots: 1, + placeholder: placeholder, + ), + (job) async { + try { + final info = await requestUpload(); + if (info == null || info.url.isEmpty) { + throw const UploadFailure('no_upload_url'); + } + final ok = await fileUploader.uploadMediaFile( + Uri.parse(info.url), + file, + onProgress: (sent, total) => job.report(0, sent, total), + ); + if (!ok) throw const UploadFailure('upload_failed'); + job.markUploaded(); + final sent = await send(info.token); + if (sent == null) return null; + return CachedMessage.fromPushPayload(accountId, chatId, sent); + } finally { + try { + await file.delete(); + } catch (_) {} + } + }, + ); + } + Future sendFile({ required int accountId, required int chatId, diff --git a/lib/frontend/screens/chats/chat/video_note_controller.dart b/lib/frontend/screens/chats/chat/video_note_controller.dart index 6976a2f..a137405 100644 --- a/lib/frontend/screens/chats/chat/video_note_controller.dart +++ b/lib/frontend/screens/chats/chat/video_note_controller.dart @@ -6,6 +6,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; +import 'package:lottie/lottie.dart' show AssetLottie; import 'package:path_provider/path_provider.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -16,8 +17,11 @@ import '../../../../core/media/native_video_note_recorder.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; import '../../../widgets/custom_notification.dart'; +import '../../../widgets/lottie_slash_icon.dart'; import 'voice_record_controller.dart'; +const String _flashIcon = 'assets/lottie/ic_flash_on_to_off.json'; + class VideoNoteController { VideoNoteController({ required this.contextOf, @@ -89,6 +93,7 @@ class VideoNoteController { bool get _stub => !_rec.isAvailable; Future _initCamera() async { + unawaited(AssetLottie(_flashIcon).load()); if (_stub) { _camReady.value = true; _textureId.value = null; @@ -457,17 +462,24 @@ class _CameraControls extends StatelessWidget { children: [ if (controller.cameraControlsAvailable) _ControlButton( - icon: Symbols.flip_camera_ios, - color: cs.onSurface, onTap: controller.flipCamera, + child: Icon( + Symbols.flip_camera_ios, + size: 24, + color: cs.onSurface, + fill: 1, + ), ), if (controller.flashAvailable) ValueListenableBuilder( valueListenable: controller.flashOn, builder: (context, on, _) => _ControlButton( - icon: on ? Symbols.flash_on : Symbols.flash_off, - color: on ? cs.primary : cs.onSurface, onTap: controller.toggleFlash, + child: LottieSlashIcon( + asset: _flashIcon, + slashed: !on, + color: on ? cs.primary : cs.onSurface, + ), ), ), ], @@ -477,14 +489,9 @@ class _CameraControls extends StatelessWidget { } class _ControlButton extends StatelessWidget { - const _ControlButton({ - required this.icon, - required this.color, - required this.onTap, - }); + const _ControlButton({required this.child, required this.onTap}); - final IconData icon; - final Color color; + final Widget child; final VoidCallback onTap; @override @@ -492,10 +499,7 @@ class _ControlButton extends StatelessWidget { return InkResponse( onTap: onTap, radius: 26, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon(icon, size: 24, color: color, fill: 1), - ), + child: Padding(padding: const EdgeInsets.all(8), child: child), ); } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 3b87250..57ba193 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -337,7 +337,6 @@ class _ChatScreenState extends State final ValueNotifier _reactionAnimation = ValueNotifier(null); int _reactionAnimationToken = 0; - final Map>> _photoUploadProgress = {}; final ValueNotifier _scheduledCount = ValueNotifier(0); late final VoiceRecordController _voiceRec = VoiceRecordController( @@ -358,7 +357,7 @@ class _ChatScreenState extends State StreamSubscription? _uploadEventSub; ValueListenable>? _photoProgressFor(CachedMessage m) => - _photoUploadProgress[m.id] ?? UploadService.instance.progressFor(m.id); + UploadService.instance.progressFor(m.id); ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; @@ -2088,10 +2087,6 @@ class _ChatScreenState extends State } _reactionNotifiers.clear(); _reactionAnimation.dispose(); - for (final n in _photoUploadProgress.values) { - n.dispose(); - } - _photoUploadProgress.clear(); ChatActivityStore.instance .listenable(widget.chatId) .removeListener(_recomputeHeaderStatus); @@ -6022,77 +6017,24 @@ class _ChatScreenState extends State return; } final wave = _buildWave(amps); - final tempId = _nextTempId(); - final progress = ValueNotifier>(const [0]); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - time: DateTime.now().millisecondsSinceEpoch, - status: 'sending', - attachments: [AudioAttachment(duration: durationMs)], + final placeholder = _addOptimisticMediaMessage( + AudioAttachment( + duration: durationMs, + waveform: String.fromCharCodes(wave), ), ); - _lastSentId = tempId; - _bumpMessages(); - Haptics.send(); - _scrollToBottom(); - try { - final info = await messagesModule.requestAudioUploadUrl(); - if (info == null || info.url.isEmpty) throw Exception('no_url'); - - final ok = await fileUploader.uploadMediaFile( - Uri.parse(info.url), - file, - onProgress: (sent, total) { - if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; - }, - ); - if (!ok) throw Exception('upload_failed'); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - - final serverMsg = await messagesModule.sendAudioMessage( - widget.chatId, - info.token, - duration: durationMs, + unawaited( + UploadService.instance.sendVoice( + accountId: _myId, + chatId: widget.chatId, + tempId: placeholder.id, + file: file, + durationMs: durationMs, wave: wave, - ); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) throw Exception('send_failed'); - - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real, removeId: tempId)); - } - _disposePhotoProgress(tempId); - } catch (_) { - if (mounted) { - _failPhotoMessage(tempId); - } else { - _disposePhotoProgress(tempId); - } - } finally { - try { - await file.delete(); - } catch (_) {} - } + placeholder: placeholder, + ), + ); } Future _sendVideoNote(File file, int durationMs) async { @@ -6102,85 +6044,23 @@ class _ChatScreenState extends State } catch (_) {} return; } - final tempId = _nextTempId(); - final progress = ValueNotifier>(const [0]); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, + final placeholder = _addOptimisticMediaMessage( + VideoAttachment(duration: durationMs, videoType: 1, localPath: file.path), + ); + + unawaited( + UploadService.instance.sendVideoNote( accountId: _myId, chatId: widget.chatId, - senderId: _myId, - time: DateTime.now().millisecondsSinceEpoch, - status: 'sending', - attachments: [VideoAttachment(duration: durationMs, videoType: 1)], + tempId: placeholder.id, + file: file, + durationMs: durationMs, + placeholder: placeholder, ), ); - _lastSentId = tempId; - _bumpMessages(); - Haptics.send(); - _scrollToBottom(); - - try { - final info = await messagesModule.requestVideoNoteUploadUrl(); - if (info == null || info.url.isEmpty) throw Exception('no_url'); - final ok = await fileUploader.uploadMediaFile( - Uri.parse(info.url), - file, - onProgress: (sent, total) { - if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; - }, - ); - if (!ok) throw Exception('upload_failed'); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - final serverMsg = await messagesModule.sendVideoNoteMessage( - widget.chatId, - info.token, - duration: durationMs, - ); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) throw Exception('send_failed'); - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real, removeId: tempId)); - } - _disposePhotoProgress(tempId); - } catch (e) { - logger.w('sendVideoNote failed: $e'); - if (mounted) { - _failPhotoMessage(tempId); - final reason = e is PacketError - ? '${e.errorKey ?? ''} ${e.message}'.trim() - : e is Exception && e.toString().contains('send_failed') - ? 'сервер не обработал видео' - : e is Exception && e.toString().contains('upload_failed') - ? 'загрузка отклонена' - : e.toString(); - showCustomNotification(context, 'Кружок не отправлен: $reason'); - } else { - _disposePhotoProgress(tempId); - } - } finally { - try { - await file.delete(); - } catch (_) {} - } } - CachedMessage _addOptimisticFileMessage(FileAttachment attachment) { + CachedMessage _addOptimisticMediaMessage(MessageAttachment attachment) { final now = DateTime.now().millisecondsSinceEpoch; final tempId = _nextTempId(); final msg = CachedMessage( @@ -6225,7 +6105,7 @@ class _ChatScreenState extends State } Future _sendHistoryFile(FileHistoryEntry entry) async { - final tempId = _addOptimisticFileMessage( + final tempId = _addOptimisticMediaMessage( FileAttachment( fileId: entry.fileId, fileToken: entry.token, @@ -6251,7 +6131,7 @@ class _ChatScreenState extends State } Future _sendFileById(int fileId) async { - final tempId = _addOptimisticFileMessage( + final tempId = _addOptimisticMediaMessage( FileAttachment(fileId: fileId), ).id; try { @@ -6478,12 +6358,26 @@ class _ChatScreenState extends State return; } _failPhotoMessage(event.tempId); - if (event.kind == UploadKind.file) { - showCustomNotification(context, 'Ошибка: ${event.reason}'); - } + final text = _uploadFailureText(event.kind, event.reason); + if (text != null) showCustomNotification(context, text); } } + String? _uploadFailureText(UploadKind kind, String reason) { + final detail = switch (reason) { + 'no_upload_url' => 'сервер не выдал ссылку', + 'upload_failed' => 'загрузка отклонена', + 'send_failed' => 'сервер не принял сообщение', + _ => reason, + }; + return switch (kind) { + UploadKind.file => 'Ошибка: $reason', + UploadKind.videoNote => 'Кружок не отправлен: $detail', + UploadKind.voice => 'Голосовое не отправлено: $detail', + UploadKind.photo || UploadKind.video => null, + }; + } + void _syncUploadStatus() { final job = UploadService.instance.activeFileJob(widget.chatId); if (job?.id == _uploadStatusJobId) return; @@ -6735,21 +6629,15 @@ class _ChatScreenState extends State ); } - void _failPhotoMessage(String tempId) { final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { _messages[idx] = _messages[idx].copyWith(status: 'error'); _bumpMessages(); } - _disposePhotoProgress(tempId); Haptics.error(); } - void _disposePhotoProgress(String tempId) { - _photoUploadProgress.remove(tempId)?.dispose(); - } - void _refuseUnencrypted(String what) { if (!mounted) return; _showAttachmentPanel.value = false; @@ -6836,7 +6724,7 @@ class _ChatScreenState extends State final placeholder = scheduledTime != null ? null - : _addOptimisticFileMessage( + : _addOptimisticMediaMessage( FileAttachment(name: filename, size: size), ); diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart index 727b102..5dd19d5 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -33,6 +33,7 @@ class VideoBubble extends StatelessWidget { cs: ctx.cs, textColor: ctx.text, meta: ctx.meta(), + uploadProgress: ctx.uploadProgress, ); } final hasCaption = message.text != null && message.text!.isNotEmpty; diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index 5198566..9abb6d8 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -1,7 +1,9 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -15,6 +17,7 @@ import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; import '../../../../models/attachment.dart'; import '../../small_spinner.dart'; +import '../../upload_progress_ring.dart'; class VideoNoteBubble extends StatefulWidget { final VideoAttachment attachment; @@ -26,6 +29,7 @@ class VideoNoteBubble extends StatefulWidget { final ColorScheme cs; final Color textColor; final Widget meta; + final ValueListenable>? uploadProgress; const VideoNoteBubble({ super.key, @@ -38,6 +42,7 @@ class VideoNoteBubble extends StatefulWidget { required this.cs, required this.textColor, required this.meta, + this.uploadProgress, }); @override @@ -57,6 +62,7 @@ class _VideoNoteBubbleState extends State final ValueNotifier _ringProgress = ValueNotifier(0); Uint8List? _preview; VideoPlayerController? _controller; + VideoPlayerController? _local; Future? _initializing; Duration? _pendingSeek; double? _lastAngle; @@ -70,6 +76,19 @@ class _VideoNoteBubbleState extends State int? get _videoId => widget.attachment.videoId; String get _cacheName => 'videonote_$_videoId.mp4'; int get _attachmentDurationMs => widget.attachment.duration ?? 0; + String? get _localPath => widget.attachment.localPath; + + String? get _posterUrl { + for (final candidate in [ + widget.attachment.thumbnail, + widget.attachment.baseUrl, + ]) { + if (candidate == null || candidate.isEmpty) continue; + if (candidate.startsWith('data:')) continue; + return candidate; + } + return null; + } bool get _ready { final controller = _controller; @@ -81,7 +100,14 @@ class _VideoNoteBubbleState extends State super.initState(); _expand = AnimationController(vsync: this, duration: _expandDuration); _preview = _previewBytes(widget.attachment.previewData); - if (VideoNotePreloader.autoLoads(widget.attachment.duration)) _preload(); + final local = _localPath; + if (local != null) { + unawaited(_openLocalPreview(File(local))); + return; + } + if (VideoNotePreloader.autoLoads(widget.attachment.duration)) { + unawaited(_warmCache()); + } } @override @@ -90,6 +116,7 @@ class _VideoNoteBubbleState extends State if (old.attachment.previewData != widget.attachment.previewData) { _preview = _previewBytes(widget.attachment.previewData); } + if (old.attachment.localPath != _localPath) _dropLocalPreview(); } @override @@ -98,6 +125,7 @@ class _VideoNoteBubbleState extends State _PreviewPool.unregister(this); _expand.dispose(); _ringProgress.dispose(); + _dropLocalPreview(); final controller = _controller; _controller = null; if (controller != null) { @@ -107,6 +135,34 @@ class _VideoNoteBubbleState extends State super.dispose(); } + Future _openLocalPreview(File file) async { + final controller = VideoPlayerController.file(file); + try { + await controller.initialize(); + } catch (e) { + logger.w('VideoNoteBubble: локальное превью не открылось: $e'); + await controller.dispose(); + return; + } + if (!mounted || file.path != _localPath) { + await controller.dispose(); + return; + } + await controller.setVolume(0); + if (!mounted) { + await controller.dispose(); + return; + } + setState(() => _local = controller); + } + + void _dropLocalPreview() { + final local = _local; + if (local == null) return; + _local = null; + unawaited(local.dispose()); + } + void _claimPlayback() { final controller = _controller; if (controller == null) return; @@ -152,11 +208,7 @@ class _VideoNoteBubbleState extends State } } - Future _preload() async { - final file = await _fetch(priority: false); - if (file == null || !mounted) return; - await _ensureController(file); - } + Future _warmCache() => _fetch(priority: false); Future _fetch({required bool priority}) { final videoId = _videoId; @@ -176,6 +228,7 @@ class _VideoNoteBubbleState extends State } Future _ensureController(File file) async { + if (!mounted) return null; if (_controller != null) return _controller; final live = MediaPlayback.instance.liveVideoNote(_cacheName); if (live != null) { @@ -230,6 +283,7 @@ class _VideoNoteBubbleState extends State } Future _toggle() async { + if (_videoId == null) return; if (_ready) { if (_controller!.value.isPlaying) { await _pause(); @@ -388,13 +442,14 @@ class _VideoNoteBubbleState extends State } Widget _buildCircle(double size) { - final controller = _controller; final ready = _ready; final playing = ready && _playing && !_scrubbing; final preview = _preview; + final local = _local; + final uploading = widget.uploadProgress; return GestureDetector( - onTap: _toggle, + onTap: uploading == null ? _toggle : null, child: SizedBox( width: size, height: size, @@ -409,63 +464,112 @@ class _VideoNoteBubbleState extends State child: AnimatedSwitcher( duration: _swapDuration, child: ready - ? SizedBox.expand( - key: const ValueKey('note-video'), - child: FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: controller!.value.size.width, - height: controller.value.size.height, - child: VideoPlayer(controller), - ), - ), + ? _videoSurface( + _controller!, + const ValueKey('note-video'), ) - : preview != null - ? SizedBox.expand( - key: const ValueKey('note-preview'), - child: Image.memory( - preview, - fit: BoxFit.cover, - gaplessPlayback: true, - ), - ) - : SizedBox.expand( - key: const ValueKey('note-empty'), - child: ColoredBox( - color: widget.cs.surfaceContainerHighest, - ), - ), + : local != null && local.value.isInitialized + ? _videoSurface(local, const ValueKey('note-local')) + : _buildPoster(preview), ), ), ), ), - if (ready) _buildRing(size), - if (!playing) - Container( - width: 52, - height: 52, - decoration: const BoxDecoration( - color: Colors.black45, - shape: BoxShape.circle, + if (uploading != null) ...[ + Positioned.fill( + child: ClipOval( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.35), + ), ), - child: _loading - ? const Padding( - padding: EdgeInsets.all(14), - child: SmallSpinner(size: 36, color: Colors.white), - ) - : Icon( - _error ? Symbols.error : Symbols.play_arrow, - color: Colors.white, - size: 30, - ), ), + UploadProgressRing( + progress: uploading, + color: Colors.white, + trackColor: Colors.white24, + ), + ] else ...[ + if (ready) _buildRing(size), + if (!playing) + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: _loading + ? const Padding( + padding: EdgeInsets.all(14), + child: SmallSpinner(size: 36, color: Colors.white), + ) + : Icon( + _error ? Symbols.error : Symbols.play_arrow, + color: Colors.white, + size: 30, + ), + ), + ], ], ), ), ); } + Widget _buildPoster(Uint8List? preview) { + final url = _posterUrl; + if (url == null) { + return _inlinePreview(preview, const ValueKey('note-preview')); + } + + final dpr = MediaQuery.devicePixelRatioOf(context); + return SizedBox.expand( + key: const ValueKey('note-poster'), + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: (_baseSize * dpr).round(), + fadeInDuration: _swapDuration, + placeholderFadeInDuration: Duration.zero, + placeholder: (_, _) => _inlinePreview(preview, null), + errorWidget: (_, _, _) => _inlinePreview(preview, null), + ), + ); + } + + Widget _inlinePreview(Uint8List? preview, Key? key) { + if (preview == null) { + return SizedBox.expand( + key: key, + child: ColoredBox(color: widget.cs.surfaceContainerHighest), + ); + } + return SizedBox.expand( + key: key, + child: Image.memory( + preview, + fit: BoxFit.cover, + gaplessPlayback: true, + filterQuality: FilterQuality.medium, + ), + ); + } + + Widget _videoSurface(VideoPlayerController controller, Key key) { + return SizedBox.expand( + key: key, + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: controller.value.size.width, + height: controller.value.size.height, + child: VideoPlayer(controller), + ), + ), + ); + } + Widget _buildRing(double size) { return GestureDetector( onTapUp: (details) => _ringTap(details.localPosition, size), diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index d209432..c41e915 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -29,6 +29,7 @@ class VoiceMessageBubble extends StatefulWidget { final int senderId; final int? audioId; final String? preloadedText; + final ValueListenable>? uploadProgress; const VoiceMessageBubble({ super.key, @@ -47,6 +48,7 @@ class VoiceMessageBubble extends StatefulWidget { required this.senderId, this.audioId, this.preloadedText, + this.uploadProgress, }); @override @@ -101,7 +103,10 @@ class _VoiceMessageBubbleState extends State { ); } + bool get _uploading => widget.uploadProgress != null; + void _toggle() { + if (_uploading) return; _claimPlayback(); _audio.toggle(); } @@ -176,8 +181,9 @@ class _VoiceMessageBubbleState extends State { widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary; Widget _buildPlayButton() { + final uploading = widget.uploadProgress; return GestureDetector( - onTap: _toggle, + onTap: uploading == null ? _toggle : null, child: Container( width: 32, height: 32, @@ -187,46 +193,73 @@ class _VoiceMessageBubbleState extends State { : widget.cs.primaryContainer, shape: BoxShape.circle, ), - child: AnimatedBuilder( - animation: Listenable.merge([ - _audio.downloaded, - _audio.downloadProgress, - _audio.playing, - ]), - builder: (context, _) { - final progress = _audio.downloadProgress.value; - if (progress != null) { - return Padding( - padding: const EdgeInsets.all(4), - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress > 0 ? progress : null, - color: _accent, - backgroundColor: _accent.withValues(alpha: 0.2), - ), - ); - } - final IconData icon; - if (_audio.playing.value) { - icon = Symbols.pause; - } else if (_audio.downloaded.value) { - icon = Symbols.play_arrow; - } else { - icon = Symbols.arrow_downward; - } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 160), - transitionBuilder: (child, animation) => - ScaleTransition(scale: animation, child: child), - child: Icon( - icon, - key: ValueKey(icon), - color: _accent, - size: 18, + child: uploading != null + ? _buildUploadIndicator(uploading) + : AnimatedBuilder( + animation: Listenable.merge([ + _audio.downloaded, + _audio.downloadProgress, + _audio.playing, + ]), + builder: (context, _) { + final progress = _audio.downloadProgress.value; + if (progress != null) { + return Padding( + padding: const EdgeInsets.all(4), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: _accent, + backgroundColor: _accent.withValues(alpha: 0.2), + ), + ); + } + final IconData icon; + if (_audio.playing.value) { + icon = Symbols.pause; + } else if (_audio.downloaded.value) { + icon = Symbols.play_arrow; + } else { + icon = Symbols.arrow_downward; + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + transitionBuilder: (child, animation) => + ScaleTransition(scale: animation, child: child), + child: Icon( + icon, + key: ValueKey(icon), + color: _accent, + size: 18, + ), + ); + }, ), - ); - }, - ), + ), + ); + } + + Widget _buildUploadIndicator(ValueListenable> progress) { + return Padding( + padding: const EdgeInsets.all(4), + child: ValueListenableBuilder>( + valueListenable: progress, + builder: (context, values, _) { + final value = values.isEmpty + ? 0.0 + : values.reduce((a, b) => a + b) / values.length; + return TweenAnimationBuilder( + tween: Tween(end: value.clamp(0.0, 1.0)), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + builder: (context, shown, _) => CircularProgressIndicator( + strokeWidth: 2, + value: shown >= 1.0 ? null : shown, + color: _accent, + backgroundColor: _accent.withValues(alpha: 0.2), + ), + ); + }, ), ); } @@ -626,7 +659,10 @@ class _WaveformPainter extends CustomPainter { void _paintKnob(Canvas canvas, Size size, double center) { if (!knob) return; - final x = (size.width * progress.clamp(0.0, 1.0)).clamp(1.5, size.width - 1.5); + final x = (size.width * progress.clamp(0.0, 1.0)).clamp( + 1.5, + size.width - 1.5, + ); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(x - 1.5, 0, 3, size.height), diff --git a/lib/frontend/widgets/lottie_slash_icon.dart b/lib/frontend/widgets/lottie_slash_icon.dart new file mode 100644 index 0000000..826f583 --- /dev/null +++ b/lib/frontend/widgets/lottie_slash_icon.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +class LottieSlashIcon extends StatefulWidget { + const LottieSlashIcon({ + super.key, + required this.asset, + required this.slashed, + required this.color, + this.size = 24, + this.duration = const Duration(milliseconds: 320), + }); + + final String asset; + final bool slashed; + final Color color; + final double size; + final Duration duration; + + @override + State createState() => _LottieSlashIconState(); +} + +class _LottieSlashIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + value: widget.slashed ? 1 : 0, + ); + + @override + void didUpdateWidget(LottieSlashIcon old) { + super.didUpdateWidget(old); + _controller.duration = widget.duration; + if (widget.slashed == old.slashed) return; + if (widget.slashed) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: widget.size, + child: Lottie.asset( + widget.asset, + controller: _controller, + fit: BoxFit.contain, + delegates: LottieDelegates( + values: [ + ValueDelegate.color(const ['**'], value: widget.color), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 2b03e45..293788a 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1936,6 +1936,7 @@ class MessageBubble extends StatelessWidget { senderId: message.senderId, audioId: audioId, preloadedText: cachedTranscription?.text, + uploadProgress: ctx.uploadProgress, ); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4d71677..e26e514 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1070,6 +1070,8 @@ } }, "uploadNotificationVideo": "Video", + "uploadNotificationVideoNote": "Video message", + "uploadNotificationVoice": "Voice message", "uploadNotificationFile": "File", "uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}", "@uploadNotificationMultiple": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8dbb429..725ab88 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4706,6 +4706,18 @@ abstract class AppLocalizations { /// **'Video'** String get uploadNotificationVideo; + /// No description provided for @uploadNotificationVideoNote. + /// + /// In en, this message translates to: + /// **'Video message'** + String get uploadNotificationVideoNote; + + /// No description provided for @uploadNotificationVoice. + /// + /// In en, this message translates to: + /// **'Voice message'** + String get uploadNotificationVoice; + /// No description provided for @uploadNotificationFile. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e32deb6..6e6d933 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2464,6 +2464,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get uploadNotificationVideo => 'Video'; + @override + String get uploadNotificationVideoNote => 'Video message'; + + @override + String get uploadNotificationVoice => 'Voice message'; + @override String get uploadNotificationFile => 'File'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 662435e..8b12b7a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2478,6 +2478,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get uploadNotificationVideo => 'Видео'; + @override + String get uploadNotificationVideoNote => 'Кружок'; + + @override + String get uploadNotificationVoice => 'Голосовое сообщение'; + @override String get uploadNotificationFile => 'Файл'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 8d841d9..e111099 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -814,6 +814,8 @@ } }, "uploadNotificationVideo": "Видео", + "uploadNotificationVideoNote": "Кружок", + "uploadNotificationVoice": "Голосовое сообщение", "uploadNotificationFile": "Файл", "uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}", "@uploadNotificationMultiple": { diff --git a/pubspec.yaml b/pubspec.yaml index b3e1721..49998d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.17+17 +version: 0.5.18+18 environment: sdk: ^3.10.4 diff --git a/test/composer_forward_preview_test.dart b/test/composer_forward_preview_test.dart index b435429..b671402 100644 --- a/test/composer_forward_preview_test.dart +++ b/test/composer_forward_preview_test.dart @@ -51,6 +51,7 @@ void main() { isMounted: () => true, onRecorded: (File file, int durationMs) async {}, formatElapsed: (milliseconds) => '$milliseconds', + bottomInset: () => 0, ); await tester.pumpWidget( diff --git a/test/lottie_slash_icon_test.dart b/test/lottie_slash_icon_test.dart new file mode 100644 index 0000000..ca46c35 --- /dev/null +++ b/test/lottie_slash_icon_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/lottie_slash_icon.dart'; +import 'package:lottie/lottie.dart'; + +const _asset = 'assets/lottie/ic_flash_on_to_off.json'; + +Map _doc() => + jsonDecode(File(_asset).readAsStringSync()) as Map; + +List> _layers(Map doc) => + (doc['layers'] as List).cast>(); + +List> _maskFrames(Map layer) { + final masks = (layer['masksProperties'] as List).cast>(); + return ((masks.single['pt'] as Map)['k'] as List) + .cast>(); +} + +List> _quad(Map frame) => + ((((frame['s'] as List).first as Map)['v']) as List) + .map((point) => (point as List).cast()) + .toList(); + +Widget _host(bool slashed) => MaterialApp( + home: Scaffold( + body: Center( + child: LottieSlashIcon( + asset: _asset, + slashed: slashed, + color: const Color(0xFFFFFFFF), + ), + ), + ), +); + +void main() { + group('Ассет перечёркивания', () { + test('квадратный, из двух слоёв, каждый со своей маской', () { + final doc = _doc(); + expect(doc['w'], doc['h']); + expect(doc['op'], greaterThan(0)); + + final layers = _layers(doc); + expect(layers.length, 2); + for (final layer in layers) { + expect(_maskFrames(layer).length, 2, reason: 'маска должна ехать'); + final group = (layer['shapes'] as List).first as Map; + final paths = (group['it'] as List).cast>().where( + (item) => item['ty'] == 'sh', + ); + expect(paths, isNotEmpty); + for (final path in paths) { + expect( + (path['ks'] as Map)['a'], + 0, + reason: 'глифы статичны — двигается только маска', + ); + } + } + }); + + test('маски слоёв дополняют друг друга', () { + final layers = _layers(_doc()); + final slashed = _maskFrames(layers.first); + final plain = _maskFrames(layers.last); + + for (var frame = 0; frame < 2; frame++) { + final a = _quad(slashed[frame]); + final b = _quad(plain[frame]); + expect( + a.take(2), + b.take(2), + reason: 'обе маски должны делить одну и ту же диагональ', + ); + expect( + a.skip(2), + isNot(b.skip(2)), + reason: 'маски должны смотреть в разные стороны от диагонали', + ); + } + }); + + test('слои двигаются одинаково', () { + final layers = _layers(_doc()); + expect(layers.first['ks'], layers.last['ks']); + }); + + testWidgets('композиция читается lottie без предупреждений', ( + tester, + ) async { + final composition = await AssetLottie(_asset).load(); + expect(composition.warnings, isEmpty); + expect(composition.layers.length, 2); + }); + }); + + group('LottieSlashIcon', () { + testWidgets('рисует lottie в обоих состояниях', (tester) async { + await tester.pumpWidget(_host(false)); + await tester.pump(); + expect(find.byType(Lottie), findsOneWidget); + + await tester.pumpWidget(_host(true)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(Lottie), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(Lottie), findsOneWidget); + }); + + testWidgets('перечёркивание анимируется в обе стороны', (tester) async { + await tester.pumpWidget(_host(false)); + final controller = tester + .widget(find.byType(LottieBuilder)) + .controller!; + expect(controller.value, 0); + + await tester.pumpWidget(_host(true)); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.value, greaterThan(0)); + expect(controller.value, lessThan(1)); + + await tester.pump(const Duration(milliseconds: 400)); + expect(controller.value, 1); + + await tester.pumpWidget(_host(false)); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.value, lessThan(1)); + + await tester.pump(const Duration(milliseconds: 400)); + expect(controller.value, 0); + }); + }); +} diff --git a/tool/make_morph_icons.py b/tool/make_morph_icons.py index bcf2ad4..69e976b 100644 --- a/tool/make_morph_icons.py +++ b/tool/make_morph_icons.py @@ -1,9 +1,15 @@ -"""Собирает lottie-морфы иконок композера прямо из шрифта Material Symbols. +"""Собирает lottie-морфы иконок прямо из шрифта Material Symbols. Контуры глифов берутся из MaterialSymbolsOutlined.ttf (инстанс по умолчанию — FILL 0, GRAD 0, opsz 24, wght 400, то есть ровно то, что рисует Icon в приложении), разбиваются на равное число безье-сегментов и попарно сопоставляются, чтобы -lottie мог интерполировать один глиф в другой. +lottie мог интерполировать один глиф в другой. Спекам с fill=1 контуры считаются +по FILL=1 — для кнопок, которые рисуют Icon(..., fill: 1). + +SPECS — морфы композера (ComposerMorphIcon), проигрываются вперёд. +SLASH_SPECS — переключатели «обычная/перечёркнутая» (LottieSlashIcon): оба глифа +лежат статикой, а перечёркивание рисуется бегущей по диагонали маской, поэтому +одного ассета хватает на оба направления. python3 tool/make_morph_icons.py @@ -36,6 +42,8 @@ class Font: '>H', self.data[maxp_off + 4:maxp_off + 6])[0] self._read_loca() self._read_cmap() + self._read_fvar() + self._read_gvar() def _read_loca(self): off, _ = self.tables['loca'] @@ -90,9 +98,131 @@ class Font: for c in range(s, e + 1): self.cmap[c] = g + (c - s) - def contours(self, codepoint): + def _read_fvar(self): + off, _ = self.tables['fvar'] + axes_off, _, axis_count, axis_size = struct.unpack( + '>HHHH', self.data[off + 4:off + 12]) + self.axes = [] + for i in range(axis_count): + p = off + axes_off + i * axis_size + self.axes.append(self.data[p:p + 4].decode('latin1')) + + def _read_gvar(self): + off, _ = self.tables['gvar'] + axis_count, shared_count, shared_off, glyph_count, flags, data_off = ( + struct.unpack('>HHIHHI', self.data[off + 4:off + 20])) + base = off + 20 + if flags & 1: + raw = struct.unpack( + '>%dI' % (glyph_count + 1), self.data[base:base + 4 * (glyph_count + 1)]) + offsets = list(raw) + else: + raw = struct.unpack( + '>%dH' % (glyph_count + 1), self.data[base:base + 2 * (glyph_count + 1)]) + offsets = [v * 2 for v in raw] + shared = [] + p = off + shared_off + for i in range(shared_count): + step = 2 * axis_count + shared.append(struct.unpack('>%dh' % axis_count, + self.data[p + i * step:p + (i + 1) * step])) + self.gvar = { + 'axis_count': axis_count, + 'shared': shared, + 'offsets': offsets, + 'data': off + data_off, + } + + def _axis_deltas(self, gid, axis, contours): + """Deltas that move the glyph to the `axis`=1 instance. + + Only tuples peaking on `axis` alone contribute: every other tuple is + multiplied by an axis coordinate that stays at its default zero. + """ + gvar = self.gvar + start = gvar['data'] + gvar['offsets'][gid] + end = gvar['data'] + gvar['offsets'][gid + 1] + if end <= start: + return None + + d = self.data[start:end] + axis_count = gvar['axis_count'] + index = self.axes.index(axis) + n_points = sum(len(c) for c in contours) + 4 + + tuple_count, cursor = struct.unpack('>HH', d[0:4]) + shared_points = None + if tuple_count & 0x8000: + shared_points, cursor = _packed_points(d, cursor) + + total = [(0.0, 0.0)] * n_points + applied = False + p = 4 + for _ in range(tuple_count & 0x0FFF): + var_size, tuple_index = struct.unpack('>HH', d[p:p + 4]) + p += 4 + if tuple_index & 0x8000: + peak = struct.unpack('>%dh' % axis_count, d[p:p + 2 * axis_count]) + p += 2 * axis_count + else: + peak = gvar['shared'][tuple_index & 0x0FFF] + if tuple_index & 0x4000: + p += 4 * axis_count + block, cursor = cursor, cursor + var_size + + if peak[index] <= 0 or any( + v for i, v in enumerate(peak) if i != index): + continue + + q = block + points = shared_points + if tuple_index & 0x2000: + points, q = _packed_points(d, q) + size = n_points if points is None else len(points) + xs, q = _packed_deltas(d, q, size) + ys, _ = _packed_deltas(d, q, size) + + scale = 16384.0 / peak[index] + sparse = [None] * n_points + for k, point in enumerate(range(size) if points is None else points): + if point < n_points: + sparse[point] = (xs[k] * scale, ys[k] * scale) + _infer_deltas(contours, sparse) + total = [(a[0] + b[0], a[1] + b[1]) for a, b in zip(total, sparse)] + applied = True + + return total if applied else None + + def contours(self, codepoint, fill=0.0): gid = self.cmap[codepoint] - return self._glyph_contours(gid) + contours = self._glyph_contours(gid) + if fill <= 0: + return contours + if self._is_composite(gid): + raise SystemExit('fill=1 не поддержан для составного глифа %04X' + % codepoint) + + deltas = self._axis_deltas(gid, 'FILL', contours) + if deltas is None: + return contours + + out = [] + index = 0 + for contour in contours: + shifted = [] + for x, y, on in contour: + dx, dy = deltas[index] + index += 1 + shifted.append((x + dx * fill, y + dy * fill, on)) + out.append(shifted) + return out + + def _is_composite(self, gid): + goff, _ = self.tables['glyf'] + start, end = self.loca[gid], self.loca[gid + 1] + if start == end: + return False + return struct.unpack('>h', self.data[goff + start:goff + start + 2])[0] < 0 def _glyph_contours(self, gid, depth=0): goff, _ = self.tables['glyf'] @@ -197,6 +327,94 @@ def _f2dot14(d, p): return struct.unpack('>h', d[p:p + 2])[0] / 16384.0 +def _packed_points(d, p): + """gvar packed point numbers; None means «все точки глифа».""" + count = d[p] + p += 1 + if count == 0: + return None, p + if count & 0x80: + count = ((count & 0x7F) << 8) | d[p] + p += 1 + points, value = [], 0 + while len(points) < count: + control = d[p] + p += 1 + run = (control & 0x7F) + 1 + for _ in range(run): + if control & 0x80: + value += struct.unpack('>H', d[p:p + 2])[0] + p += 2 + else: + value += d[p] + p += 1 + points.append(value) + return points[:count], p + + +def _packed_deltas(d, p, count): + out = [] + while len(out) < count: + control = d[p] + p += 1 + run = (control & 0x3F) + 1 + if control & 0x80: + out.extend([0] * run) + elif control & 0x40: + for _ in range(run): + out.append(struct.unpack('>h', d[p:p + 2])[0]) + p += 2 + else: + for _ in range(run): + out.append(struct.unpack('>b', d[p:p + 1])[0]) + p += 1 + return out[:count], p + + +def _interpolate(v, v1, d1, v2, d2): + if v1 > v2: + v1, d1, v2, d2 = v2, d2, v1, d1 + if v1 == v2: + return d1 if d1 == d2 else 0.0 + if v <= v1: + return d1 + if v >= v2: + return d2 + return d1 + (d2 - d1) * (v - v1) / (v2 - v1) + + +def _infer_deltas(contours, deltas): + """IUP: точки, которых нет в тапле, тянутся за соседними опорными.""" + first = 0 + for contour in contours: + last = first + len(contour) - 1 + refs = [i for i in range(first, last + 1) if deltas[i] is not None] + if not refs: + for i in range(first, last + 1): + deltas[i] = (0.0, 0.0) + elif len(refs) == 1: + for i in range(first, last + 1): + deltas[i] = deltas[refs[0]] + else: + for k, a in enumerate(refs): + b = refs[(k + 1) % len(refs)] + i = first if a == last else a + 1 + while i != b: + deltas[i] = ( + _interpolate(contour[i - first][0], + contour[a - first][0], deltas[a][0], + contour[b - first][0], deltas[b][0]), + _interpolate(contour[i - first][1], + contour[a - first][1], deltas[a][1], + contour[b - first][1], deltas[b][1]), + ) + i = first if i == last else i + 1 + first = last + 1 + for i, value in enumerate(deltas): + if value is None: + deltas[i] = (0.0, 0.0) + + def to_cubic(contour): """TrueType quadratic contour -> list of cubic segments [(p0,c1,c2,p1), ...].""" pts = [] @@ -367,9 +585,9 @@ def _area(path): return area / 2 -def glyph_paths(codepoint, count): +def glyph_paths(codepoint, count, fill=0.0): out = [] - for contour in _font.contours(codepoint): + for contour in _font.contours(codepoint, fill): path = _exact_path(contour, count) if not path: continue @@ -414,10 +632,10 @@ def outer_sign(shapes): return 1.0 if biggest[0] > 0 else -1.0 -def pair_glyphs(from_cp, to_cp, count): +def pair_glyphs(from_cp, to_cp, count, fill=0.0): """[(path_from, path_to), ...] with matching vertex counts and winding.""" - src = glyph_paths(from_cp, count) - dst = glyph_paths(to_cp, count) + src = glyph_paths(from_cp, count, fill) + dst = glyph_paths(to_cp, count, fill) src_sign = outer_sign(src) dst_sign = outer_sign(dst) @@ -441,6 +659,8 @@ def pair_glyphs(from_cp, to_cp, count): MIC = 0xE31D CAM = 0xE04B SEND = 0xE163 +FLASH_ON = 0xE3E7 +FLASH_OFF = 0xE3E6 POINTS = 56 FPS = 60 @@ -504,6 +724,54 @@ def shape_item(index, path_from, path_to): } +def _group(items, name): + items = list(items) + items.append({ + 'ty': 'fl', + 'c': {'a': 0, 'k': [1, 1, 1, 1], 'ix': 4}, + 'o': {'a': 0, 'k': 100, 'ix': 5}, + 'r': 1, + 'bm': 0, + 'nm': 'Fill', + 'mn': 'ADBE Vector Graphic - Fill', + 'hd': False, + }) + items.append({ + 'ty': 'tr', + 'p': {'a': 0, 'k': [0, 0], 'ix': 2}, + 'a': {'a': 0, 'k': [0, 0], 'ix': 1}, + 's': {'a': 0, 'k': [100, 100], 'ix': 3}, + 'r': {'a': 0, 'k': 0, 'ix': 6}, + 'o': {'a': 0, 'k': 100, 'ix': 7}, + 'sk': {'a': 0, 'k': 0, 'ix': 4}, + 'sa': {'a': 0, 'k': 0, 'ix': 5}, + 'nm': 'Transform', + }) + return { + 'ty': 'gr', + 'it': items, + 'nm': name, + 'np': len(items), + 'cix': 2, + 'bm': 0, + 'ix': 1, + 'mn': 'ADBE Vector Group', + 'hd': False, + } + + +def static_shape(index, path): + return { + 'ind': index, + 'ty': 'sh', + 'ix': index + 1, + 'ks': {'a': 0, 'k': path_value(path), 'ix': 2}, + 'nm': 'Path %d' % (index + 1), + 'mn': 'ADBE Vector Shape - Group', + 'hd': False, + } + + def keyframes(stops, vector): out = [] for i, (frame, value) in enumerate(stops): @@ -539,30 +807,10 @@ def transform(rotation=None, scale=None, offset_x=None): return ks -def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None): - pairs = pair_glyphs(from_cp, to_cp, POINTS) +def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None, + fill=0.0): + pairs = pair_glyphs(from_cp, to_cp, POINTS, fill) items = [shape_item(i, a, b) for i, (a, b) in enumerate(pairs)] - items.append({ - 'ty': 'fl', - 'c': {'a': 0, 'k': [1, 1, 1, 1], 'ix': 4}, - 'o': {'a': 0, 'k': 100, 'ix': 5}, - 'r': 1, - 'bm': 0, - 'nm': 'Fill', - 'mn': 'ADBE Vector Graphic - Fill', - 'hd': False, - }) - items.append({ - 'ty': 'tr', - 'p': {'a': 0, 'k': [0, 0], 'ix': 2}, - 'a': {'a': 0, 'k': [0, 0], 'ix': 1}, - 's': {'a': 0, 'k': [100, 100], 'ix': 3}, - 'r': {'a': 0, 'k': 0, 'ix': 6}, - 'o': {'a': 0, 'k': 100, 'ix': 7}, - 'sk': {'a': 0, 'k': 0, 'ix': 4}, - 'sa': {'a': 0, 'k': 0, 'ix': 5}, - 'nm': 'Transform', - }) return { 'v': '5.12.1', @@ -582,17 +830,7 @@ def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None): 'sr': 1, 'ks': transform(rotation, scale, offset_x), 'ao': 0, - 'shapes': [{ - 'ty': 'gr', - 'it': items, - 'nm': 'Group 1', - 'np': len(items), - 'cix': 2, - 'bm': 0, - 'ix': 1, - 'mn': 'ADBE Vector Group', - 'hd': False, - }], + 'shapes': [_group(items, 'Group 1')], 'ip': 0, 'op': DUR, 'st': 0, @@ -602,6 +840,101 @@ def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None): } +def _wipe_quad(cut, ahead): + """Половина плоскости по обе стороны от диагонали x + y = cut.""" + reach = CANVAS * 1.5 + mid = (cut / 2, cut / 2) + along = (reach / math.sqrt(2), -reach / math.sqrt(2)) + depth = reach * math.sqrt(2) * (1 if ahead else -1) + corners = [ + (mid[0] + along[0], mid[1] + along[1]), + (mid[0] - along[0], mid[1] - along[1]), + (mid[0] - along[0] + depth, mid[1] - along[1] + depth), + (mid[0] + along[0] + depth, mid[1] + along[1] + depth), + ] + return { + 'i': [[0, 0]] * 4, + 'o': [[0, 0]] * 4, + 'v': [[r2(x), r2(y)] for x, y in corners], + 'c': True, + } + + +def wipe_mask(span, ahead): + start, end = span + return [{ + 'inv': False, + 'mode': 'a', + 'pt': { + 'a': 1, + 'k': [ + {'i': EASE_IN, 'o': EASE_OUT, 't': 0, + 's': [_wipe_quad(start, ahead)]}, + {'t': DUR, 's': [_wipe_quad(end, ahead)]}, + ], + 'ix': 1, + }, + 'o': {'a': 0, 'k': 100, 'ix': 3}, + 'x': {'a': 0, 'k': 0, 'ix': 4}, + 'nm': 'Wipe', + }] + + +def _diagonal_span(*glyphs): + values = [v[0][0] + v[0][1] for paths in glyphs for _, path in paths + for v in path] + margin = CANVAS * 0.04 + return min(values) - margin, max(values) + margin + + +def build_slash(name, plain_cp, slashed_cp, fill=0.0, scale=None): + """Кадр 0 — обычный глиф, последний — перечёркнутый. + + Оба глифа лежат статичными слоями, а по диагонали (перпендикулярно самой + перечёркивающей линии) едет маска: перечёркнутый слой открывается ровно там, + где обычный закрывается, поэтому линия выглядит нарисованной поверх иконки. + """ + plain = glyph_paths(plain_cp, POINTS, fill) + slashed = glyph_paths(slashed_cp, POINTS, fill) + span = _diagonal_span(plain, slashed) + + def layer(index, paths, ahead, title): + items = [static_shape(i, path) for i, (_, path) in enumerate(paths)] + return { + 'ddd': 0, + 'ind': index, + 'ty': 4, + 'nm': title, + 'sr': 1, + 'ks': transform(scale=scale), + 'ao': 0, + 'hasMask': True, + 'masksProperties': wipe_mask(span, ahead), + 'shapes': [_group(items, title)], + 'ip': 0, + 'op': DUR, + 'st': 0, + 'bm': 0, + } + + return { + 'v': '5.12.1', + 'fr': FPS, + 'ip': 0, + 'op': DUR, + 'w': int(CANVAS), + 'h': int(CANVAS), + 'nm': name, + 'ddd': 0, + 'assets': [], + 'layers': [ + layer(1, slashed, False, 'slashed'), + layer(2, plain, True, 'plain'), + ], + 'markers': [], + } + + SPECS = [ dict( name='ic_mic_to_videocam', @@ -645,15 +978,30 @@ SPECS = [ ] +SLASH_SPECS = [ + dict( + name='ic_flash_on_to_off', + plain_cp=FLASH_ON, slashed_cp=FLASH_OFF, + fill=1.0, + scale=[(0, 100), (11, 92), (DUR, 100)], + ), +] + + +def _write(name, data): + path = os.path.join(OUT_DIR, name + '.json') + with open(path, 'w') as fh: + json.dump(data, fh, separators=(',', ':')) + print(f'{name:24s} {os.path.getsize(path) // 1024:3d} KB ' + f'layers={len(data["layers"])}') + + def main(): os.makedirs(OUT_DIR, exist_ok=True) for spec in SPECS: - data = build(**spec) - path = os.path.join(OUT_DIR, spec['name'] + '.json') - with open(path, 'w') as fh: - json.dump(data, fh, separators=(',', ':')) - print(f"{spec['name']:24s} {os.path.getsize(path) // 1024:3d} KB " - f"paths={len(data['layers'][0]['shapes'][0]['it']) - 2}") + _write(spec['name'], build(**spec)) + for spec in SLASH_SPECS: + _write(spec['name'], build_slash(**spec)) if __name__ == '__main__':