From 78db3f4ca0a32f060559f2773360effe91bdca4a Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Thu, 19 Dec 2024 19:28:53 +0100 Subject: [PATCH] feat!: Add Chart zoom and pan (#1793) * feat: Add Chart zoom and pan (WIP) * feat: Allow zooming and panning freely for line chart * fix: Scaling factor * fix: Scaling in, translating to maxX/maxY and scaling out now keeps the translated/scaled values inside min/max bounds * ref: Use Matrix3 for transformations * feat: Get size from LineChart renderer * ref: Improve code style * feat: Improve scaling via mouse wheel * ref: Use LayoutBuilder to get size for chart leaf * feat: Allow scaling line chart * feat: Allow panning via trackpad * feat: Add panning and zooming for touch gestures * feat: Add double tap gesture recognizer to render base chart * test: Remove unused tests * revert: Move vector_math dependency back to dev dependencies * ref: Move scaling/panning logic to new `InteractiveLineChart` widget * fix: Update bounds when widget is updated * fix: Handle NaN bound values * ref: Add BaseInteractiveChart and BaseInteractiveChartState * ref: Use `BaseInteractiveChartState` in InteractiveLineChart * feat: Add `InteractiveScatterChart` * feat: Add FlTapEvent * fix: Use FlTapEvent for scatter chart sample * fix: TypeError when using touchCallbacks of InteractiveCharts * feat: Add interactive bar chart * revert: last 26 commits * feat: Allow zooming and panning line chart (axis titles WIP) * fix: Import of custom interactive viewer * feat: Add scaling/panning to titles * feat: Add parameters to control LineChart scale behavior * feat: Add scaling to Line Chart Sample 1 * fix: Disable pan gesture recognizer when scaling is enabled Otherwise pan gesture recognizer will consume touch events and InteractiveViewer's GestureDetector won't be notified. * ref: Move interactive viewer inside axis chart scaffold to make it more reusable * feat: Improve edge cases in line chart painter * fix: Hide titles when they overflow chart bounds * feat: Allow scaling via trackpad scroll * feat!: Allow scaling bar charts BREAKING CHANGE: `BarChart` is not `const` anymore, because we are asserting that start, center and end bar alignment can only be used with horizontal or none scaling. * feat: Allow scaling and panning scatter chart * ref: Remove scale axis parameter from line chart again * ref: Set ScaleAxis.none as default on bar chart * docs: Add documentation to public custom interactive viewer methods * docs: Add docstring to update chart rect method * test: Add tests for `BarChart` * test: Add test for maxScale default value * test: Minor refactorings on bar chart test * test: Improve formatting of bar chart tests * test: Add line chart tests * feat: Add static list of ScaleAxis that allow scaling to ScaleAxis enum * test: Add tests for scatter chart * docs: Add documentation about scaling to `AxisChartScaffoldWidget` * ref: Improve readability of `viewSize` getter in side titles widget * ref: Improve readability of `axisOffset` in side titles widget * fix: Add 1 pixel to `_getPositionsWithinChartRange` to avoid clipping titles at the edge of the chart * ref: Make getPositionsWithinChartRange private * fix: Draw all touch tooltips when bar chart is not scaled * fix: Draw all touch tooltips when line chart is not scaled * fix: Use dot height 0 when lineBarsData does not have lineData for showingTooltipSpots * ref: Add docstrings and improve readability of AxisChartScaffoldWidget * test: Add tests for AxisChartScaffoldWidget scaling and panning * test: Improve line chart test formatting * test: Add tests for chart renderers * fix: Tests for Flutter v3.27.0 * ref: Create and reuse clipPaint property on painters * ref: Use clipPaint in axis chart painter as well * fix: Inflate chart rect for titles to avoid clipping * feat: Allow passing transformation controller to control chart transformations * fix: Keep transformation controller if both old and new widget controller is null * feat: Add minScale and transformation controller param to bar, line and scatter chart * test: Check that transformation controller is passed from axis chart scaffold widget to interactive viewer * test: Add tests to check if chart rect is set to 0 when scaling goes to 1.0 * ref: Ignore custom interactive viewer in coverage * ref: Use reuse Paint instance for clipping in bar chart painter * ref: Use reuse Paint instance for clipping in line chart painter * ref: Use reuse Paint instance for clipping in scatter chart painter * test: Remove done todos * test: Add tests for bar chart painter * test: Add test for restoring canvas before and clipping again after drawing extra lines * test: Add line chart painter test for minY == maxY * Add Bitcoin price history as a line chart sample * Add the zoom/scale feature in the line_chart_sample12 * Add the transformation controller buttons in the line_chart_sample12 * Add the url in the btc_last_year_price.json * fix: Do not dispose external transformation controller * ref: Rename bounding box to chartVirtualRect * ref: Rename ScaleAxis to FlScaleAxis * ref: Rename ScaleAxis to FlScaleAxis * test: Add test for skipping drawing tooltip when outside of canvas * test: Add tests for drawTouchTooltip to take dot height into account * test: Add test for finding largest dot height * fix: Check if widget is mounted before calling update virtual chart rect post frame callback * fix: Make LineChartSample12 responsive to prevent overflow error * ref: Rename variables to make purpose clearer * ref: Use variable to improve null-check * ref: Remove scaling from line chart sample1 * ref: Wrap transformation configuration in an object `FlTransformationConfig` * chore: Update changelog and add migration guide * docs: Add documentation for chart transformations --------- Co-authored-by: imaNNeoFighT --- CHANGELOG.md | 2 + example/assets/data/btc_last_year_price.json | 4 + .../lib/presentation/presentation_utils.dart | 16 + .../samples/bar/bar_chart_sample1.dart | 2 +- .../presentation/samples/chart_samples.dart | 2 + .../samples/line/line_chart_sample12.dart | 381 +++ example/macos/Podfile.lock | 2 +- example/macos/Runner/AppDelegate.swift | 4 + example/pubspec.yaml | 2 + lib/fl_chart.dart | 2 + lib/src/chart/bar_chart/bar_chart.dart | 31 +- .../chart/bar_chart/bar_chart_painter.dart | 59 +- .../chart/bar_chart/bar_chart_renderer.dart | 34 +- .../base/axis_chart/axis_chart_data.dart | 4 +- .../base/axis_chart/axis_chart_painter.dart | 59 +- .../axis_chart_scaffold_widget.dart | 245 +- lib/src/chart/base/axis_chart/scale_axis.dart | 20 + .../side_titles/side_titles_widget.dart | 111 +- .../axis_chart/transformation_config.dart | 38 + .../base/base_chart/base_chart_painter.dart | 30 +- .../base/base_chart/render_base_chart.dart | 45 +- .../chart/base/custom_interactive_viewer.dart | 1179 ++++++++++ lib/src/chart/line_chart/line_chart.dart | 19 +- .../chart/line_chart/line_chart_painter.dart | 90 +- .../chart/line_chart/line_chart_renderer.dart | 26 +- .../chart/pie_chart/pie_chart_painter.dart | 5 +- .../chart/pie_chart/pie_chart_renderer.dart | 2 +- .../radar_chart/radar_chart_renderer.dart | 2 +- .../chart/scatter_chart/scatter_chart.dart | 9 +- .../scatter_chart/scatter_chart_painter.dart | 38 +- .../scatter_chart/scatter_chart_renderer.dart | 28 +- pubspec.yaml | 2 +- .../documentations/handle_transformations.md | 119 + repo_files/documentations/index.md | 4 +- .../0.70.0/MIGRATION_00_70_00.md | 7 + .../bar_chart/bar_chart_painter_test.dart | 607 +++++ .../bar_chart/bar_chart_renderer_test.dart | 39 + .../bar_chart_renderer_test.mocks.dart | 4 +- test/chart/bar_chart/bar_chart_test.dart | 933 ++++++++ .../axis_chart_scaffold_widget_test.dart | 1175 +++++++++- .../transformation_config_test.dart | 31 + test/chart/base/render_base_chart_test.dart | 188 ++ .../base/render_base_chart_test.mocks.dart | 2060 +++++++++++++++++ .../line_chart/line_chart_painter_test.dart | 419 ++++ .../line_chart/line_chart_renderer_test.dart | 39 + test/chart/line_chart/line_chart_test.dart | 826 +++++++ .../scatter_chart_renderer_test.dart | 39 + .../scatter_chart/scatter_chart_test.dart | 827 +++++++ 48 files changed, 9674 insertions(+), 136 deletions(-) create mode 100644 example/assets/data/btc_last_year_price.json create mode 100644 example/lib/presentation/presentation_utils.dart create mode 100644 example/lib/presentation/samples/line/line_chart_sample12.dart create mode 100644 lib/src/chart/base/axis_chart/scale_axis.dart create mode 100644 lib/src/chart/base/axis_chart/transformation_config.dart create mode 100644 lib/src/chart/base/custom_interactive_viewer.dart create mode 100644 repo_files/documentations/handle_transformations.md create mode 100644 repo_files/documentations/migration_guides/0.70.0/MIGRATION_00_70_00.md create mode 100644 test/chart/bar_chart/bar_chart_test.dart create mode 100644 test/chart/base/axis_chart/transformation_config_test.dart create mode 100644 test/chart/base/render_base_chart_test.dart create mode 100644 test/chart/base/render_base_chart_test.mocks.dart create mode 100644 test/chart/line_chart/line_chart_test.dart create mode 100644 test/chart/scatter_chart/scatter_chart_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 756997224..b117be45d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## newVersion +* **IMPROVEMENT** (by @Peetee06) Added functionality to control the transformation of axis-based charts using `FlTransformationConfig` class. You can now enable scaling and panning for `LineChart`, `BarChart` and `ScatterChart` using this class. * **IMPROVEMENT** (by @Peetee06) Added some new unit tests in `bar_chart_data_extensions_test.dart`, `gradient_extension_test.dart` and fixed a typo in `bar_chart_data.dart` * **BREAKING** (by @Peetee06) Fixed the equatable functionality in our BarChart. We hope it will not affect anything in our chart, but because the behaviour is changed, we marked it as a breaking change. (read more [here](https://github.com/imaNNeo/fl_chart/pull/1789#discussion_r1858371718)) +* **BREAKING** (by @Peetee06) `BarChart` is not const anymore due to adding an assert to check if transformations are allowed depending on the `BarChartData.alignment` property. * **IMPROVEMENT** (by AliAkberAakash) Minor typo fix in our line chart documentation, #1795 ## 0.69.2 diff --git a/example/assets/data/btc_last_year_price.json b/example/assets/data/btc_last_year_price.json new file mode 100644 index 000000000..3c42a2911 --- /dev/null +++ b/example/assets/data/btc_last_year_price.json @@ -0,0 +1,4 @@ +{ + "url": "https://api.coingecko.com/api/v3/coins/bitcoin/market_chart?vs_currency=eur&days=365", + "prices":[[1702944000000,39078.47891755909],[1703030400000,38477.47402170458],[1703116800000,39864.87762044923],[1703203200000,39838.679877640185],[1703289600000,39908.58005878162],[1703376000000,39678.979321595696],[1703462400000,39062.32681431535],[1703548800000,39597.85927945271],[1703635200000,38502.06842500796],[1703721600000,39086.65066359461],[1703808000000,38491.64747698571],[1703894400000,38057.70863986569],[1703980800000,38189.68296890573],[1704067200000,38240.20908960317],[1704153600000,40022.56708386281],[1704240000000,41121.44236936086],[1704326400000,39193.97344095043],[1704412800000,40376.019880448854],[1704499200000,40269.190802082194],[1704585600000,40125.3447966974],[1704672000000,40118.34311925359],[1704758400000,42857.43103594771],[1704844800000,42178.411061608065],[1704931200000,42492.38965139214],[1705017600000,42171.814324539526],[1705104000000,39122.52373887039],[1705190400000,39081.06524302686],[1705276800000,38190.210045708205],[1705363200000,38908.89747537756],[1705449600000,39668.76253090919],[1705536000000,39242.29028152058],[1705622400000,37938.77972050555],[1705708800000,38150.14288219012],[1705795200000,38173.221525259156],[1705881600000,38141.44573510522],[1705968000000,36313.41944934261],[1706054400000,36689.35973905385],[1706140800000,36871.20615880076],[1706227200000,36811.15855147119],[1706313600000,38535.125270419456],[1706400000000,38771.39629759249],[1706486400000,38759.18018541519],[1706572800000,39930.07569963493],[1706659200000,39555.50605607913],[1706745600000,39390.4556124346],[1706832000000,39601.98542555142],[1706918400000,39978.18836096943],[1707004800000,39798.71036813004],[1707091200000,39517.8500683694],[1707177600000,39701.74708708336],[1707264000000,40053.71777001113],[1707350400000,41066.29173159883],[1707436800000,42076.0465628706],[1707523200000,43713.572063470674],[1707609600000,44293.80084671968],[1707696000000,44634.239554822176],[1707782400000,46463.69349243575],[1707868800000,46442.363953357555],[1707955200000,48260.30672871491],[1708041600000,48219.73170515296],[1708128000000,48405.75606037122],[1708214400000,47958.58062660045],[1708300800000,48348.85208766836],[1708387200000,48043.59788234124],[1708473600000,48367.898535816996],[1708560000000,47916.29005429376],[1708646400000,47410.49380112791],[1708732800000,46933.94401263771],[1708819200000,47591.24123045009],[1708905600000,47833.38664994704],[1708992000000,50208.13598783667],[1709078400000,52573.782674551876],[1709164800000,57717.736388579004],[1709251200000,56734.564615792624],[1709337600000,57560.48389994141],[1709424000000,57229.6765848964],[1709510400000,58157.09485482637],[1709596800000,62819.675032831015],[1709683200000,59216.16978710529],[1709769600000,60688.021041080945],[1709856000000,61130.91471064688],[1709942400000,62425.38416118097],[1710028800000,62601.51789297492],[1710115200000,63133.50171410394],[1710201600000,65996.21012712058],[1710288000000,65396.96618741416],[1710374400000,66750.17623162274],[1710460800000,65624.65332089699],[1710547200000,63816.32105192292],[1710633600000,59954.666681584524],[1710720000000,62871.803645507665],[1710806400000,62274.42066839416],[1710892800000,57178.75390064521],[1710979200000,62011.64214760789],[1711065600000,60327.113345413214],[1711152000000,58488.654960729786],[1711238400000,59204.01008039168],[1711324800000,62277.19869537941],[1711411200000,64530.77647863455],[1711497600000,64695.8250958296],[1711584000000,64211.47397847694],[1711670400000,65526.713768406174],[1711756800000,64739.24207573283],[1711843200000,64538.55260842681],[1711929600000,66012.15293045473],[1712016000000,64987.58440425927],[1712102400000,60773.862930874704],[1712188800000,61012.09522243375],[1712275200000,63241.87913574653],[1712361600000,62689.87435787321],[1712448000000,63632.39935297565],[1712534400000,64086.823460256426],[1712620800000,65931.06490439967],[1712707200000,63687.50350010323],[1712793600000,65668.16571700791],[1712880000000,65354.13336928861],[1712966400000,63099.287555906376],[1713052800000,60427.82751512378],[1713139200000,61736.684829580605],[1713225600000,59686.89912995451],[1713312000000,59995.209903171795],[1713398400000,57478.36312839251],[1713484800000,59623.56249209243],[1713571200000,60012.93722485975],[1713657600000,60862.26888013952],[1713744000000,60924.51880957888],[1713830400000,62738.59113235908],[1713916800000,62051.58606093932],[1714003200000,60081.61563815237],[1714089600000,60110.989276790104],[1714176000000,59659.006735859955],[1714262400000,59381.3230914825],[1714348800000,58876.98016307695],[1714435200000,59532.80475223963],[1714521600000,56955.30306317563],[1714608000000,54392.86086133425],[1714694400000,55109.42816389191],[1714780800000,58355.75377420205],[1714867200000,59266.86265965134],[1714953600000,59488.78971098448],[1715040000000,58653.24832859499],[1715126400000,57997.6431882983],[1715212800000,56957.965704980525],[1715299200000,58566.91642282563],[1715385600000,56489.04308941497],[1715472000000,56385.835846451875],[1715558400000,57100.99619053609],[1715644800000,58257.94727352527],[1715731200000,56927.17129981614],[1715817600000,60811.29527582236],[1715904000000,60054.301551755365],[1715990400000,61631.8782491127],[1716076800000,61502.73339909207],[1716163200000,60924.86820709035],[1716249600000,65770.16026770095],[1716336000000,64653.893276922405],[1716422400000,63912.083874721946],[1716508800000,62795.077789946045],[1716595200000,63150.07451451353],[1716681600000,63850.68338638202],[1716768000000,63150.48139241663],[1716854400000,63871.133659487794],[1716940800000,62950.705715262724],[1717027200000,62561.005165216935],[1717113600000,63123.741723088686],[1717200000000,62145.91785386879],[1717286400000,62362.92899401087],[1717372800000,62419.71597472404],[1717459200000,63083.78769270654],[1717545600000,64881.83386232028],[1717632000000,65462.71014376164],[1717718400000,64964.377920006424],[1717804800000,64146.75781805342],[1717891200000,64137.2658453789],[1717977600000,64623.80690329454],[1718064000000,64559.64845115045],[1718150400000,62689.904415286815],[1718236800000,63085.64417953837],[1718323200000,62107.46654002352],[1718409600000,61562.52612996296],[1718496000000,61759.23292317989],[1718582400000,62231.3694306833],[1718668800000,61877.28055510076],[1718755200000,60621.91572601943],[1718841600000,60398.36957706064],[1718928000000,60572.188509925974],[1719014400000,59924.844689378995],[1719100800000,60079.40613872267],[1719187200000,59147.18345045349],[1719273600000,56243.927094112085],[1719360000000,57677.50624706287],[1719446400000,56873.38456798354],[1719532800000,57491.13821425305],[1719619200000,56265.05472146868],[1719705600000,56775.63073475873],[1719792000000,58430.814422068135],[1719878400000,58509.4401642161],[1719964800000,57718.07416888454],[1720051200000,55833.80621748973],[1720137600000,52904.90433375453],[1720224000000,52307.68520432331],[1720310400000,53715.700026296974],[1720396800000,51613.90871758563],[1720483200000,52316.60116848915],[1720569600000,53621.436327802985],[1720656000000,53272.288732207024],[1720742400000,52781.06838693916],[1720828800000,53014.0364517072],[1720915200000,54166.67728993813],[1721001600000,55984.24278202276],[1721088000000,59500.95530894624],[1721174400000,59763.54497883689],[1721260800000,58625.721758164145],[1721347200000,58665.955279138856],[1721433600000,61224.60704078251],[1721520000000,61705.0466917566],[1721606400000,62467.87253811925],[1721692800000,62073.819234324954],[1721779200000,60765.80580937294],[1721865600000,60341.49772175119],[1721952000000,60594.574722199846],[1722038400000,62469.76061302131],[1722124800000,62599.81463153654],[1722211200000,62873.08389964636],[1722297600000,61707.72841612238],[1722384000000,61216.26793898385],[1722470400000,59750.92079501405],[1722556800000,60586.10315866606],[1722643200000,56231.21313117775],[1722729600000,55639.42359487421],[1722816000000,53169.53306907562],[1722902400000,49271.40149467704],[1722988800000,51222.280928669235],[1723075200000,50411.00101226333],[1723161600000,56671.842504303975],[1723248000000,55761.51548402803],[1723334400000,55729.910819518554],[1723420800000,53884.90745860033],[1723507200000,54270.12342034756],[1723593600000,55114.14602647766],[1723680000000,53343.88139134332],[1723766400000,52521.96236743676],[1723852800000,53376.171320376874],[1723939200000,53873.71546267861],[1724025600000,53006.74653077159],[1724112000000,53742.26074312489],[1724198400000,53072.96487740486],[1724284800000,54811.25973940124],[1724371200000,54317.08310206506],[1724457600000,57173.9518647258],[1724544000000,57319.03380665118],[1724630400000,57448.04865756021],[1724716800000,56355.54477751003],[1724803200000,53250.67556605576],[1724889600000,53049.55806257478],[1724976000000,53564.80049060293],[1725062400000,53481.26904169355],[1725148800000,53307.011182245114],[1725235200000,51929.8407759559],[1725321600000,53412.30560377036],[1725408000000,52046.1502790778],[1725494400000,52329.681939274866],[1725580800000,50518.54680242768],[1725667200000,48622.690205035346],[1725753600000,48827.90111822669],[1725840000000,49416.66965979366],[1725926400000,51694.7737107547],[1726012800000,52293.63869759159],[1726099200000,52115.49563129617],[1726185600000,52451.75030832475],[1726272000000,54674.77446852742],[1726358400000,54142.357125255534],[1726444800000,53407.547394791305],[1726531200000,52308.16606889451],[1726617600000,54228.992673647364],[1726704000000,55288.44506329167],[1726790400000,56427.20338319878],[1726876800000,56488.082242429715],[1726963200000,56759.67683409224],[1727049600000,56960.916902308025],[1727136000000,56989.44770900782],[1727222400000,57493.81502099898],[1727308800000,56737.43484845022],[1727395200000,58275.038981340615],[1727481600000,58891.82868753552],[1727568000000,59019.92695395789],[1727654400000,58791.91905839133],[1727740800000,56785.567724519715],[1727827200000,55018.60488254399],[1727913600000,54905.83731260847],[1728000000000,55029.88993283354],[1728086400000,56557.5221526609],[1728172800000,56539.73406535857],[1728259200000,57252.32733417741],[1728345600000,56754.33895468004],[1728432000000,56657.33655044081],[1728518400000,55382.099090773205],[1728604800000,55055.71620633765],[1728691200000,57015.18140309107],[1728777600000,57760.33532582211],[1728864000000,57417.523313436555],[1728950400000,60551.20074941091],[1729036800000,61522.6779321783],[1729123200000,62295.13515961688],[1729209600000,62168.14661024002],[1729296000000,62971.25326535072],[1729382400000,62900.664368658116],[1729468800000,63461.0436312693],[1729555200000,62314.63337960675],[1729641600000,62379.86708703343],[1729728000000,61857.31577287963],[1729814400000,62996.90492256556],[1729900800000,61649.93632490859],[1729987200000,62050.376516974386],[1730073600000,62938.34448352703],[1730160000000,64580.714702132216],[1730246400000,67268.45323344307],[1730332800000,66618.65645361826],[1730419200000,64550.8369757725],[1730505600000,63941.79763295739],[1730592000000,63726.32978776158],[1730678400000,63270.25948561604],[1730764800000,62346.71648632486],[1730851200000,63437.06088138662],[1730937600000,70456.7357599368],[1731024000000,70367.30136816247],[1731110400000,71385.26799176612],[1731196800000,71493.3957234108],[1731283200000,75100.39122172692],[1731369600000,83136.67035524877],[1731456000000,83116.39043645399],[1731542400000,85646.80574476697],[1731628800000,83012.8378117053],[1731715200000,86243.41823875198],[1731801600000,85927.71877600063],[1731888000000,85275.3689714038],[1731974400000,85439.42645883636],[1732060800000,86966.37073765934],[1732147200000,89326.4051029836],[1732233600000,94069.11559788969],[1732320000000,94955.55602347082],[1732406400000,93760.27068972368],[1732492800000,93546.11482101932],[1732579200000,88960.11249694412],[1732665600000,87635.2127980843],[1732752000000,90869.12691365478],[1732838400000,90578.80838013266],[1732924800000,92112.0297646138],[1733011200000,91239.47122285848],[1733097600000,92304.33867392405],[1733184000000,91254.9956460552],[1733270400000,91374.28893844674],[1733356800000,94065.64524925785],[1733443200000,91830.04825245812],[1733529600000,94532.37474294563],[1733616000000,94396.5048454845],[1733702400000,95806.82736572677],[1733788800000,92230.50083900787],[1733875200000,91771.99667795865],[1733961600000,96256.94286515891],[1734048000000,95478.4718086457],[1734134400000,96474.24824906969],[1734220800000,96521.66753667717],[1734307200000,99631.30349379999],[1734393600000,100856.74619693069],[1734396005000,100857.82408798973]],"market_caps":[[1702944000000,764045559429.9309],[1703030400000,753661901693.3868],[1703116800000,780530401149.5747],[1703203200000,779398921861.9735],[1703289600000,781618872485.1848],[1703376000000,777082690166.5908],[1703462400000,767022708939.5092],[1703548800000,775395628458.022],[1703635200000,753811358997.4457],[1703721600000,764431847914.952],[1703808000000,755499387426.541],[1703894400000,744365987728.8765],[1703980800000,747690300646.6624],[1704067200000,749793913990.623],[1704153600000,782654769238.5906],[1704240000000,802781005990.2638],[1704326400000,767255728861.6259],[1704412800000,791548580238.1727],[1704499200000,788420193391.969],[1704585600000,786156615466.8936],[1704672000000,783483447081.5107],[1704758400000,839801871053.8757],[1704844800000,825360034647.3444],[1704931200000,834081331690.4401],[1705017600000,828583454756.0026],[1705104000000,764669480252.108],[1705190400000,765413030638.2758],[1705276800000,751728074198.5833],[1705363200000,763009829957.6416],[1705449600000,777614406833.8657],[1705536000000,769011178459.9075],[1705622400000,743690861843.4908],[1705708800000,747617250694.5536],[1705795200000,749429420625.5665],[1705881600000,748057990952.723],[1705968000000,712368205942.2236],[1706054400000,717595846224.838],[1706140800000,723122348942.4839],[1706227200000,722256139684.4453],[1706313600000,755726914732.5784],[1706400000000,760573087967.9829],[1706486400000,760145533102.0594],[1706572800000,783768195098.995],[1706659200000,773940797300.2744],[1706745600000,772787325782.8993],[1706832000000,776719396542.2999],[1706918400000,783958733249.4813],[1707004800000,780353189423.5363],[1707091200000,775166129480.905],[1707177600000,778777836396.1644],[1707264000000,785970965415.3887],[1707350400000,806224953741.7788],[1707436800000,826409667638.9438],[1707523200000,858208751753.8303],[1707609600000,869955939250.7516],[1707696000000,875978364782.8907],[1707782400000,911118983788.3319],[1707868800000,909849453848.8177],[1707955200000,948242282042.772],[1708041600000,947310107637.3593],[1708128000000,950197730601.678],[1708214400000,942550827895.1658],[1708300800000,949322898904.7262],[1708387200000,942991011917.1135],[1708473600000,949540001949.272],[1708560000000,941627317726.0989],[1708646400000,930501637905.6324],[1708732800000,922355633704.3813],[1708819200000,934676374407.968],[1708905600000,939479759011.2206],[1708992000000,986997076569.4227],[1709078400000,1031955351342.0142],[1709164800000,1130411922559.5562],[1709251200000,1117043914029.6543],[1709337600000,1132012429589.901],[1709424000000,1124332376821.0593],[1709510400000,1140239593736.3315],[1709596800000,1228771711791.6702],[1709683200000,1165166845467.8345],[1709769600000,1190756605244.6602],[1709856000000,1203016564461.5864],[1709942400000,1225358206016.94],[1710028800000,1229978177507.9448],[1710115200000,1240532622108.6042],[1710201600000,1297198119272.7556],[1710288000000,1284621855311.5063],[1710374400000,1311710453331.1807],[1710460800000,1289935627507.947],[1710547200000,1255862277359.9707],[1710633600000,1177567732739.3704],[1710720000000,1235543252546.1501],[1710806400000,1224902554138.9043],[1710892800000,1126404736225.0005],[1710979200000,1218425490228.851],[1711065600000,1185130051154.7644],[1711152000000,1148762056120.8308],[1711238400000,1164746053086.3816],[1711324800000,1224549270763.7375],[1711411200000,1265735595022.6558],[1711497600000,1270001087597.4917],[1711584000000,1257948244675.7322],[1711670400000,1289062232208.1372],[1711756800000,1272808920844.3113],[1711843200000,1268735443309.547],[1711929600000,1298379310622.5427],[1712016000000,1279434155078.9397],[1712102400000,1196304599517.0796],[1712188800000,1201042014718.3618],[1712275200000,1244181203942.2173],[1712361600000,1233398772850.8083],[1712448000000,1252853847882.3098],[1712534400000,1262195994485.1626],[1712620800000,1298271561930.8398],[1712707200000,1254048562016.6165],[1712793600000,1291829474485.008],[1712880000000,1286022438247.104],[1712966400000,1240036468179.4954],[1713052800000,1191586427540.0698],[1713139200000,1214419196128.5972],[1713225600000,1174217480691.2903],[1713312000000,1180219579685.6067],[1713398400000,1131391929592.0496],[1713484800000,1174027153438.296],[1713571200000,1184813761326.875],[1713657600000,1198537261371.2727],[1713744000000,1200575636208.7861],[1713830400000,1236171486303.3884],[1713916800000,1221220335945.559],[1714003200000,1182986317158.927],[1714089600000,1183322986546.24],[1714176000000,1174004613361.7327],[1714262400000,1168134415693.9995],[1714348800000,1159037915185.4558],[1714435200000,1173144443638.821],[1714521600000,1122733421392.7615],[1714608000000,1074539282758.3136],[1714694400000,1085640313340.4697],[1714780800000,1151356095639.7876],[1714867200000,1167823420330.3193],[1714953600000,1170693158689.583],[1715040000000,1155394777129.147],[1715126400000,1143529004292.6711],[1715212800000,1120237757734.764],[1715299200000,1150877656960.4106],[1715385600000,1112737515566.075],[1715472000000,1110612255834.1973],[1715558400000,1124453150567.785],[1715644800000,1148051981903.983],[1715731200000,1121416977137.8594],[1715817600000,1200367522106.6362],[1715904000000,1183686599329.002],[1715990400000,1212577969927.9417],[1716076800000,1213212301806.6638],[1716163200000,1199871409788.1362],[1716249600000,1295016060059.1472],[1716336000000,1273106118664.916],[1716422400000,1258222473590.343],[1716508800000,1235720348593.309],[1716595200000,1244542923268.9268],[1716681600000,1257314123353.6438],[1716768000000,1243813340333.789],[1716854400000,1258601999436.9578],[1716940800000,1241636599638.2234],[1717027200000,1232802172226.9258],[1717113600000,1242748884197.98],[1717200000000,1226438800629.8735],[1717286400000,1228433941518.9446],[1717372800000,1231006583667.743],[1717459200000,1242762702466.981],[1717545600000,1278263906372.3005],[1717632000000,1287689992253.43],[1717718400000,1280663158196.4136],[1717804800000,1264299316495.0254],[1717891200000,1264020591899.3923],[1717977600000,1273609983761.1584],[1718064000000,1272210501370.2603],[1718150400000,1236256618655.0908],[1718236800000,1244029473920.9504],[1718323200000,1226474851982.018],[1718409600000,1214209737400.5469],[1718496000000,1217046635445.1626],[1718582400000,1227595179188.131],[1718668800000,1219051116802.9226],[1718755200000,1193916188008.191],[1718841600000,1189652390521.7627],[1718928000000,1195025470628.2522],[1719014400000,1181499377294.3064],[1719100800000,1184321835834.7974],[1719187200000,1167675702293.7664],[1719273600000,1107858256415.485],[1719360000000,1137194446940.2493],[1719446400000,1121746795196.9453],[1719532800000,1133540553897.4954],[1719619200000,1109655746534.5195],[1719705600000,1119192024599.859],[1719792000000,1151840688521.5325],[1719878400000,1153842325231.202],[1719964800000,1137636248797.9766],[1720051200000,1099959601411.4194],[1720137600000,1045455213793.5796],[1720224000000,1031120886833.7273],[1720310400000,1058232307989.9891],[1720396800000,1016418261442.7322],[1720483200000,1031886535204.6488],[1720569600000,1058511512538.3088],[1720656000000,1051818926340.8833],[1720742400000,1040185593841.387],[1720828800000,1045247628106.2981],[1720915200000,1069888561366.5227],[1721001600000,1106207687377.5999],[1721088000000,1172591088151.519],[1721174400000,1178656334961.6272],[1721260800000,1157075741795.815],[1721347200000,1157133857736.086],[1721433600000,1208294383639.0444],[1721520000000,1217149815021.6057],[1721606400000,1232461528971.5544],[1721692800000,1224219293321.9321],[1721779200000,1198835347285.7869],[1721865600000,1190570334458.9207],[1721952000000,1195542086259.1108],[1722038400000,1232400087482.9558],[1722124800000,1235238043388.1118],[1722211200000,1240304376596.7764],[1722297600000,1217681073144.4795],[1722384000000,1208037973353.6438],[1722470400000,1178510499183.3403],[1722556800000,1194838474557.0352],[1722643200000,1109705678810.4106],[1722729600000,1097387256676.079],[1722816000000,1052498007467.1093],[1722902400000,972427918423.3748],[1722988800000,1010553135277.3352],[1723075200000,995829528973.5793],[1723161600000,1116222608474.5273],[1723248000000,1096966222757.3721],[1723334400000,1100012902964.6958],[1723420800000,1062177229408.719],[1723507200000,1071240180223.895],[1723593600000,1087488608146.0857],[1723680000000,1053042558016.0778],[1723766400000,1036457024309.6311],[1723852800000,1053996298299.8085],[1723939200000,1063563839568.8687],[1724025600000,1048736410107.2246],[1724112000000,1061067922878.8774],[1724198400000,1047855615204.1729],[1724284800000,1082245173425.6625],[1724371200000,1072480197942.716],[1724457600000,1126449704877.065],[1724544000000,1130137190128.4998],[1724630400000,1134445401094.1704],[1724716800000,1112777064777.5403],[1724803200000,1052696200890.6747],[1724889600000,1048104543360.7959],[1724976000000,1057603318248.8383],[1725062400000,1056174914763.6614],[1725148800000,1052644976421.7173],[1725235200000,1025971504101.7284],[1725321600000,1055774373172.189],[1725408000000,1029402103522.1177],[1725494400000,1033215644314.5056],[1725580800000,998279096382.0908],[1725667200000,960727122442.3049],[1725753600000,963876478138.5984],[1725840000000,978789756851.9156],[1725926400000,1021080494896.519],[1726012800000,1032514469645.9021],[1726099200000,1030116857690.4011],[1726185600000,1036768569791.1576],[1726272000000,1080345335187.4402],[1726358400000,1069483699270.5634],[1726444800000,1054918116527.0073],[1726531200000,1033801760122.5457],[1726617600000,1071488975861.3236],[1726704000000,1090617713058.4265],[1726790400000,1114446751303.7446],[1726876800000,1115365405107.757],[1726963200000,1122693086016.0098],[1727049600000,1125222965632.3362],[1727136000000,1125923016568.3755],[1727222400000,1136025045157.7083],[1727308800000,1119165941254.2256],[1727395200000,1151723722886.6248],[1727481600000,1163414323043.4895],[1727568000000,1166555764895.319],[1727654400000,1162004270981.7502],[1727740800000,1121955923185.1003],[1727827200000,1087242811870.4403],[1727913600000,1085088202725.9215],[1728000000000,1087741100651.1527],[1728086400000,1117128576184.053],[1728172800000,1117066123002.787],[1728259200000,1131919568773.6216],[1728345600000,1121655031340.7485],[1728432000000,1120296945863.3242],[1728518400000,1094687997044.0862],[1728604800000,1088058691272.9097],[1728691200000,1127062755021.9717],[1728777600000,1141359509847.64],[1728864000000,1134323870057.25],[1728950400000,1197356598803.713],[1729036800000,1215867091585.8062],[1729123200000,1231494200521.79],[1729209600000,1228557191099.4949],[1729296000000,1244920069909.3826],[1729382400000,1243448974215.4006],[1729468800000,1253692236055.3801],[1729555200000,1231860087149.921],[1729641600000,1233160899216.1892],[1729728000000,1222780930737.8003],[1729814400000,1244798930657.2415],[1729900800000,1216575374276.9106],[1729987200000,1226960927651.153],[1730073600000,1244481292778.0234],[1730160000000,1276885375262.7925],[1730246400000,1330292730940.5366],[1730332800000,1317853770698.0745],[1730419200000,1277141006442.827],[1730505600000,1264603721992.7305],[1730592000000,1260304002327.4312],[1730678400000,1252514111441.5698],[1730764800000,1232424950327.2417],[1730851200000,1254425017928.7395],[1730937600000,1394022982578.445],[1731024000000,1391245051949.168],[1731110400000,1411897389187.6711],[1731196800000,1417614104649.186],[1731283200000,1484434748459.1074],[1731369600000,1647828731487.2427],[1731456000000,1640041552819.5366],[1731542400000,1694929711862.1062],[1731628800000,1639737492315.8826],[1731715200000,1706203252031.6326],[1731801600000,1697962280123.0564],[1731888000000,1686578526653.0322],[1731974400000,1689949263959.0898],[1732060800000,1720695713494.1255],[1732147200000,1766101580550.202],[1732233600000,1861205978707.735],[1732320000000,1879473647269.3767],[1732406400000,1853767867711.6104],[1732492800000,1850862266537.5037],[1732579200000,1757981270636.0205],[1732665600000,1734807678369.8247],[1732752000000,1798047653070.2747],[1732838400000,1791871712985.0552],[1732924800000,1821220595631.0247],[1733011200000,1805655347652.551],[1733097600000,1826712092790.8245],[1733184000000,1805973641926.7527],[1733270400000,1808870011532.4983],[1733356800000,1861950970195.8547],[1733443200000,1814513963950.151],[1733529600000,1870708184255.423],[1733616000000,1868160448106.8904],[1733702400000,1895819284477.133],[1733788800000,1824685578367.7026],[1733875200000,1816541694218.0803],[1733961600000,1905410387343.5999],[1734048000000,1889662319163.5203],[1734134400000,1908887834839.7786],[1734220800000,1911680889414.6633],[1734307200000,1974756090871.2612],[1734393600000,1994542016925.7861],[1734396005000,1997412290393.3455]],"total_volumes":[[1702944000000,24236756809.610874],[1703030400000,21312795316.66426],[1703116800000,25887016911.17018],[1703203200000,19940952368.916634],[1703289600000,18895067898.906437],[1703376000000,8953022262.130066],[1703462400000,16623374495.489248],[1703548800000,17009181022.074778],[1703635200000,18544256378.162125],[1703721600000,20925648865.060066],[1703808000000,18375029769.578327],[1703894400000,22461574030.714294],[1703980800000,13321172326.845724],[1704067200000,12850316555.324738],[1704153600000,15367058214.745464],[1704240000000,35725011405.88339],[1704326400000,39491078579.2771],[1704412800000,23866156229.01105],[1704499200000,26805544307.690857],[1704585600000,10809150081.41469],[1704672000000,13845181930.24744],[1704758400000,37274113826.374435],[1704844800000,36490066076.38239],[1704931200000,47410932764.19392],[1705017600000,44797651373.354866],[1705104000000,41876420338.82902],[1705190400000,17716846311.352158],[1705276800000,15503197379.230145],[1705363200000,20679492660.517693],[1705449600000,20247282080.206425],[1705536000000,19567983377.45631],[1705622400000,23134362082.989418],[1705708800000,22383239893.13225],[1705795200000,8742685618.842075],[1705881600000,7375172401.676388],[1705968000000,28682189093.05966],[1706054400000,27340392964.10615],[1706140800000,20416186504.057236],[1706227200000,12329524792.522268],[1706313600000,20878573788.263638],[1706400000000,9844140113.16917],[1706486400000,12607124883.848349],[1706572800000,19085104248.29378],[1706659200000,22618759527.17684],[1706745600000,20533587440.086643],[1706832000000,20636966305.840107],[1706918400000,17247488100.16845],[1707004800000,7204501674.165378],[1707091200000,10436964474.709417],[1707177600000,17391461496.537296],[1707264000000,15924809340.146698],[1707350400000,19676663584.994637],[1707436800000,25715815919.968224],[1707523200000,38938892280.48865],[1707609600000,15263610371.064165],[1707696000000,12223157201.843592],[1707782400000,34984086720.421394],[1707868800000,34861630132.88448],[1707955200000,38955768310.822464],[1708041600000,29689990497.43616],[1708128000000,23058964466.517426],[1708214400000,18420761945.184425],[1708300800000,15788371337.35062],[1708387200000,20933904079.177547],[1708473600000,31913880466.34793],[1708560000000,28304827430.508766],[1708646400000,21925852529.42366],[1708732800000,20897974088.66788],[1708819200000,14334928103.442892],[1708905600000,14284884544.825203],[1708992000000,32632631614.771385],[1709078400000,47944138521.27305],[1709164800000,80469773523.04562],[1709251200000,62652966681.79769],[1709337600000,33838491238.65334],[1709424000000,23234013063.10625],[1709510400000,25003431615.645008],[1709596800000,68683749167.89397],[1709683200000,88793359828.09514],[1709769600000,67115921901.30683],[1709856000000,44611078613.12767],[1709942400000,57885098776.72138],[1710028800000,19518157761.86789],[1710115200000,33506857287.23204],[1710201600000,60984557068.80768],[1710288000000,59112467277.932526],[1710374400000,47399187528.719086],[1710460800000,57955447999.744316],[1710547200000,74565965097.54878],[1710633600000,45237986618.562836],[1710720000000,43360819029.21664],[1710806400000,47000236982.08203],[1710892800000,73786793068.606],[1710979200000,64770977376.002335],[1711065600000,44547287696.10954],[1711152000000,39081986975.866745],[1711238400000,23474954022.615948],[1711324800000,25997014256.79051],[1711411200000,41354490140.194534],[1711497600000,33470731576.137875],[1711584000000,38323058412.463455],[1711670400000,28222733898.22986],[1711756800000,23875651073.0657],[1711843200000,15193189000.910406],[1711929600000,18273877855.502132],[1712016000000,33562044549.864765],[1712102400000,41940693527.11473],[1712188800000,32810919678.179626],[1712275200000,34615710161.11247],[1712361600000,32399476179.27087],[1712448000000,17631945851.64823],[1712534400000,16561889813.524487],[1712620800000,30480013239.47029],[1712707200000,33601878224.764893],[1712793600000,35733622227.3955],[1712880000000,28076812702.66515],[1712966400000,40787556830.5058],[1713052800000,46175487248.79834],[1713139200000,37831941525.483795],[1713225600000,40270749314.591446],[1713312000000,39692534875.41359],[1713398400000,38541431787.99773],[1713484800000,33730360102.954216],[1713571200000,48982665986.2395],[1713657600000,15570292448.765707],[1713744000000,18381678273.477943],[1713830400000,26045239342.36995],[1713916800000,21708521696.923584],[1714003200000,28919311055.77435],[1714089600000,23029161947.69737],[1714176000000,21788821603.454067],[1714262400000,17940895060.380325],[1714348800000,15114077954.721254],[1714435200000,25421790237.986824],[1714521600000,37064033196.407425],[1714608000000,47686124997.17279],[1714694400000,26331959408.546562],[1714780800000,31194900456.511097],[1714867200000,19047182119.65269],[1714953600000,16836129152.427391],[1715040000000,16635968731.550478],[1715126400000,18530378080.389984],[1715212800000,18891699679.907948],[1715299200000,23963094002.49368],[1715385600000,22254420525.397045],[1715472000000,11244904093.616146],[1715558400000,12111167668.150711],[1715644800000,25701869152.978073],[1715731200000,19954749749.200596],[1715817600000,34989448098.62592],[1715904000000,26791795060.979183],[1715990400000,23344382280.64292],[1716076800000,11972376997.253046],[1716163200000,8343132501.680276],[1716249600000,33076900352.36851],[1716336000000,37863059660.65226],[1716422400000,28693829269.986565],[1716508800000,36282391585.036026],[1716595200000,25915528544.90744],[1716681600000,14683494912.702457],[1716768000000,10364325595.831345],[1716854400000,17479681941.591335],[1716940800000,28821382088.28565],[1717027200000,22515367160.244823],[1717113600000,23398765116.409836],[1717200000000,18130599420.539665],[1717286400000,9878000585.622467],[1717372800000,14869923072.588507],[1717459200000,27377900221.38685],[1717545600000,29004797739.730236],[1717632000000,29943165536.127033],[1717718400000,21678984842.985905],[1717804800000,17208262650.83919],[1717891200000,9889885385.793055],[1717977600000,9705557901.477417],[1718064000000,17963155583.8117],[1718150400000,35859341572.46878],[1718236800000,33314086852.608078],[1718323200000,27612809188.818436],[1718409600000,26082269218.757824],[1718496000000,12570767640.817629],[1718582400000,11817587184.625206],[1718668800000,27381634087.485992],[1718755200000,38534540549.638336],[1718841600000,20174670906.88063],[1718928000000,24288361251.269745],[1719014400000,23096069748.525814],[1719100800000,5929957294.213889],[1719187200000,10055620107.878874],[1719273600000,39167894649.76987],[1719360000000,19707107706.19171],[1719446400000,21446724014.390743],[1719532800000,17608135427.23568],[1719619200000,22760288280.98671],[1719705600000,10666911569.81817],[1719792000000,16181763633.619421],[1719878400000,23818378375.692726],[1719964800000,16838886952.541449],[1720051200000,28126682562.287437],[1720137600000,39882312622.7753],[1720224000000,55388162417.01709],[1720310400000,19642932662.272545],[1720396800000,18668734939.988163],[1720483200000,38420746451.49442],[1720569600000,26274120844.467396],[1720656000000,23991700389.182217],[1720742400000,26852746247.844795],[1720828800000,23352541611.613438],[1720915200000,15352362821.061203],[1721001600000,19562980897.157185],[1721088000000,34699131677.40675],[1721174400000,37524680143.79378],[1721260800000,30584615622.361465],[1721347200000,23826232916.692142],[1721433600000,33963050379.528145],[1721520000000,15932968631.498928],[1721606400000,24843581686.185513],[1721692800000,39625015138.62209],[1721779200000,33266571810.64418],[1721865600000,26460367769.009254],[1721952000000,33026960946.45104],[1722038400000,28347985942.522514],[1722124800000,28493532219.991848],[1722211200000,15823930171.331104],[1722297600000,39304449047.5405],[1722384000000,26709586357.683525],[1722470400000,29010595429.980762],[1722556800000,36070582812.4214],[1722643200000,35716608343.00602],[1722729600000,30124639091.24687],[1722816000000,30857488073.92184],[1722902400000,109805375344.71971],[1722988800000,48165283096.90922],[1723075200000,38065296810.65033],[1723161600000,45075752485.89274],[1723248000000,30945556000.44254],[1723334400000,12282202454.06668],[1723420800000,19899321258.11229],[1723507200000,36030650038.289856],[1723593600000,29062190504.223873],[1723680000000,25500715375.97391],[1723766400000,30524957963.753498],[1723852800000,27528656719.12384],[1723939200000,11301669424.835934],[1724025600000,16014988081.814625],[1724112000000,21678542798.058605],[1724198400000,28280403717.648746],[1724284800000,29546835293.844273],[1724371200000,25338482846.447086],[1724457600000,40203764990.14742],[1724544000000,19681198155.428112],[1724630400000,15978900606.43484],[1724716800000,16027077290.044294],[1724803200000,34111499405.192535],[1724889600000,37771373954.530266],[1724976000000,29388408896.179905],[1725062400000,39813795557.68244],[1725148800000,10306119140.205893],[1725235200000,23055759148.414356],[1725321600000,25313134436.021263],[1725408000000,24283254518.8608],[1725494400000,33886402300.26096],[1725580800000,27004866037.61644],[1725667200000,45320178289.28158],[1725753600000,15372585933.106846],[1725840000000,16815561367.908846],[1725926400000,32308980055.771793],[1726012800000,26930906291.377064],[1726099200000,34299672189.86188],[1726185600000,30851533892.901806],[1726272000000,28788956794.35168],[1726358400000,14256812437.764132],[1726444800000,15593989422.240282],[1726531200000,28931645426.28441],[1726617600000,30740118553.114304],[1726704000000,36395588024.81709],[1726790400000,37601462347.57837],[1726876800000,31844524527.859264],[1726963200000,11582366076.384974],[1727049600000,18230121450.62975],[1727136000000,21597115107.60136],[1727222400000,28142705656.780155],[1727308800000,23437869613.173584],[1727395200000,33996117325.546436],[1727481600000,29239510929.881367],[1727568000000,13733976640.534172],[1727654400000,11593612614.885637],[1727740800000,31537924652.0654],[1727827200000,49034177042.745705],[1727913600000,37901679060.51742],[1728000000000,34172777744.048645],[1728086400000,27639040540.389217],[1728172800000,10103308836.609213],[1728259200000,13300847513.28617],[1728345600000,30869381700.261482],[1728432000000,26081711472.44388],[1728518400000,26075363404.124302],[1728604800000,26964801126.052986],[1728691200000,29250057048.886543],[1728777600000,16143457901.477922],[1728864000000,15338765154.550152],[1728950400000,42678099498.38608],[1729036800000,47590231395.44467],[1729123200000,37424938104.72408],[1729209600000,31867101644.482155],[1729296000000,36776163863.41151],[1729382400000,12978490558.257559],[1729468800000,15877367461.98269],[1729555200000,37433699396.538925],[1729641600000,29080874803.888737],[1729728000000,30311730450.974777],[1729814400000,33147468234.34605],[1729900800000,44837230618.6183],[1729987200000,19600259820.499084],[1730073600000,15316545074.05997],[1730160000000,38825503403.448814],[1730246400000,60527540932.56764],[1730332800000,40056440641.14229],[1730419200000,42173773194.33304],[1730505600000,50469710696.11748],[1730592000000,13526495563.210878],[1730678400000,34630826043.86812],[1730764800000,42472713673.43949],[1730851200000,36620093013.85483],[1730937600000,119717779475.74457],[1731024000000,61665436983.2205],[1731110400000,47267635583.06925],[1731196800000,29323966699.251873],[1731283200000,87239403420.88857],[1731369600000,125646393751.07103],[1731456000000,143470295938.74728],[1731542400000,128767560174.35379],[1731628800000,94639219723.79593],[1731715200000,80017548477.67146],[1731801600000,46711735711.32105],[1731888000000,46088268734.089355],[1731974400000,73036195338.83128],[1732060800000,75881616541.24971],[1732147200000,76555842681.1068],[1732233600000,112837970883.96764],[1732320000000,82303465655.24199],[1732406400000,45511800251.80984],[1732492800000,48355173230.56986],[1732579200000,85585174959.04805],[1732665600000,92837558437.63956],[1732752000000,77130214035.7825],[1732838400000,46460805592.15066],[1732924800000,70577516541.62772],[1733011200000,41198720283.14104],[1733097600000,46618694880.34837],[1733184000000,96193940544.53133],[1733270400000,83671044173.51279],[1733356800000,93446650410.37816],[1733443200000,179935267250.6917],[1733529600000,109509066272.2067],[1733616000000,57765605617.94603],[1733702400000,59316795003.98513],[1733788800000,139118734393.82437],[1733875200000,119485287617.8021],[1733961600000,112782750873.94843],[1734048000000,95565580844.76036],[1734134400000,75010198346.95139],[1734220800000,54581936836.852325],[1734307200000,67211229482.25423],[1734393600000,109519261247.90494],[1734396005000,105347250732.53983]] +} diff --git a/example/lib/presentation/presentation_utils.dart b/example/lib/presentation/presentation_utils.dart new file mode 100644 index 000000000..037bf5044 --- /dev/null +++ b/example/lib/presentation/presentation_utils.dart @@ -0,0 +1,16 @@ +import 'package:flutter/cupertino.dart'; +import 'package:intl/intl.dart'; + +class AppUtils { + static String getFormattedCurrency( + BuildContext context, + double value, { + bool noDecimals = true, + }) { + final germanFormat = NumberFormat.currency( + symbol: '€', + decimalDigits: noDecimals && value % 1 == 0 ? 0 : 2, + ); + return germanFormat.format(value); + } +} diff --git a/example/lib/presentation/samples/bar/bar_chart_sample1.dart b/example/lib/presentation/samples/bar/bar_chart_sample1.dart index 7a5465c40..53a351b8c 100644 --- a/example/lib/presentation/samples/bar/bar_chart_sample1.dart +++ b/example/lib/presentation/samples/bar/bar_chart_sample1.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:math'; -import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart_app/presentation/resources/app_resources.dart'; import 'package:fl_chart_app/util/extensions/color_extensions.dart'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; class BarChartSample1 extends StatefulWidget { diff --git a/example/lib/presentation/samples/chart_samples.dart b/example/lib/presentation/samples/chart_samples.dart index 98e090148..e058a1e8d 100644 --- a/example/lib/presentation/samples/chart_samples.dart +++ b/example/lib/presentation/samples/chart_samples.dart @@ -12,6 +12,7 @@ import 'chart_sample.dart'; import 'line/line_chart_sample1.dart'; import 'line/line_chart_sample10.dart'; import 'line/line_chart_sample11.dart'; +import 'line/line_chart_sample12.dart'; import 'line/line_chart_sample2.dart'; import 'line/line_chart_sample3.dart'; import 'line/line_chart_sample4.dart'; @@ -41,6 +42,7 @@ class ChartSamples { LineChartSample(9, (context) => LineChartSample9()), LineChartSample(10, (context) => const LineChartSample10()), LineChartSample(11, (context) => const LineChartSample11()), + LineChartSample(12, (context) => const LineChartSample12()), ], ChartType.bar: [ BarChartSample(1, (context) => BarChartSample1()), diff --git a/example/lib/presentation/samples/line/line_chart_sample12.dart b/example/lib/presentation/samples/line/line_chart_sample12.dart new file mode 100644 index 000000000..8856cedad --- /dev/null +++ b/example/lib/presentation/samples/line/line_chart_sample12.dart @@ -0,0 +1,381 @@ +import 'dart:convert'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:fl_chart_app/presentation/presentation_utils.dart'; +import 'package:fl_chart_app/presentation/resources/app_colors.dart'; +import 'package:fl_chart_app/util/extensions/color_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class LineChartSample12 extends StatefulWidget { + const LineChartSample12({super.key}); + + @override + State createState() => _LineChartSample12State(); +} + +class _LineChartSample12State extends State { + List<(DateTime, double)>? _bitcoinPriceHistory; + late TransformationController _transformationController; + + @override + void initState() { + _reloadData(); + _transformationController = TransformationController(); + super.initState(); + } + + void _reloadData() async { + final dataStr = await rootBundle.loadString( + 'assets/data/btc_last_year_price.json', + ); + final json = jsonDecode(dataStr) as Map; + setState(() { + _bitcoinPriceHistory = (json['prices'] as List).map((item) { + final timestamp = item[0] as int; + final price = item[1] as double; + return (DateTime.fromMillisecondsSinceEpoch(timestamp), price); + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + const leftReservedSize = 52.0; + return Column( + children: [ + LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return width >= 380 + ? Row( + children: [ + const SizedBox(width: leftReservedSize), + const _ChartTitle(), + const Spacer(), + Center( + child: _TransformationButtons( + controller: _transformationController, + ), + ), + ], + ) + : Column( + children: [ + const _ChartTitle(), + const SizedBox(height: 16), + _TransformationButtons( + controller: _transformationController, + ), + ], + ); + }, + ), + AspectRatio( + aspectRatio: 1.4, + child: Padding( + padding: const EdgeInsets.only( + top: 0.0, + right: 18.0, + ), + child: LineChart( + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + minScale: 1.0, + maxScale: 25.0, + transformationController: _transformationController, + ), + LineChartData( + lineBarsData: [ + LineChartBarData( + spots: _bitcoinPriceHistory?.asMap().entries.map((e) { + final index = e.key; + final item = e.value; + final value = item.$2; + return FlSpot(index.toDouble(), value); + }).toList() ?? + [], + dotData: const FlDotData(show: false), + color: AppColors.contentColorYellow, + barWidth: 1, + shadow: const Shadow( + color: AppColors.contentColorYellow, + blurRadius: 2, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + AppColors.contentColorYellow.withValues(alpha: 0.2), + AppColors.contentColorYellow.withValues(alpha: 0.0), + ], + stops: const [0.5, 1.0], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + lineTouchData: LineTouchData( + touchSpotThreshold: 5, + getTouchLineStart: (_, __) => -double.infinity, + getTouchLineEnd: (_, __) => double.infinity, + getTouchedSpotIndicator: + (LineChartBarData barData, List spotIndexes) { + return spotIndexes.map((spotIndex) { + return TouchedSpotIndicatorData( + const FlLine( + color: AppColors.contentColorRed, + strokeWidth: 1.5, + dashArray: [8, 2], + ), + FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) { + return FlDotCirclePainter( + radius: 6, + color: AppColors.contentColorYellow, + strokeWidth: 0, + strokeColor: AppColors.contentColorYellow, + ); + }, + ), + ); + }).toList(); + }, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (List touchedBarSpots) { + return touchedBarSpots.map((barSpot) { + final price = barSpot.y; + final date = + _bitcoinPriceHistory![barSpot.x.toInt()].$1; + return LineTooltipItem( + '', + const TextStyle( + color: AppColors.contentColorBlack, + fontWeight: FontWeight.bold, + ), + children: [ + TextSpan( + text: '${date.year}/${date.month}/${date.day}', + style: TextStyle( + color: AppColors.contentColorGreen.darken(20), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + TextSpan( + text: '\n${AppUtils.getFormattedCurrency( + context, + price, + noDecimals: true, + )}', + style: const TextStyle( + color: AppColors.contentColorYellow, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ); + }).toList(); + }, + getTooltipColor: (LineBarSpot barSpot) => + AppColors.contentColorBlack, + ), + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: const AxisTitles( + drawBelowEverything: true, + sideTitles: SideTitles( + showTitles: true, + reservedSize: leftReservedSize, + maxIncluded: false, + minIncluded: false, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 38, + maxIncluded: false, + getTitlesWidget: (double value, TitleMeta meta) { + final date = _bitcoinPriceHistory![value.toInt()].$1; + return SideTitleWidget( + axisSide: meta.axisSide, + child: Transform.rotate( + angle: -45 * 3.14 / 180, + child: Text( + '${date.month}/${date.day}', + style: const TextStyle( + color: AppColors.contentColorGreen, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + }, + ), + ), + ), + ), + duration: Duration.zero, + ), + ), + ), + ], + ); + } + + @override + void dispose() { + _transformationController.dispose(); + super.dispose(); + } +} + +class _ChartTitle extends StatelessWidget { + const _ChartTitle(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 14), + Text( + 'Bitcoin Price History', + style: TextStyle( + color: AppColors.contentColorYellow, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + Text( + '2023/12/19 - 2024/12/17', + style: TextStyle( + color: AppColors.contentColorGreen, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + SizedBox(height: 14), + ], + ); + } +} + +class _TransformationButtons extends StatelessWidget { + const _TransformationButtons({ + required this.controller, + }); + + final TransformationController controller; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Tooltip( + message: 'Zoom in', + child: IconButton( + icon: const Icon( + Icons.add, + size: 16, + ), + onPressed: _transformationZoomIn, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: 'Move left', + child: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + size: 16, + ), + onPressed: _transformationMoveLeft, + ), + ), + Tooltip( + message: 'Reset zoom', + child: IconButton( + icon: const Icon( + Icons.refresh, + size: 16, + ), + onPressed: _transformationReset, + ), + ), + Tooltip( + message: 'Move right', + child: IconButton( + icon: const Icon( + Icons.arrow_forward_ios, + size: 16, + ), + onPressed: _transformationMoveRight, + ), + ), + ], + ), + Tooltip( + message: 'Zoom out', + child: IconButton( + icon: const Icon( + Icons.minimize, + size: 16, + ), + onPressed: _transformationZoomOut, + ), + ), + ], + ); + } + + void _transformationReset() { + controller.value = Matrix4.identity(); + } + + void _transformationZoomIn() { + controller.value *= Matrix4.diagonal3Values( + 1.1, + 1.1, + 1, + ); + } + + void _transformationMoveLeft() { + controller.value *= Matrix4.translationValues( + 20, + 0, + 0, + ); + } + + void _transformationMoveRight() { + controller.value *= Matrix4.translationValues( + -20, + 0, + 0, + ); + } + + void _transformationZoomOut() { + controller.value *= Matrix4.diagonal3Values( + 0.9, + 0.9, + 1, + ); + } +} diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 8fc3fc4ef..a36b9516f 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -32,4 +32,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift index 8e02df288..b3c176141 100644 --- a/example/macos/Runner/AppDelegate.swift +++ b/example/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 76b7c4143..b2f5fe697 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: flutter_bloc: ^8.1.6 package_info_plus: ^8.0.2 equatable: ^2.0.5 + intl: ^0.20.1 dev_dependencies: flutter_test: @@ -39,3 +40,4 @@ flutter: assets: - assets/icons/ - assets/fonts/ + - assets/data/ diff --git a/lib/fl_chart.dart b/lib/fl_chart.dart index d3597fb81..d35f1afcc 100644 --- a/lib/fl_chart.dart +++ b/lib/fl_chart.dart @@ -5,6 +5,8 @@ export 'src/chart/bar_chart/bar_chart.dart'; export 'src/chart/bar_chart/bar_chart_data.dart'; export 'src/chart/base/axis_chart/axis_chart_data.dart'; export 'src/chart/base/axis_chart/axis_chart_widgets.dart'; +export 'src/chart/base/axis_chart/scale_axis.dart'; +export 'src/chart/base/axis_chart/transformation_config.dart'; export 'src/chart/base/base_chart/base_chart_data.dart'; export 'src/chart/base/base_chart/fl_touch_event.dart'; export 'src/chart/line_chart/line_chart.dart'; diff --git a/lib/src/chart/bar_chart/bar_chart.dart b/lib/src/chart/bar_chart/bar_chart.dart index 2806be0d2..a9c12f31f 100644 --- a/lib/src/chart/bar_chart/bar_chart.dart +++ b/lib/src/chart/bar_chart/bar_chart.dart @@ -1,7 +1,11 @@ -import 'package:fl_chart/fl_chart.dart'; +import 'package:fl_chart/src/chart/bar_chart/bar_chart_data.dart'; import 'package:fl_chart/src/chart/bar_chart/bar_chart_helper.dart'; import 'package:fl_chart/src/chart/bar_chart/bar_chart_renderer.dart'; import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart'; +import 'package:fl_chart/src/chart/base/base_chart/base_chart_data.dart'; +import 'package:fl_chart/src/chart/base/base_chart/fl_touch_event.dart'; import 'package:flutter/cupertino.dart'; /// Renders a bar chart as a widget, using provided [BarChartData]. @@ -11,7 +15,7 @@ class BarChart extends ImplicitlyAnimatedWidget { /// new values with animation, and duration is [duration]. /// also you can change the [curve] /// which default is [Curves.linear]. - const BarChart( + BarChart( this.data, { this.chartRendererKey, super.key, @@ -20,7 +24,20 @@ class BarChart extends ImplicitlyAnimatedWidget { Duration duration = const Duration(milliseconds: 150), @Deprecated('Please use [curve] instead') Curve? swapAnimationCurve, Curve curve = Curves.linear, - }) : super( + this.transformationConfig = const FlTransformationConfig(), + }) : assert( + switch (data.alignment) { + BarChartAlignment.center || + BarChartAlignment.end || + BarChartAlignment.start => + transformationConfig.scaleAxis != FlScaleAxis.horizontal && + transformationConfig.scaleAxis != FlScaleAxis.free, + _ => true, + }, + 'Can not scale horizontally when BarChartAlignment is center, ' + 'end or start', + ), + super( duration: swapAnimationDuration ?? duration, curve: swapAnimationCurve ?? curve, ); @@ -28,6 +45,9 @@ class BarChart extends ImplicitlyAnimatedWidget { /// Determines how the [BarChart] should be look like. final BarChartData data; + /// {@macro fl_chart.AxisChartScaffoldWidget.transformationConfig} + final FlTransformationConfig transformationConfig; + /// We pass this key to our renderers which are supposed to /// render the chart itself (without anything around the chart). final Key? chartRendererKey; @@ -56,10 +76,13 @@ class _BarChartState extends AnimatedWidgetBaseState { return AxisChartScaffoldWidget( data: showingData, - chart: BarChartLeaf( + transformationConfig: widget.transformationConfig, + chartBuilder: (context, chartVirtualRect) => BarChartLeaf( data: _withTouchedIndicators(_barChartDataTween!.evaluate(animation)), targetData: _withTouchedIndicators(showingData), key: widget.chartRendererKey, + chartVirtualRect: chartVirtualRect, + canBeScaled: widget.transformationConfig.scaleAxis != FlScaleAxis.none, ), ); } diff --git a/lib/src/chart/bar_chart/bar_chart_painter.dart b/lib/src/chart/bar_chart/bar_chart_painter.dart index 5dc35e8e8..355abd43d 100644 --- a/lib/src/chart/bar_chart/bar_chart_painter.dart +++ b/lib/src/chart/bar_chart/bar_chart_painter.dart @@ -34,11 +34,14 @@ class BarChartPainter extends AxisChartPainter { ..style = PaintingStyle.stroke ..color = Colors.transparent ..strokeWidth = 1.0; + + _clipPaint = Paint(); } late Paint _barPaint; late Paint _barStrokePaint; late Paint _bgTouchTooltipPaint; late Paint _borderTouchTooltipPaint; + late Paint _clipPaint; List? _groupBarsPosition; @@ -49,6 +52,16 @@ class BarChartPainter extends AxisChartPainter { CanvasWrapper canvasWrapper, PaintHolder holder, ) { + if (holder.chartVirtualRect != null) { + final canvasRect = Offset.zero & canvasWrapper.size; + canvasWrapper + ..saveLayer( + canvasRect, + _clipPaint, + ) + ..clipRect(canvasRect); + } + super.paint(context, canvasWrapper, holder); final data = holder.data; final targetData = holder.targetData; @@ -57,10 +70,15 @@ class BarChartPainter extends AxisChartPainter { return; } - final groupsX = data.calculateGroupsX(canvasWrapper.size.width); + final usableSize = holder.getChartUsableSize(canvasWrapper.size); + + final groupsX = data.calculateGroupsX(usableSize.width); + final adjustment = holder.chartVirtualRect?.left ?? 0; + final groupsXAdjusted = groupsX.map((e) => e + adjustment).toList(); + _groupBarsPosition = calculateGroupAndBarsPosition( - canvasWrapper.size, - groupsX, + usableSize, + groupsXAdjusted, data.barGroups, ); @@ -69,7 +87,7 @@ class BarChartPainter extends AxisChartPainter { context, canvasWrapper, holder, - canvasWrapper.size, + usableSize, ); } @@ -80,10 +98,14 @@ class BarChartPainter extends AxisChartPainter { context, canvasWrapper, holder, - canvasWrapper.size, + usableSize, ); } + if (holder.chartVirtualRect != null) { + canvasWrapper.restore(); + } + for (var i = 0; i < targetData.barGroups.length; i++) { final barGroup = targetData.barGroups[i]; for (var j = 0; j < barGroup.barRods.length; j++) { @@ -383,16 +405,18 @@ class BarChartPainter extends AxisChartPainter { final textWidth = drawingTextPainter.width; final textHeight = drawingTextPainter.height + textsBelowMargin; + final barX = groupPositions[barGroupIndex].barsX[barRodIndex]; + /// if we have multiple bar lines, /// there are more than one FlCandidate on touch area, /// we should get the most top FlSpot Offset to draw the tooltip on top of it final barToYPixel = Offset( - groupPositions[barGroupIndex].barsX[barRodIndex], + barX, getPixelY(showOnRodData.toY, viewSize, holder), ); final barFromYPixel = Offset( - groupPositions[barGroupIndex].barsX[barRodIndex], + barX, getPixelY(showOnRodData.fromY, viewSize, holder), ); @@ -404,6 +428,17 @@ class BarChartPainter extends AxisChartPainter { final drawTooltipOnTop = tooltipData.direction == TooltipDirection.top || (tooltipData.direction == TooltipDirection.auto && showOnRodData.isUpward()); + + final tooltipOriginPoint = Offset( + barX, + drawTooltipOnTop ? barTopY : barBottomY, + ); + + final isZoomed = holder.chartVirtualRect != null; + if (isZoomed && !canvasWrapper.size.contains(tooltipOriginPoint)) { + return; + } + final tooltipTop = drawTooltipOnTop ? barTopY - tooltipHeight - tooltipData.tooltipMargin : barBottomY + tooltipData.tooltipMargin; @@ -584,7 +619,7 @@ class BarChartPainter extends AxisChartPainter { /// Returns null if finds nothing! BarTouchedSpot? handleTouch( Offset localPosition, - Size viewSize, + Size size, PaintHolder holder, ) { final data = holder.data; @@ -594,6 +629,14 @@ class BarChartPainter extends AxisChartPainter { return null; } + final viewSize = holder.getChartUsableSize(size); + + // Check if the touch is outside the canvas bounds + final isZoomed = holder.chartVirtualRect != null; + if (isZoomed && !size.contains(touchedPoint)) { + return null; + } + if (_groupBarsPosition == null) { final groupsX = data.calculateGroupsX(viewSize.width); _groupBarsPosition = diff --git a/lib/src/chart/bar_chart/bar_chart_renderer.dart b/lib/src/chart/bar_chart/bar_chart_renderer.dart index fca83a4d5..b9d2ef0cf 100644 --- a/lib/src/chart/bar_chart/bar_chart_renderer.dart +++ b/lib/src/chart/bar_chart/bar_chart_renderer.dart @@ -9,10 +9,18 @@ import 'package:flutter/cupertino.dart'; /// Low level BarChart Widget. class BarChartLeaf extends LeafRenderObjectWidget { - const BarChartLeaf({super.key, required this.data, required this.targetData}); + const BarChartLeaf({ + super.key, + required this.data, + required this.targetData, + required this.canBeScaled, + required this.chartVirtualRect, + }); final BarChartData data; final BarChartData targetData; + final Rect? chartVirtualRect; + final bool canBeScaled; @override RenderBarChart createRenderObject(BuildContext context) => RenderBarChart( @@ -20,6 +28,8 @@ class BarChartLeaf extends LeafRenderObjectWidget { data, targetData, MediaQuery.of(context).textScaler, + chartVirtualRect, + canBeScaled: canBeScaled, ); @override @@ -28,7 +38,9 @@ class BarChartLeaf extends LeafRenderObjectWidget { ..data = data ..targetData = targetData ..textScaler = MediaQuery.of(context).textScaler - ..buildContext = context; + ..buildContext = context + ..chartVirtualRect = chartVirtualRect + ..canBeScaled = canBeScaled; } } // coverage:ignore-end @@ -40,10 +52,13 @@ class RenderBarChart extends RenderBaseChart { BarChartData data, BarChartData targetData, TextScaler textScaler, - ) : _data = data, + Rect? chartVirtualRect, { + required bool canBeScaled, + }) : _data = data, _targetData = targetData, _textScaler = textScaler, - super(targetData.barTouchData, context); + _chartVirtualRect = chartVirtualRect, + super(targetData.barTouchData, context, canBeScaled: canBeScaled); BarChartData get data => _data; BarChartData _data; @@ -73,6 +88,15 @@ class RenderBarChart extends RenderBaseChart { markNeedsPaint(); } + Rect? get chartVirtualRect => _chartVirtualRect; + Rect? _chartVirtualRect; + + set chartVirtualRect(Rect? value) { + if (_chartVirtualRect == value) return; + _chartVirtualRect = value; + markNeedsPaint(); + } + // We couldn't mock [size] property of this class, that's why we have this @visibleForTesting Size? mockTestSize; @@ -81,7 +105,7 @@ class RenderBarChart extends RenderBaseChart { BarChartPainter painter = BarChartPainter(); PaintHolder get paintHolder => - PaintHolder(data, targetData, textScaler); + PaintHolder(data, targetData, textScaler, chartVirtualRect); @override void paint(PaintingContext context, Offset offset) { diff --git a/lib/src/chart/base/axis_chart/axis_chart_data.dart b/lib/src/chart/base/axis_chart/axis_chart_data.dart index 5f7525d03..c9d68eede 100644 --- a/lib/src/chart/base/axis_chart/axis_chart_data.dart +++ b/lib/src/chart/base/axis_chart/axis_chart_data.dart @@ -1398,7 +1398,7 @@ class FlDotCirclePainter extends FlDotPainter { /// Implementation of the parent class to get the size of the circle @override - Size getSize(FlSpot spot) => Size(radius * 2, radius * 2); + Size getSize(FlSpot spot) => Size.fromRadius(radius + strokeWidth); @override Color get mainColor => color; @@ -1499,7 +1499,7 @@ class FlDotSquarePainter extends FlDotPainter { /// Implementation of the parent class to get the size of the square @override - Size getSize(FlSpot spot) => Size(size, size); + Size getSize(FlSpot spot) => Size.square(size + strokeWidth); @override Color get mainColor => color; diff --git a/lib/src/chart/base/axis_chart/axis_chart_painter.dart b/lib/src/chart/base/axis_chart/axis_chart_painter.dart index 53bd7033f..9581cd49d 100644 --- a/lib/src/chart/base/axis_chart/axis_chart_painter.dart +++ b/lib/src/chart/base/axis_chart/axis_chart_painter.dart @@ -25,12 +25,15 @@ abstract class AxisChartPainter _extraLinesPaint = Paint()..style = PaintingStyle.stroke; _imagePaint = Paint(); + + _clipPaint = Paint(); } late Paint _gridPaint; late Paint _backgroundPaint; late Paint _extraLinesPaint; late Paint _imagePaint; + late Paint _clipPaint; /// [_rangeAnnotationPaint] draws range annotations; late Paint _rangeAnnotationPaint; @@ -217,6 +220,10 @@ abstract class AxisChartPainter CanvasWrapper canvasWrapper, PaintHolder holder, ) { + if (holder.chartVirtualRect != null) { + canvasWrapper.restore(); + } + super.paint(context, canvasWrapper, holder); final data = holder.data; final viewSize = canvasWrapper.size; @@ -228,6 +235,15 @@ abstract class AxisChartPainter if (data.extraLinesData.verticalLines.isNotEmpty) { drawVerticalLines(context, canvasWrapper, holder, viewSize); } + + if (holder.chartVirtualRect != null) { + canvasWrapper + ..saveLayer( + Offset.zero & canvasWrapper.size, + _clipPaint, + ) + ..clipRect(Offset.zero & canvasWrapper.size); + } } void drawHorizontalLines( @@ -448,24 +464,53 @@ abstract class AxisChartPainter /// With this function we can convert our [FlSpot] x /// to the view base axis x . /// the view 0, 0 is on the top/left, but the spots is bottom/left - double getPixelX(double spotX, Size viewSize, PaintHolder holder) { - final data = holder.data; + double getPixelX( + double spotX, + Size viewSize, + PaintHolder holder, + ) { + final usableSize = holder.getChartUsableSize(viewSize); + + final pixelXUnadjusted = _getPixelX(spotX, holder.data, usableSize); + + // Adjust the position relative to the canvas if chartVirtualRect + // is provided + final adjustment = holder.chartVirtualRect?.left ?? 0; + return pixelXUnadjusted + adjustment; + } + + double _getPixelX(double spotX, D data, Size usableSize) { final deltaX = data.maxX - data.minX; if (deltaX == 0.0) { return 0; } - return ((spotX - data.minX) / deltaX) * viewSize.width; + return ((spotX - data.minX) / deltaX) * usableSize.width; } /// With this function we can convert our [FlSpot] y /// to the view base axis y. - double getPixelY(double spotY, Size viewSize, PaintHolder holder) { - final data = holder.data; + double getPixelY( + double spotY, + Size viewSize, + PaintHolder holder, + ) { + final usableSize = holder.getChartUsableSize(viewSize); + + final pixelYUnadjusted = _getPixelY(spotY, holder.data, usableSize); + + // Adjust the position relative to the canvas if chartVirtualRect + // is provided + final adjustment = holder.chartVirtualRect?.top ?? 0; + return pixelYUnadjusted + adjustment; + } + + double _getPixelY(double spotY, D data, Size usableSize) { final deltaY = data.maxY - data.minY; if (deltaY == 0.0) { - return viewSize.height; + return usableSize.height; } - return viewSize.height - (((spotY - data.minY) / deltaY) * viewSize.height); + return usableSize.height - + (((spotY - data.minY) / deltaY) * usableSize.height); } /// With this function we can get horizontal diff --git a/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart b/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart index d67421450..16fbb9aca 100644 --- a/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart +++ b/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart @@ -1,9 +1,22 @@ -import 'package:fl_chart/fl_chart.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_data.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; import 'package:fl_chart/src/chart/base/axis_chart/side_titles/side_titles_widget.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart'; +import 'package:fl_chart/src/chart/base/custom_interactive_viewer.dart'; import 'package:fl_chart/src/extensions/fl_titles_data_extension.dart'; import 'package:flutter/material.dart'; -/// A scaffold to show an axis-based chart +/// A builder to build a chart. +/// +/// The [chartVirtualRect] is the virtual chart virtual rect to be used when +/// laying out the chart's content. It is transformed based on users' +/// interactions like scaling and panning. +typedef ChartBuilder = Widget Function( + BuildContext context, + Rect? chartVirtualRect, +); + +/// A scaffold to show a scalable axis-based chart /// /// It contains some placeholders to represent an axis-based chart. /// @@ -18,60 +31,230 @@ import 'package:flutter/material.dart'; /// /// `left`, `top`, `right`, `bottom` are some place holders to show titles /// provided by [AxisChartData.titlesData] around the chart -/// `chart` is a centered place holder to show a raw chart. -class AxisChartScaffoldWidget extends StatelessWidget { +/// `chart` is a centered place holder to show a raw chart. The chart is +/// built using [chartBuilder]. +class AxisChartScaffoldWidget extends StatefulWidget { const AxisChartScaffoldWidget({ super.key, - required this.chart, + required this.chartBuilder, required this.data, + this.transformationConfig = const FlTransformationConfig(), }); - final Widget chart; + + /// The builder to build the chart. + final ChartBuilder chartBuilder; + + /// The data to build the chart. final AxisChartData data; + /// {@template fl_chart.AxisChartScaffoldWidget.transformationConfig} + /// The transformation configuration of the chart. + /// + /// Used to configure scaling and panning of the chart. + /// {@endtemplate} + final FlTransformationConfig transformationConfig; + + @override + State createState() => + _AxisChartScaffoldWidgetState(); +} + +class _AxisChartScaffoldWidgetState extends State { + late TransformationController _transformationController; + + final _chartKey = GlobalKey(); + + Rect? _chartVirtualRect; + + FlTransformationConfig get _transformationConfig => + widget.transformationConfig; + + bool get _canScaleHorizontally => + _transformationConfig.scaleAxis == FlScaleAxis.horizontal || + _transformationConfig.scaleAxis == FlScaleAxis.free; + + bool get _canScaleVertically => + _transformationConfig.scaleAxis == FlScaleAxis.vertical || + _transformationConfig.scaleAxis == FlScaleAxis.free; + + @override + void initState() { + super.initState(); + _transformationController = + _transformationConfig.transformationController ?? + TransformationController(); + _transformationController.addListener(_updateChartVirtualRect); + updateRectPostFrame(); + } + + @override + void dispose() { + _transformationController.removeListener(_updateChartVirtualRect); + if (_transformationConfig.transformationController == null) { + _transformationController.dispose(); + } + super.dispose(); + } + + @override + void didUpdateWidget(AxisChartScaffoldWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + switch (( + oldWidget.transformationConfig.transformationController, + widget.transformationConfig.transformationController + )) { + case (null, null): + break; + case (null, TransformationController()): + _transformationController.dispose(); + _transformationController = + widget.transformationConfig.transformationController!; + _transformationController.addListener(_updateChartVirtualRect); + case (TransformationController(), null): + _transformationController.removeListener(_updateChartVirtualRect); + _transformationController = TransformationController(); + _transformationController.addListener(_updateChartVirtualRect); + case (TransformationController(), TransformationController()): + if (oldWidget.transformationConfig.transformationController != + widget.transformationConfig.transformationController) { + _transformationController.removeListener(_updateChartVirtualRect); + _transformationController = + widget.transformationConfig.transformationController!; + _transformationController.addListener(_updateChartVirtualRect); + } + } + + updateRectPostFrame(); + } + + void updateRectPostFrame() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _updateChartVirtualRect(); + }); + } + + // Applies the inverse transformation to the chart to get the zoomed + // bounding box. + // + // The transformation matrix is inverted because the bounding box needs to + // grow beyond the chart's boundaries when the chart is scaled in order + // for its content to be laid out on the larger area. This leads to the + // scaling effect. + void _updateChartVirtualRect() { + final scale = _transformationController.value.getMaxScaleOnAxis(); + if (scale == 1.0) { + setState(() { + _chartVirtualRect = null; + }); + return; + } + final inverseMatrix = Matrix4.inverted(_transformationController.value); + + final chartVirtualQuad = CustomInteractiveViewer.transformViewport( + inverseMatrix, + _chartBoundaryRect, + ); + + final chartVirtualRect = CustomInteractiveViewer.axisAlignedBoundingBox( + chartVirtualQuad, + ); + + final adjustedRect = Rect.fromLTWH( + _canScaleHorizontally ? chartVirtualRect.left : _chartBoundaryRect.left, + _canScaleVertically ? chartVirtualRect.top : _chartBoundaryRect.top, + _canScaleHorizontally ? chartVirtualRect.width : _chartBoundaryRect.width, + _canScaleVertically ? chartVirtualRect.height : _chartBoundaryRect.height, + ); + + setState(() { + _chartVirtualRect = adjustedRect; + }); + } + + // The Rect representing the chart. + // + // This represents the actual size and offset of the chart. + Rect get _chartBoundaryRect { + assert(_chartKey.currentContext != null); + final childRenderBox = + _chartKey.currentContext!.findRenderObject()! as RenderBox; + return Offset.zero & childRenderBox.size; + } + bool get showLeftTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.leftTitles.showAxisTitles; - final showSideTitles = data.titlesData.leftTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.leftTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.leftTitles.showSideTitles; return showAxisTitles || showSideTitles; } bool get showRightTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.rightTitles.showAxisTitles; - final showSideTitles = data.titlesData.rightTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.rightTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.rightTitles.showSideTitles; return showAxisTitles || showSideTitles; } bool get showTopTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.topTitles.showAxisTitles; - final showSideTitles = data.titlesData.topTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.topTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.topTitles.showSideTitles; return showAxisTitles || showSideTitles; } bool get showBottomTitles { - if (!data.titlesData.show) { + if (!widget.data.titlesData.show) { return false; } - final showAxisTitles = data.titlesData.bottomTitles.showAxisTitles; - final showSideTitles = data.titlesData.bottomTitles.showSideTitles; + final showAxisTitles = widget.data.titlesData.bottomTitles.showAxisTitles; + final showSideTitles = widget.data.titlesData.bottomTitles.showSideTitles; return showAxisTitles || showSideTitles; } List stackWidgets(BoxConstraints constraints) { + final chart = KeyedSubtree( + key: _chartKey, + child: widget.chartBuilder(context, _chartVirtualRect), + ); + + final interactiveChart = LayoutBuilder( + builder: (context, constraints) { + return CustomInteractiveViewer( + transformationController: _transformationController, + clipBehavior: Clip.none, + trackpadScrollCausesScale: + _transformationConfig.trackpadScrollCausesScale, + maxScale: _transformationConfig.maxScale, + minScale: _transformationConfig.minScale, + child: SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: chart, + ), + ); + }, + ); + final widgets = [ Container( - margin: data.titlesData.allSidesPadding, + margin: widget.data.titlesData.allSidesPadding, decoration: BoxDecoration( - border: data.borderData.isVisible() ? data.borderData.border : null, + border: widget.data.borderData.isVisible() + ? widget.data.borderData.border + : null, ), - child: chart, + child: switch (_transformationConfig.scaleAxis) { + FlScaleAxis.none => chart, + FlScaleAxis() => interactiveChart, + }, ), ]; @@ -79,44 +262,48 @@ class AxisChartScaffoldWidget extends StatelessWidget { if (showLeftTitles) { widgets.insert( - insertIndex(data.titlesData.leftTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.leftTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.left, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, + chartVirtualRect: _chartVirtualRect, ), ); } if (showTopTitles) { widgets.insert( - insertIndex(data.titlesData.topTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.topTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.top, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, + chartVirtualRect: _chartVirtualRect, ), ); } if (showRightTitles) { widgets.insert( - insertIndex(data.titlesData.rightTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.rightTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.right, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, + chartVirtualRect: _chartVirtualRect, ), ); } if (showBottomTitles) { widgets.insert( - insertIndex(data.titlesData.bottomTitles.drawBelowEverything), + insertIndex(widget.data.titlesData.bottomTitles.drawBelowEverything), SideTitlesWidget( side: AxisSide.bottom, - axisChartData: data, + axisChartData: widget.data, parentSize: constraints.biggest, + chartVirtualRect: _chartVirtualRect, ), ); } diff --git a/lib/src/chart/base/axis_chart/scale_axis.dart b/lib/src/chart/base/axis_chart/scale_axis.dart new file mode 100644 index 000000000..b54f7b2af --- /dev/null +++ b/lib/src/chart/base/axis_chart/scale_axis.dart @@ -0,0 +1,20 @@ +enum FlScaleAxis { + /// Scales the horizontal axis. + horizontal, + + /// Scales the vertical axis. + vertical, + + /// Scales both the horizontal and vertical axes. + free, + + /// Does not scale the axes. + none; + + /// Axes that allow scaling. + static const scalingEnabledAxis = [ + free, + horizontal, + vertical, + ]; +} diff --git a/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart b/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart index da6d00915..2a134e9bd 100644 --- a/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart +++ b/lib/src/chart/base/axis_chart/side_titles/side_titles_widget.dart @@ -8,33 +8,41 @@ import 'package:fl_chart/src/extensions/fl_titles_data_extension.dart'; import 'package:fl_chart/src/utils/utils.dart'; import 'package:flutter/material.dart'; -class SideTitlesWidget extends StatelessWidget { +class SideTitlesWidget extends StatefulWidget { const SideTitlesWidget({ super.key, required this.side, required this.axisChartData, required this.parentSize, + this.chartVirtualRect, }); final AxisSide side; final AxisChartData axisChartData; final Size parentSize; + final Rect? chartVirtualRect; - bool get isHorizontal => side == AxisSide.top || side == AxisSide.bottom; + @override + State createState() => _SideTitlesWidgetState(); +} + +class _SideTitlesWidgetState extends State { + bool get isHorizontal => + widget.side == AxisSide.top || widget.side == AxisSide.bottom; bool get isVertical => !isHorizontal; - double get minX => axisChartData.minX; + double get minX => widget.axisChartData.minX; - double get maxX => axisChartData.maxX; + double get maxX => widget.axisChartData.maxX; - double get baselineX => axisChartData.baselineX; + double get baselineX => widget.axisChartData.baselineX; - double get minY => axisChartData.minY; + double get minY => widget.axisChartData.minY; - double get maxY => axisChartData.maxY; + double get maxY => widget.axisChartData.maxY; - double get baselineY => axisChartData.baselineY; + double get baselineY => widget.axisChartData.baselineY; double get axisMin => isHorizontal ? minX : minY; @@ -42,13 +50,15 @@ class SideTitlesWidget extends StatelessWidget { double get axisBaseLine => isHorizontal ? baselineX : baselineY; - FlTitlesData get titlesData => axisChartData.titlesData; + FlTitlesData get titlesData => widget.axisChartData.titlesData; - bool get isLeftOrTop => side == AxisSide.left || side == AxisSide.top; + bool get isLeftOrTop => + widget.side == AxisSide.left || widget.side == AxisSide.top; - bool get isRightOrBottom => side == AxisSide.right || side == AxisSide.bottom; + bool get isRightOrBottom => + widget.side == AxisSide.right || widget.side == AxisSide.bottom; - AxisTitles get axisTitles => switch (side) { + AxisTitles get axisTitles => switch (widget.side) { AxisSide.left => titlesData.leftTitles, AxisSide.top => titlesData.topTitles, AxisSide.right => titlesData.rightTitles, @@ -61,7 +71,7 @@ class SideTitlesWidget extends StatelessWidget { Axis get counterDirection => isHorizontal ? Axis.vertical : Axis.horizontal; - Alignment get alignment => switch (side) { + Alignment get alignment => switch (widget.side) { AxisSide.left => Alignment.centerLeft, AxisSide.top => Alignment.topCenter, AxisSide.right => Alignment.centerRight, @@ -70,8 +80,8 @@ class SideTitlesWidget extends StatelessWidget { EdgeInsets get thisSidePadding { final titlesPadding = titlesData.allSidesPadding; - final borderPadding = axisChartData.borderData.allSidesPadding; - return switch (side) { + final borderPadding = widget.axisChartData.borderData.allSidesPadding; + return switch (widget.side) { AxisSide.right || AxisSide.left => titlesPadding.onlyTopBottom + borderPadding.onlyTopBottom, @@ -82,9 +92,9 @@ class SideTitlesWidget extends StatelessWidget { } double get thisSidePaddingTotal { - final borderPadding = axisChartData.borderData.allSidesPadding; + final borderPadding = widget.axisChartData.borderData.allSidesPadding; final titlesPadding = titlesData.allSidesPadding; - return switch (side) { + return switch (widget.side) { AxisSide.right || AxisSide.left => titlesPadding.vertical + borderPadding.vertical, @@ -94,6 +104,28 @@ class SideTitlesWidget extends StatelessWidget { }; } + Size get viewSize { + final chartVirtualRect = widget.chartVirtualRect; + if (chartVirtualRect == null) { + return widget.parentSize; + } + + return chartVirtualRect.size + + Offset(thisSidePaddingTotal, thisSidePaddingTotal); + } + + double get axisOffset { + final chartVirtualRect = widget.chartVirtualRect; + if (chartVirtualRect == null) { + return 0; + } + + return switch (widget.side) { + AxisSide.left || AxisSide.right => chartVirtualRect.top, + AxisSide.top || AxisSide.bottom => chartVirtualRect.left, + }; + } + List makeWidgets( double axisViewSize, double axisMin, @@ -106,8 +138,8 @@ class SideTitlesWidget extends StatelessWidget { axisViewSize, axisMax - axisMin, ); - if (isHorizontal && axisChartData is BarChartData) { - final barChartData = axisChartData as BarChartData; + if (isHorizontal && widget.axisChartData is BarChartData) { + final barChartData = widget.axisChartData as BarChartData; if (barChartData.barGroups.isEmpty) { return []; } @@ -116,7 +148,8 @@ class SideTitlesWidget extends StatelessWidget { final index = e.key; final xLocation = e.value; final xValue = barChartData.barGroups[index].x; - return AxisSideTitleMetaData(xValue.toDouble(), xLocation); + final adjustedLocation = xLocation + axisOffset; + return AxisSideTitleMetaData(xValue.toDouble(), adjustedLocation); }).toList(); } else { final axisValues = AxisChartHelper().iterateThroughAxis( @@ -136,10 +169,13 @@ class SideTitlesWidget extends StatelessWidget { if (isVertical) { portion = 1 - portion; } - final axisLocation = portion * axisViewSize; + final axisLocation = portion * axisViewSize + axisOffset; return AxisSideTitleMetaData(axisValue, axisLocation); }).toList(); } + + axisPositions = _getPositionsWithinChartRange(axisPositions, side); + return axisPositions.map( (metaData) { return AxisSideTitleWidgetHolder( @@ -166,12 +202,37 @@ class SideTitlesWidget extends StatelessWidget { ).toList(); } + List _getPositionsWithinChartRange( + List axisPositions, + AxisSide side, + ) { + final chartSize = Size( + widget.parentSize.width - thisSidePaddingTotal, + widget.parentSize.height - thisSidePaddingTotal, + ); + // Add 1 pixel to the chart's edges to avoid clipping the last title. + final chartRect = (Offset.zero & chartSize).inflate(1); + + return axisPositions.where((metaData) { + final location = metaData.axisPixelLocation; + return switch (side) { + AxisSide.left || + AxisSide.right => + chartRect.contains(Offset(0, location)), + AxisSide.top || + AxisSide.bottom => + chartRect.contains(Offset(location, 0)), + }; + }).toList(); + } + @override Widget build(BuildContext context) { if (!axisTitles.showAxisTitles && !axisTitles.showSideTitles) { return Container(); } - final axisViewSize = isHorizontal ? parentSize.width : parentSize.height; + + final axisViewSize = isHorizontal ? viewSize.width : viewSize.height; return Align( alignment: alignment, child: Flex( @@ -181,7 +242,7 @@ class SideTitlesWidget extends StatelessWidget { if (isLeftOrTop && axisTitles.axisNameWidget != null) _AxisTitleWidget( axisTitles: axisTitles, - side: side, + side: widget.side, axisViewSize: axisViewSize, ), if (sideTitles.showTitles) @@ -200,14 +261,14 @@ class SideTitlesWidget extends StatelessWidget { axisViewSize - thisSidePaddingTotal, axisMin, axisMax, - side, + widget.side, ), ), ), if (isRightOrBottom && axisTitles.axisNameWidget != null) _AxisTitleWidget( axisTitles: axisTitles, - side: side, + side: widget.side, axisViewSize: axisViewSize, ), ], diff --git a/lib/src/chart/base/axis_chart/transformation_config.dart b/lib/src/chart/base/axis_chart/transformation_config.dart new file mode 100644 index 000000000..824a41507 --- /dev/null +++ b/lib/src/chart/base/axis_chart/transformation_config.dart @@ -0,0 +1,38 @@ +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; +import 'package:flutter/widgets.dart'; + +/// Configuration for the transformation of an axis-based chart. +class FlTransformationConfig { + const FlTransformationConfig({ + this.scaleAxis = FlScaleAxis.none, + this.minScale = 1, + this.maxScale = 2.5, + this.trackpadScrollCausesScale = false, + this.transformationController, + }) : assert(minScale >= 1, 'minScale must be greater than or equal to 1'), + assert( + maxScale >= minScale, + 'maxScale must be greater than or equal to minScale', + ); + + /// Determines what axis of the chart should be scaled. + final FlScaleAxis scaleAxis; + + /// The minimum scale of the chart. + /// + /// Ignored when [scaleAxis] is [FlScaleAxis.none]. + final double minScale; + + /// The maximum scale of the chart. + /// + /// Ignored when [scaleAxis] is [FlScaleAxis.none]. + final double maxScale; + + /// Whether trackpad scroll causes scale. + /// + /// Ignored when [scaleAxis] is [FlScaleAxis.none]. + final bool trackpadScrollCausesScale; + + /// The transformation controller to control the transformation of the chart. + final TransformationController? transformationController; +} diff --git a/lib/src/chart/base/base_chart/base_chart_painter.dart b/lib/src/chart/base/base_chart/base_chart_painter.dart index 3e3d1ee95..09aba8ab5 100644 --- a/lib/src/chart/base/base_chart/base_chart_painter.dart +++ b/lib/src/chart/base/base_chart/base_chart_painter.dart @@ -19,7 +19,12 @@ class BaseChartPainter { /// Holds data for painting on canvas class PaintHolder { /// Holds data for painting on canvas - const PaintHolder(this.data, this.targetData, this.textScaler); + const PaintHolder( + this.data, + this.targetData, + this.textScaler, [ + this.chartVirtualRect, + ]); /// [data] is what we need to show frame by frame (it might be changed by an animator) final Data data; @@ -29,4 +34,27 @@ class PaintHolder { /// system [TextScaler] used for scaling texts for better readability final TextScaler textScaler; + + /// The virtual rect representing the chart when it is scaled or panned. + /// + /// The chart will be drawn in this virtual canvas, and then clipped to the + /// actual canvas. + /// + /// When the chart is scaled, the virtual canvas will be larger than the + /// actual canvas. This will lead to the content being laid out on the larger + /// area. Thus resulting in the scaling effect. + /// + /// Null when not scaling or panning. + final Rect? chartVirtualRect; + + /// Returns the size of the chart that is actually being painted. + /// + /// When scaling the chart, the chart is painted on a larger area to simulate + /// the zoom effect. This function returns the size of the area that is + /// actually being painted. + /// + /// When not scaled it returns the actual size of the chart. + Size getChartUsableSize(Size viewSize) { + return chartVirtualRect?.size ?? viewSize; + } } diff --git a/lib/src/chart/base/base_chart/render_base_chart.dart b/lib/src/chart/base/base_chart/render_base_chart.dart index 7652cc6d4..fbd34ab84 100644 --- a/lib/src/chart/base/base_chart/render_base_chart.dart +++ b/lib/src/chart/base/base_chart/render_base_chart.dart @@ -11,12 +11,24 @@ abstract class RenderBaseChart extends RenderBox implements MouseTrackerAnnotation { /// We use [FlTouchData] to retrieve [FlTouchData.touchCallback] and [FlTouchData.mouseCursorResolver] /// to invoke them when touch happens. - RenderBaseChart(FlTouchData? touchData, BuildContext context) - : _buildContext = context { + RenderBaseChart( + FlTouchData? touchData, + BuildContext context, { + required bool canBeScaled, + }) : _canBeScaled = canBeScaled, + _buildContext = context { updateBaseTouchData(touchData); initGestureRecognizers(); } + bool get canBeScaled => _canBeScaled; + bool _canBeScaled; + set canBeScaled(bool value) { + if (_canBeScaled == value) return; + _canBeScaled = value; + markNeedsPaint(); + } + // We use buildContext to retrieve Theme data BuildContext get buildContext => _buildContext; BuildContext _buildContext; @@ -40,18 +52,21 @@ abstract class RenderBaseChart extends RenderBox late bool _validForMouseTracker; /// Recognizes pan gestures, such as onDown, onStart, onUpdate, onCancel, ... - late PanGestureRecognizer _panGestureRecognizer; + @visibleForTesting + late PanGestureRecognizer panGestureRecognizer; /// Recognizes tap gestures, such as onTapDown, onTapCancel and onTapUp - late TapGestureRecognizer _tapGestureRecognizer; + @visibleForTesting + late TapGestureRecognizer tapGestureRecognizer; /// Recognizes longPress gestures, such as onLongPressStart, onLongPressMoveUpdate and onLongPressEnd - late LongPressGestureRecognizer _longPressGestureRecognizer; + @visibleForTesting + late LongPressGestureRecognizer longPressGestureRecognizer; /// Initializes our recognizers and implement their callbacks. void initGestureRecognizers() { - _panGestureRecognizer = PanGestureRecognizer(); - _panGestureRecognizer + panGestureRecognizer = PanGestureRecognizer(); + panGestureRecognizer ..onDown = (dragDownDetails) { _notifyTouchEvent(FlPanDownEvent(dragDownDetails)); } @@ -68,8 +83,8 @@ abstract class RenderBaseChart extends RenderBox _notifyTouchEvent(FlPanEndEvent(dragEndDetails)); }; - _tapGestureRecognizer = TapGestureRecognizer(); - _tapGestureRecognizer + tapGestureRecognizer = TapGestureRecognizer(); + tapGestureRecognizer ..onTapDown = (tapDownDetails) { _notifyTouchEvent(FlTapDownEvent(tapDownDetails)); } @@ -80,9 +95,9 @@ abstract class RenderBaseChart extends RenderBox _notifyTouchEvent(FlTapUpEvent(tapUpDetails)); }; - _longPressGestureRecognizer = + longPressGestureRecognizer = LongPressGestureRecognizer(duration: _longPressDuration); - _longPressGestureRecognizer + longPressGestureRecognizer ..onLongPressStart = (longPressStartDetails) { _notifyTouchEvent(FlLongPressStart(longPressStartDetails)); } @@ -122,9 +137,11 @@ abstract class RenderBaseChart extends RenderBox return; } if (event is PointerDownEvent) { - _longPressGestureRecognizer.addPointer(event); - _tapGestureRecognizer.addPointer(event); - _panGestureRecognizer.addPointer(event); + longPressGestureRecognizer.addPointer(event); + tapGestureRecognizer.addPointer(event); + if (!canBeScaled) { + panGestureRecognizer.addPointer(event); + } } else if (event is PointerHoverEvent) { _notifyTouchEvent(FlPointerHoverEvent(event)); } diff --git a/lib/src/chart/base/custom_interactive_viewer.dart b/lib/src/chart/base/custom_interactive_viewer.dart new file mode 100644 index 000000000..7fddaba5c --- /dev/null +++ b/lib/src/chart/base/custom_interactive_viewer.dart @@ -0,0 +1,1179 @@ +// coverage:ignore-file +// This file is copied from Flutter's InteractiveViewer widget. +// The only change is that the child is not wrapped in a `Transform` so +// we can react to the transformation ourselves. +// +// This should be removed once the official InteractiveViewer allows to disable +// the Transform widget. + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3; + +typedef CustomInteractiveViewerWidgetBuilder = Widget Function( + BuildContext context, + Quad viewport, +); + +@immutable +class CustomInteractiveViewer extends StatefulWidget { + CustomInteractiveViewer({ + super.key, + this.clipBehavior = Clip.hardEdge, + this.panAxis = PanAxis.free, + this.boundaryMargin = EdgeInsets.zero, + this.constrained = true, + this.maxScale = 2.5, + this.minScale = 0.8, + this.interactionEndFrictionCoefficient = _kDrag, + this.onInteractionEnd, + this.onInteractionStart, + this.onInteractionUpdate, + this.panEnabled = true, + this.scaleEnabled = true, + this.scaleFactor = kDefaultMouseScrollToScaleFactor, + this.transformationController, + this.alignment, + this.trackpadScrollCausesScale = false, + required this.child, + }) : assert(minScale > 0), + assert(interactionEndFrictionCoefficient > 0), + assert(minScale.isFinite), + assert(maxScale > 0), + assert(!maxScale.isNaN), + assert(maxScale >= minScale), + assert( + (boundaryMargin.horizontal.isInfinite && + boundaryMargin.vertical.isInfinite) || + (boundaryMargin.top.isFinite && + boundaryMargin.right.isFinite && + boundaryMargin.bottom.isFinite && + boundaryMargin.left.isFinite), + ), + builder = null; + + CustomInteractiveViewer.builder({ + super.key, + this.clipBehavior = Clip.hardEdge, + this.panAxis = PanAxis.free, + this.boundaryMargin = EdgeInsets.zero, + this.maxScale = 2.5, + this.minScale = 0.8, + this.interactionEndFrictionCoefficient = _kDrag, + this.onInteractionEnd, + this.onInteractionStart, + this.onInteractionUpdate, + this.panEnabled = true, + this.scaleEnabled = true, + this.scaleFactor = 200.0, + this.transformationController, + this.alignment, + this.trackpadScrollCausesScale = false, + required CustomInteractiveViewerWidgetBuilder this.builder, + }) : assert(minScale > 0), + assert(interactionEndFrictionCoefficient > 0), + assert(minScale.isFinite), + assert(maxScale > 0), + assert(!maxScale.isNaN), + assert(maxScale >= minScale), + assert( + (boundaryMargin.horizontal.isInfinite && + boundaryMargin.vertical.isInfinite) || + (boundaryMargin.top.isFinite && + boundaryMargin.right.isFinite && + boundaryMargin.bottom.isFinite && + boundaryMargin.left.isFinite), + ), + constrained = false, + child = null; + + final Alignment? alignment; + final Clip clipBehavior; + + final PanAxis panAxis; + + final EdgeInsets boundaryMargin; + + final CustomInteractiveViewerWidgetBuilder? builder; + + final Widget? child; + + final bool constrained; + + final bool panEnabled; + + final bool scaleEnabled; + + final bool trackpadScrollCausesScale; + + final double scaleFactor; + + final double maxScale; + + final double minScale; + + final double interactionEndFrictionCoefficient; + + final GestureScaleEndCallback? onInteractionEnd; + + final GestureScaleStartCallback? onInteractionStart; + + final GestureScaleUpdateCallback? onInteractionUpdate; + + final TransformationController? transformationController; + + static const double _kDrag = 0.0000135; + + /// Returns the closest point to the given point on the given line segment. + @visibleForTesting + static Vector3 getNearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) { + final lengthSquared = math.pow(l2.x - l1.x, 2.0).toDouble() + + math.pow(l2.y - l1.y, 2.0).toDouble(); + + // In this case, l1 == l2. + if (lengthSquared == 0) { + return l1; + } + + // Calculate how far down the line segment the closest point is and return + // the point. + final l1P = point - l1; + final l1L2 = l2 - l1; + final fraction = clampDouble(l1P.dot(l1L2) / lengthSquared, 0, 1); + return l1 + l1L2 * fraction; + } + + /// Returns the axis aligned bounding box for the given Quad, which might not + /// be axis aligned. + static Rect axisAlignedBoundingBox(Quad quad) { + var xMin = quad.point0.x; + var xMax = quad.point0.x; + var yMin = quad.point0.y; + var yMax = quad.point0.y; + for (final point in [ + quad.point1, + quad.point2, + quad.point3, + ]) { + if (point.x < xMin) { + xMin = point.x; + } else if (point.x > xMax) { + xMax = point.x; + } + + if (point.y < yMin) { + yMin = point.y; + } else if (point.y > yMax) { + yMax = point.y; + } + } + + return Rect.fromLTRB(xMin, yMin, xMax, yMax); + } + + /// Given a quad, return its axis aligned bounding box. + @visibleForTesting + static Quad getAxisAlignedBoundingBox(Quad quad) { + final double minX = math.min( + quad.point0.x, + math.min( + quad.point1.x, + math.min( + quad.point2.x, + quad.point3.x, + ), + ), + ); + final double minY = math.min( + quad.point0.y, + math.min( + quad.point1.y, + math.min( + quad.point2.y, + quad.point3.y, + ), + ), + ); + final double maxX = math.max( + quad.point0.x, + math.max( + quad.point1.x, + math.max( + quad.point2.x, + quad.point3.x, + ), + ), + ); + final double maxY = math.max( + quad.point0.y, + math.max( + quad.point1.y, + math.max( + quad.point2.y, + quad.point3.y, + ), + ), + ); + return Quad.points( + Vector3(minX, minY, 0), + Vector3(maxX, minY, 0), + Vector3(maxX, maxY, 0), + Vector3(minX, maxY, 0), + ); + } + + /// Returns true iff the point is inside the rectangle given by the Quad, + /// inclusively. + /// Algorithm from https://math.stackexchange.com/a/190373. + @visibleForTesting + static bool pointIsInside(Vector3 point, Quad quad) { + final aM = point - quad.point0; + final aB = quad.point1 - quad.point0; + final aD = quad.point3 - quad.point0; + + final aMAB = aM.dot(aB); + final aBAB = aB.dot(aB); + final aMAD = aM.dot(aD); + final aDAD = aD.dot(aD); + + return 0 <= aMAB && aMAB <= aBAB && 0 <= aMAD && aMAD <= aDAD; + } + + /// Get the point inside (inclusively) the given Quad that is nearest to the + /// given Vector3. + @visibleForTesting + static Vector3 getNearestPointInside(Vector3 point, Quad quad) { + // If the point is inside the axis aligned bounding box, then it's ok where + // it is. + if (pointIsInside(point, quad)) { + return point; + } + + // Otherwise, return the nearest point on the quad. + final closestPoints = [ + CustomInteractiveViewer.getNearestPointOnLine( + point, + quad.point0, + quad.point1, + ), + CustomInteractiveViewer.getNearestPointOnLine( + point, + quad.point1, + quad.point2, + ), + CustomInteractiveViewer.getNearestPointOnLine( + point, + quad.point2, + quad.point3, + ), + CustomInteractiveViewer.getNearestPointOnLine( + point, + quad.point3, + quad.point0, + ), + ]; + var minDistance = double.infinity; + late Vector3 closestOverall; + for (final closePoint in closestPoints) { + final distance = math.sqrt( + math.pow(point.x - closePoint.x, 2) + + math.pow(point.y - closePoint.y, 2), + ); + if (distance < minDistance) { + minDistance = distance; + closestOverall = closePoint; + } + } + return closestOverall; + } + + /// Transform the four corners of the viewport by the inverse of the given + /// matrix. This gives the viewport after the child has been transformed by the + /// given matrix. The viewport transforms as the inverse of the child (i.e. + /// moving the child left is equivalent to moving the viewport right). + static Quad transformViewport(Matrix4 matrix, Rect viewport) { + final inverseMatrix = matrix.clone()..invert(); + return Quad.points( + inverseMatrix.transform3( + Vector3( + viewport.topLeft.dx, + viewport.topLeft.dy, + 0, + ), + ), + inverseMatrix.transform3( + Vector3( + viewport.topRight.dx, + viewport.topRight.dy, + 0, + ), + ), + inverseMatrix.transform3( + Vector3( + viewport.bottomRight.dx, + viewport.bottomRight.dy, + 0, + ), + ), + inverseMatrix.transform3( + Vector3( + viewport.bottomLeft.dx, + viewport.bottomLeft.dy, + 0, + ), + ), + ); + } + + @override + State createState() => + _CustomInteractiveViewerState(); +} + +class _CustomInteractiveViewerState extends State + with TickerProviderStateMixin { + TransformationController? _transformationController; + + final GlobalKey _childKey = GlobalKey(); + final GlobalKey _parentKey = GlobalKey(); + Animation? _animation; + Animation? _scaleAnimation; + late Offset _scaleAnimationFocalPoint; + late AnimationController _controller; + late AnimationController _scaleController; + Axis? _currentAxis; // Used with panAxis. + Offset? _referenceFocalPoint; // Point where the current gesture began. + double? _scaleStart; // Scale value at start of scaling gesture. + final double _currentRotation = + 0; // Rotation of _transformationController.value. + _GestureType? _gestureType; + + // The _boundaryRect is calculated by adding the boundaryMargin to the size of + // the child. + Rect get _boundaryRect { + assert(_childKey.currentContext != null); + assert(!widget.boundaryMargin.left.isNaN); + assert(!widget.boundaryMargin.right.isNaN); + assert(!widget.boundaryMargin.top.isNaN); + assert(!widget.boundaryMargin.bottom.isNaN); + + final childRenderBox = + _childKey.currentContext!.findRenderObject()! as RenderBox; + final childSize = childRenderBox.size; + final boundaryRect = + widget.boundaryMargin.inflateRect(Offset.zero & childSize); + assert( + !boundaryRect.isEmpty, + "CustomInteractiveViewer's child must have nonzero dimensions.", + ); + // Boundaries that are partially infinite are not allowed because Matrix4's + // rotation and translation methods don't handle infinites well. + assert( + boundaryRect.isFinite || + (boundaryRect.left.isInfinite && + boundaryRect.top.isInfinite && + boundaryRect.right.isInfinite && + boundaryRect.bottom.isInfinite), + 'boundaryRect must either be infinite in all directions or finite in all directions.', + ); + return boundaryRect; + } + + // The Rect representing the child's parent. + Rect get _viewport { + assert(_parentKey.currentContext != null); + final parentRenderBox = + _parentKey.currentContext!.findRenderObject()! as RenderBox; + return Offset.zero & parentRenderBox.size; + } + + // Return a new matrix representing the given matrix after applying the given + // translation. + Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { + if (translation == Offset.zero) { + return matrix.clone(); + } + + final Offset alignedTranslation; + + if (_currentAxis != null) { + alignedTranslation = switch (widget.panAxis) { + PanAxis.horizontal => _alignAxis(translation, Axis.horizontal), + PanAxis.vertical => _alignAxis(translation, Axis.vertical), + PanAxis.aligned => _alignAxis(translation, _currentAxis!), + PanAxis.free => translation, + }; + } else { + alignedTranslation = translation; + } + + final nextMatrix = matrix.clone() + ..translate( + alignedTranslation.dx, + alignedTranslation.dy, + ); + + // Transform the viewport to determine where its four corners will be after + // the child has been transformed. + final nextViewport = CustomInteractiveViewer.transformViewport( + nextMatrix, + _viewport, + ); + + // If the boundaries are infinite, then no need to check if the translation + // fits within them. + if (_boundaryRect.isInfinite) { + return nextMatrix; + } + + // Expand the boundaries with rotation. This prevents the problem where a + // mismatch in orientation between the viewport and boundaries effectively + // limits translation. With this approach, all points that are visible with + // no rotation are visible after rotation. + final boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation( + _boundaryRect, + _currentRotation, + ); + + // If the given translation fits completely within the boundaries, allow it. + final offendingDistance = _exceedsBy(boundariesAabbQuad, nextViewport); + if (offendingDistance == Offset.zero) { + return nextMatrix; + } + + // Desired translation goes out of bounds, so translate to the nearest + // in-bounds point instead. + final nextTotalTranslation = _getMatrixTranslation(nextMatrix); + final currentScale = matrix.getMaxScaleOnAxis(); + final correctedTotalTranslation = Offset( + nextTotalTranslation.dx - offendingDistance.dx * currentScale, + nextTotalTranslation.dy - offendingDistance.dy * currentScale, + ); + + final correctedMatrix = matrix.clone() + ..setTranslation( + Vector3( + correctedTotalTranslation.dx, + correctedTotalTranslation.dy, + 0, + ), + ); + + // Double check that the corrected translation fits. + final correctedViewport = CustomInteractiveViewer.transformViewport( + correctedMatrix, + _viewport, + ); + final offendingCorrectedDistance = + _exceedsBy(boundariesAabbQuad, correctedViewport); + if (offendingCorrectedDistance == Offset.zero) { + return correctedMatrix; + } + + // If the corrected translation doesn't fit in either direction, don't allow + // any translation at all. This happens when the viewport is larger than the + // entire boundary. + if (offendingCorrectedDistance.dx != 0.0 && + offendingCorrectedDistance.dy != 0.0) { + return matrix.clone(); + } + + // Otherwise, allow translation in only the direction that fits. This + // happens when the viewport is larger than the boundary in one direction. + final unidirectionalCorrectedTotalTranslation = Offset( + offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0, + offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0, + ); + return matrix.clone() + ..setTranslation( + Vector3( + unidirectionalCorrectedTotalTranslation.dx, + unidirectionalCorrectedTotalTranslation.dy, + 0, + ), + ); + } + + // Return a new matrix representing the given matrix after applying the given + // scale. + Matrix4 _matrixScale(Matrix4 matrix, double scale) { + if (scale == 1.0) { + return matrix.clone(); + } + assert(scale != 0.0); + + // Don't allow a scale that results in an overall scale beyond min/max + // scale. + final currentScale = _transformationController!.value.getMaxScaleOnAxis(); + final double totalScale = math.max( + currentScale * scale, + // Ensure that the scale cannot make the child so big that it can't fit + // inside the boundaries (in either direction). + math.max( + _viewport.width / _boundaryRect.width, + _viewport.height / _boundaryRect.height, + ), + ); + final clampedTotalScale = clampDouble( + totalScale, + widget.minScale, + widget.maxScale, + ); + final clampedScale = clampedTotalScale / currentScale; + return matrix.clone()..scale(clampedScale); + } + + // Returns true iff the given _GestureType is enabled. + bool _gestureIsSupported(_GestureType? gestureType) { + return switch (gestureType) { + _GestureType.scale => widget.scaleEnabled, + _GestureType.pan || null => widget.panEnabled, + }; + } + + // Decide which type of gesture this is by comparing the amount of scale + // and rotation in the gesture, if any. Scale starts at 1 and rotation + // starts at 0. Pan will have no scale and no rotation because it uses only one + // finger. + _GestureType _getGestureType(ScaleUpdateDetails details) { + final scale = !widget.scaleEnabled ? 1.0 : details.scale; + if (scale != 1) { + return _GestureType.scale; + } else { + return _GestureType.pan; + } + } + + // Handle the start of a gesture. All of pan, scale, and rotate are handled + // with GestureDetector's scale gesture. + void _onScaleStart(ScaleStartDetails details) { + widget.onInteractionStart?.call(details); + + if (_controller.isAnimating) { + _controller + ..stop() + ..reset(); + _animation?.removeListener(_onAnimate); + _animation = null; + } + if (_scaleController.isAnimating) { + _scaleController + ..stop() + ..reset(); + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + } + + _gestureType = null; + _currentAxis = null; + _scaleStart = _transformationController!.value.getMaxScaleOnAxis(); + _referenceFocalPoint = _transformationController!.toScene( + details.localFocalPoint, + ); + } + + // Handle an update to an ongoing gesture. All of pan, scale, and rotate are + // handled with GestureDetector's scale gesture. + void _onScaleUpdate(ScaleUpdateDetails details) { + final scale = _transformationController!.value.getMaxScaleOnAxis(); + _scaleAnimationFocalPoint = details.localFocalPoint; + final focalPointScene = _transformationController!.toScene( + details.localFocalPoint, + ); + + if (_gestureType == _GestureType.pan) { + // When a gesture first starts, it sometimes has no change in scale and + // rotation despite being a two-finger gesture. Here the gesture is + // allowed to be reinterpreted as its correct type after originally + // being marked as a pan. + _gestureType = _getGestureType(details); + } else { + _gestureType ??= _getGestureType(details); + } + if (!_gestureIsSupported(_gestureType)) { + widget.onInteractionUpdate?.call(details); + return; + } + + switch (_gestureType!) { + case _GestureType.scale: + assert(_scaleStart != null); + // details.scale gives us the amount to change the scale as of the + // start of this gesture, so calculate the amount to scale as of the + // previous call to _onScaleUpdate. + final desiredScale = _scaleStart! * details.scale; + final scaleChange = desiredScale / scale; + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // While scaling, translate such that the user's two fingers stay on + // the same places in the scene. That means that the focal point of + // the scale should be on the same place in the scene before and after + // the scale. + final focalPointSceneScaled = _transformationController!.toScene( + details.localFocalPoint, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - _referenceFocalPoint!, + ); + + // details.localFocalPoint should now be at the same location as the + // original _referenceFocalPoint point. If it's not, that's because + // the translate came in contact with a boundary. In that case, update + // _referenceFocalPoint so subsequent updates happen in relation to + // the new effective focal point. + final focalPointSceneCheck = _transformationController!.toScene( + details.localFocalPoint, + ); + if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) { + _referenceFocalPoint = focalPointSceneCheck; + } + + case _GestureType.pan: + assert(_referenceFocalPoint != null); + // details may have a change in scale here when scaleEnabled is false. + // In an effort to keep the behavior similar whether or not scaleEnabled + // is true, these gestures are thrown away. + if (details.scale != 1.0) { + widget.onInteractionUpdate?.call(details); + return; + } + _currentAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); + // Translate so that the same point in the scene is underneath the + // focal point before and after the movement. + final translationChange = focalPointScene - _referenceFocalPoint!; + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + translationChange, + ); + _referenceFocalPoint = _transformationController!.toScene( + details.localFocalPoint, + ); + } + widget.onInteractionUpdate?.call(details); + } + + // Handle the end of a gesture of _GestureType. All of pan, scale, and rotate + // are handled with GestureDetector's scale gesture. + void _onScaleEnd(ScaleEndDetails details) { + widget.onInteractionEnd?.call(details); + _scaleStart = null; + _referenceFocalPoint = null; + + _animation?.removeListener(_onAnimate); + _scaleAnimation?.removeListener(_onScaleAnimate); + _controller.reset(); + _scaleController.reset(); + + if (!_gestureIsSupported(_gestureType)) { + _currentAxis = null; + return; + } + + switch (_gestureType) { + case _GestureType.pan: + if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { + _currentAxis = null; + return; + } + final translationVector = + _transformationController!.value.getTranslation(); + final translation = Offset(translationVector.x, translationVector.y); + final frictionSimulationX = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dx, + details.velocity.pixelsPerSecond.dx, + ); + final frictionSimulationY = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dy, + details.velocity.pixelsPerSecond.dy, + ); + final tFinal = _getFinalTime( + details.velocity.pixelsPerSecond.distance, + widget.interactionEndFrictionCoefficient, + ); + _animation = Tween( + begin: translation, + end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.decelerate, + ), + ); + _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); + _animation!.addListener(_onAnimate); + _controller.forward(); + case _GestureType.scale: + if (details.scaleVelocity.abs() < 0.1) { + _currentAxis = null; + return; + } + final scale = _transformationController!.value.getMaxScaleOnAxis(); + final frictionSimulation = FrictionSimulation( + widget.interactionEndFrictionCoefficient * widget.scaleFactor, + scale, + details.scaleVelocity / 10, + ); + final tFinal = _getFinalTime( + details.scaleVelocity.abs(), + widget.interactionEndFrictionCoefficient, + effectivelyMotionless: 0.1, + ); + _scaleAnimation = + Tween(begin: scale, end: frictionSimulation.x(tFinal)) + .animate( + CurvedAnimation( + parent: _scaleController, + curve: Curves.decelerate, + ), + ); + _scaleController.duration = + Duration(milliseconds: (tFinal * 1000).round()); + _scaleAnimation!.addListener(_onScaleAnimate); + _scaleController.forward(); + case null: + break; + } + } + + // Handle mousewheel and web trackpad scroll events. + void _receivedPointerSignal(PointerSignalEvent event) { + final double scaleChange; + if (event is PointerScrollEvent) { + if (event.kind == PointerDeviceKind.trackpad && + !widget.trackpadScrollCausesScale) { + // Trackpad scroll, so treat it as a pan. + widget.onInteractionStart?.call( + ScaleStartDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + ), + ); + + final localDelta = PointerEvent.transformDeltaViaPositions( + untransformedEndPosition: event.position + event.scrollDelta, + untransformedDelta: event.scrollDelta, + transform: event.transform, + ); + + if (!_gestureIsSupported(_GestureType.pan)) { + widget.onInteractionUpdate?.call( + ScaleUpdateDetails( + focalPoint: event.position - event.scrollDelta, + localFocalPoint: event.localPosition - event.scrollDelta, + focalPointDelta: -localDelta, + ), + ); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + + final focalPointScene = _transformationController!.toScene( + event.localPosition, + ); + + final newFocalPointScene = _transformationController!.toScene( + event.localPosition - localDelta, + ); + + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + newFocalPointScene - focalPointScene, + ); + + widget.onInteractionUpdate?.call( + ScaleUpdateDetails( + focalPoint: event.position - event.scrollDelta, + localFocalPoint: event.localPosition - localDelta, + focalPointDelta: -localDelta, + ), + ); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + // Ignore left and right mouse wheel scroll. + if (event.scrollDelta.dy == 0.0) { + return; + } + scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor); + } else if (event is PointerScaleEvent) { + scaleChange = event.scale; + } else { + return; + } + widget.onInteractionStart?.call( + ScaleStartDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + ), + ); + + if (!_gestureIsSupported(_GestureType.scale)) { + widget.onInteractionUpdate?.call( + ScaleUpdateDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + scale: scaleChange, + ), + ); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + + final focalPointScene = _transformationController!.toScene( + event.localPosition, + ); + + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // After scaling, translate such that the event's position is at the + // same scene point before and after the scale. + final focalPointSceneScaled = _transformationController!.toScene( + event.localPosition, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - focalPointScene, + ); + + widget.onInteractionUpdate?.call( + ScaleUpdateDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + scale: scaleChange, + ), + ); + widget.onInteractionEnd?.call(ScaleEndDetails()); + } + + // Handle inertia drag animation. + void _onAnimate() { + if (!_controller.isAnimating) { + _currentAxis = null; + _animation?.removeListener(_onAnimate); + _animation = null; + _controller.reset(); + return; + } + // Translate such that the resulting translation is _animation.value. + final translationVector = _transformationController!.value.getTranslation(); + final translation = Offset(translationVector.x, translationVector.y); + final translationScene = _transformationController!.toScene( + translation, + ); + final animationScene = _transformationController!.toScene( + _animation!.value, + ); + final translationChangeScene = animationScene - translationScene; + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + translationChangeScene, + ); + } + + // Handle inertia scale animation. + void _onScaleAnimate() { + if (!_scaleController.isAnimating) { + _currentAxis = null; + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + _scaleController.reset(); + return; + } + final desiredScale = _scaleAnimation!.value; + final scaleChange = + desiredScale / _transformationController!.value.getMaxScaleOnAxis(); + final referenceFocalPoint = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // While scaling, translate such that the user's two fingers stay on + // the same places in the scene. That means that the focal point of + // the scale should be on the same place in the scene before and after + // the scale. + final focalPointSceneScaled = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - referenceFocalPoint, + ); + } + + void _onTransformationControllerChange() { + // A change to the TransformationController's value is a change to the + // state. + setState(() {}); + } + + @override + void initState() { + super.initState(); + + _transformationController = + widget.transformationController ?? TransformationController(); + _transformationController!.addListener(_onTransformationControllerChange); + _controller = AnimationController( + vsync: this, + ); + _scaleController = AnimationController(vsync: this); + } + + @override + void didUpdateWidget(CustomInteractiveViewer oldWidget) { + super.didUpdateWidget(oldWidget); + // Handle all cases of needing to dispose and initialize + // transformationControllers. + if (oldWidget.transformationController == null) { + if (widget.transformationController != null) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController!.dispose(); + _transformationController = widget.transformationController; + _transformationController! + .addListener(_onTransformationControllerChange); + } + } else { + if (widget.transformationController == null) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController = TransformationController(); + _transformationController! + .addListener(_onTransformationControllerChange); + } else if (widget.transformationController != + oldWidget.transformationController) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController = widget.transformationController; + _transformationController! + .addListener(_onTransformationControllerChange); + } + } + } + + @override + void dispose() { + _controller.dispose(); + _scaleController.dispose(); + _transformationController! + .removeListener(_onTransformationControllerChange); + if (widget.transformationController == null) { + _transformationController!.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (widget.child != null) { + child = _CustomInteractiveViewerBuilt( + childKey: _childKey, + clipBehavior: widget.clipBehavior, + constrained: widget.constrained, + matrix: _transformationController!.value, + alignment: widget.alignment, + child: widget.child!, + ); + } else { + // When using CustomInteractiveViewer.builder, then constrained is false and the + // viewport is the size of the constraints. + assert(widget.builder != null); + assert(!widget.constrained); + child = LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final matrix = _transformationController!.value; + return _CustomInteractiveViewerBuilt( + childKey: _childKey, + clipBehavior: widget.clipBehavior, + constrained: widget.constrained, + alignment: widget.alignment, + matrix: matrix, + child: widget.builder!( + context, + CustomInteractiveViewer.transformViewport( + matrix, + Offset.zero & constraints.biggest, + ), + ), + ); + }, + ); + } + + return Listener( + key: _parentKey, + onPointerSignal: _receivedPointerSignal, + child: GestureDetector( + behavior: HitTestBehavior.opaque, // Necessary when panning off screen. + onScaleEnd: _onScaleEnd, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + trackpadScrollCausesScale: widget.trackpadScrollCausesScale, + trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor), + child: child, + ), + ); + } +} + +// This widget allows us to easily swap in and out the LayoutBuilder in +// CustomInteractiveViewer's depending on if it's using a builder or a child. +class _CustomInteractiveViewerBuilt extends StatelessWidget { + const _CustomInteractiveViewerBuilt({ + required this.child, + required this.childKey, + required this.clipBehavior, + required this.constrained, + required this.matrix, + required this.alignment, + }); + + final Widget child; + final GlobalKey childKey; + final Clip clipBehavior; + final bool constrained; + final Matrix4 matrix; + final Alignment? alignment; + + @override + Widget build(BuildContext context) { + Widget child = KeyedSubtree( + key: childKey, + child: this.child, + ); + + if (!constrained) { + child = OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0, + minHeight: 0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: child, + ); + } + + return ClipRect( + clipBehavior: clipBehavior, + child: child, + ); + } +} + +// A classification of relevant user gestures. Each contiguous user gesture is +// represented by exactly one _GestureType. +enum _GestureType { + pan, + scale, +} + +// Given a velocity and drag, calculate the time at which motion will come to +// a stop, within the margin of effectivelyMotionless. +double _getFinalTime( + double velocity, + double drag, { + double effectivelyMotionless = 10, +}) { + return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); +} + +// Return the translation from the given Matrix4 as an Offset. +Offset _getMatrixTranslation(Matrix4 matrix) { + final nextTranslation = matrix.getTranslation(); + return Offset(nextTranslation.x, nextTranslation.y); +} + +// Find the axis aligned bounding box for the rect rotated about its center by +// the given amount. +Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) { + final rotationMatrix = Matrix4.identity() + ..translate(rect.size.width / 2, rect.size.height / 2) + ..rotateZ(rotation) + ..translate(-rect.size.width / 2, -rect.size.height / 2); + final boundariesRotated = Quad.points( + rotationMatrix.transform3(Vector3(rect.left, rect.top, 0)), + rotationMatrix.transform3(Vector3(rect.right, rect.top, 0)), + rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0)), + rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0)), + ); + return CustomInteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated); +} + +// Return the amount that viewport lies outside of boundary. If the viewport +// is completely contained within the boundary (inclusively), then returns +// Offset.zero. +Offset _exceedsBy(Quad boundary, Quad viewport) { + final viewportPoints = [ + viewport.point0, + viewport.point1, + viewport.point2, + viewport.point3, + ]; + var largestExcess = Offset.zero; + for (final point in viewportPoints) { + final pointInside = + CustomInteractiveViewer.getNearestPointInside(point, boundary); + final excess = Offset( + pointInside.x - point.x, + pointInside.y - point.y, + ); + if (excess.dx.abs() > largestExcess.dx.abs()) { + largestExcess = Offset(excess.dx, largestExcess.dy); + } + if (excess.dy.abs() > largestExcess.dy.abs()) { + largestExcess = Offset(largestExcess.dx, excess.dy); + } + } + + return _round(largestExcess); +} + +// Round the output values. This works around a precision problem where +// values that should have been zero were given as within 10^-10 of zero. +Offset _round(Offset offset) { + return Offset( + double.parse(offset.dx.toStringAsFixed(9)), + double.parse(offset.dy.toStringAsFixed(9)), + ); +} + +// Align the given offset to the given axis by allowing movement only in the +// axis direction. +Offset _alignAxis(Offset offset, Axis axis) { + return switch (axis) { + Axis.horizontal => Offset(offset.dx, 0), + Axis.vertical => Offset(0, offset.dy), + }; +} + +// Given two points, return the axis where the distance between the points is +// greatest. If they are equal, return null. +Axis? _getPanAxis(Offset point1, Offset point2) { + if (point1 == point2) { + return null; + } + final x = point2.dx - point1.dx; + final y = point2.dy - point1.dy; + return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; +} diff --git a/lib/src/chart/line_chart/line_chart.dart b/lib/src/chart/line_chart/line_chart.dart index 2d4286946..5b46be03d 100644 --- a/lib/src/chart/line_chart/line_chart.dart +++ b/lib/src/chart/line_chart/line_chart.dart @@ -1,5 +1,9 @@ -import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart'; +import 'package:fl_chart/src/chart/base/base_chart/base_chart_data.dart'; +import 'package:fl_chart/src/chart/base/base_chart/fl_touch_event.dart'; +import 'package:fl_chart/src/chart/line_chart/line_chart_data.dart'; import 'package:fl_chart/src/chart/line_chart/line_chart_helper.dart'; import 'package:fl_chart/src/chart/line_chart/line_chart_renderer.dart'; import 'package:flutter/cupertino.dart'; @@ -18,11 +22,15 @@ class LineChart extends ImplicitlyAnimatedWidget { super.key, super.duration = const Duration(milliseconds: 150), super.curve = Curves.linear, + this.transformationConfig = const FlTransformationConfig(), }); /// Determines how the [LineChart] should be look like. final LineChartData data; + /// {@macro fl_chart.AxisChartScaffoldWidget.transformationConfig} + final FlTransformationConfig transformationConfig; + /// We pass this key to our renderers which are supposed to /// render the chart itself (without anything around the chart). final Key? chartRendererKey; @@ -52,10 +60,15 @@ class _LineChartState extends AnimatedWidgetBaseState { final showingData = _getData(); return AxisChartScaffoldWidget( - chart: LineChartLeaf( - data: _withTouchedIndicators(_lineChartDataTween!.evaluate(animation)), + transformationConfig: widget.transformationConfig, + chartBuilder: (context, chartVirtualRect) => LineChartLeaf( + data: _withTouchedIndicators( + _lineChartDataTween!.evaluate(animation), + ), targetData: _withTouchedIndicators(showingData), key: widget.chartRendererKey, + chartVirtualRect: chartVirtualRect, + canBeScaled: widget.transformationConfig.scaleAxis != FlScaleAxis.none, ), data: showingData, ); diff --git a/lib/src/chart/line_chart/line_chart_painter.dart b/lib/src/chart/line_chart/line_chart_painter.dart index 9030b6e0f..74782a0a8 100644 --- a/lib/src/chart/line_chart/line_chart_painter.dart +++ b/lib/src/chart/line_chart/line_chart_painter.dart @@ -45,6 +45,8 @@ class LineChartPainter extends AxisChartPainter { ..style = PaintingStyle.stroke ..color = Colors.transparent ..strokeWidth = 1.0; + + _clipPaint = Paint(); } late Paint _barPaint; late Paint _barAreaPaint; @@ -53,6 +55,7 @@ class LineChartPainter extends AxisChartPainter { late Paint _touchLinePaint; late Paint _bgTouchTooltipPaint; late Paint _borderTouchTooltipPaint; + late Paint _clipPaint; /// Paints [LineChartData] into the provided canvas. @override @@ -62,12 +65,20 @@ class LineChartPainter extends AxisChartPainter { PaintHolder holder, ) { final data = holder.data; + if (holder.chartVirtualRect != null) { + canvasWrapper + ..saveLayer( + Offset.zero & canvasWrapper.size, + _clipPaint, + ) + ..clipRect(Offset.zero & canvasWrapper.size); + } super.paint(context, canvasWrapper, holder); if (data.lineBarsData.isEmpty) { return; } - if (data.clipData.any) { + if (data.clipData.any && holder.chartVirtualRect == null) { canvasWrapper.saveLayer( Rect.fromLTWH( 0, @@ -75,7 +86,7 @@ class LineChartPainter extends AxisChartPainter { canvasWrapper.size.width + 40, canvasWrapper.size.height + 40, ), - Paint(), + _clipPaint, ); clipToBorder(canvasWrapper, holder); @@ -134,7 +145,7 @@ class LineChartPainter extends AxisChartPainter { drawTouchedSpotsIndicator(canvasWrapper, lineIndexDrawingInfo, holder); - if (data.clipData.any) { + if (data.clipData.any || holder.chartVirtualRect != null) { canvasWrapper.restore(); } @@ -207,7 +218,8 @@ class LineChartPainter extends AxisChartPainter { LineChartBarData barData, PaintHolder holder, ) { - final viewSize = canvasWrapper.size; + final viewSize = holder.getChartUsableSize(canvasWrapper.size); + final barList = barData.spots.splitByNullSpots(); // paint each sublist that was built above @@ -722,7 +734,7 @@ class LineChartPainter extends AxisChartPainter { if (barData.belowBarData.applyCutOffY) { canvasWrapper.saveLayer( Rect.fromLTWH(0, 0, viewSize.width, viewSize.height), - Paint(), + _clipPaint, ); } @@ -816,7 +828,7 @@ class LineChartPainter extends AxisChartPainter { if (barData.aboveBarData.applyCutOffY) { canvasWrapper.saveLayer( Rect.fromLTWH(0, 0, viewSize.width, viewSize.height), - Paint(), + _clipPaint, ); } @@ -895,7 +907,7 @@ class LineChartPainter extends AxisChartPainter { canvasWrapper ..saveLayer( Rect.fromLTWH(0, 0, viewSize.width, viewSize.height), - Paint(), + _clipPaint, ) ..drawPath(barPath, _barAreaPaint) ..restore(); // clear the above area that get out of the bar line @@ -989,6 +1001,13 @@ class LineChartPainter extends AxisChartPainter { const textsBelowMargin = 4; + // Get the dot height if available + final dotHeight = _getDotHeight( + viewSize: viewSize, + holder: holder, + showingTooltipSpots: showingTooltipSpots.showingSpots, + ); + /// creating TextPainters to calculate the width and height of the tooltip final drawingTextPainters = []; @@ -1047,6 +1066,14 @@ class LineChartPainter extends AxisChartPainter { getPixelY(showOnSpot.y, viewSize, holder), ); + // Create an extended boundary that includes the center of the dot + final extendedBoundary = (Offset.zero & viewSize).inflate(dotHeight / 2); + + final isZoomed = holder.chartVirtualRect != null; + if (isZoomed && !extendedBoundary.contains(mostTopOffset)) { + return; + } + final tooltipWidth = biggerWidth + tooltipData.tooltipPadding.horizontal; final tooltipHeight = sumTextsHeight + tooltipData.tooltipPadding.vertical; @@ -1226,6 +1253,12 @@ class LineChartPainter extends AxisChartPainter { PaintHolder holder, ) { final data = holder.data; + final viewSize = holder.getChartUsableSize(size); + + final isZoomed = holder.chartVirtualRect != null; + if (isZoomed && !size.contains(localPosition)) { + return null; + } /// it holds list of nearest touched spots of each line /// and we use it to draw touch stuff on them @@ -1236,8 +1269,14 @@ class LineChartPainter extends AxisChartPainter { final barData = data.lineBarsData[i]; // find the nearest spot on touch area in this bar line - final foundTouchedSpot = - getNearestTouchedSpot(size, localPosition, barData, i, holder); + final foundTouchedSpot = getNearestTouchedSpot( + viewSize, + localPosition, + barData, + i, + holder, + ); + if (foundTouchedSpot != null) { touchedSpots.add(foundTouchedSpot); } @@ -1298,6 +1337,39 @@ class LineChartPainter extends AxisChartPainter { return null; } } + + // Get the height of the dot for the given showingTooltipSpots + double _getDotHeight({ + required Size viewSize, + required PaintHolder holder, + required List showingTooltipSpots, + }) { + double? dotHeight; + for (final info in showingTooltipSpots) { + // Find the corresponding indicator data for this spot + final lineData = holder.data.lineBarsData.elementAtOrNull(info.barIndex); + if (lineData == null) continue; + + final indicators = holder.data.lineTouchData + .getTouchedSpotIndicator(lineData, [info.spotIndex]); + + final indicatorData = indicators.elementAtOrNull(0); + if (indicatorData != null && indicatorData.touchedSpotDotData.show) { + final xPercentInLine = (getPixelX(info.x, viewSize, holder) / + getBarLineXLength(lineData, viewSize, holder)) * + 100; + final dotPainter = indicatorData.touchedSpotDotData + .getDotPainter(info, xPercentInLine, lineData, info.spotIndex); + final currentDotHeight = dotPainter.getSize(info).height; + + // Keep the largest dot height + if (dotHeight == null || currentDotHeight > dotHeight) { + dotHeight = currentDotHeight; + } + } + } + return dotHeight ?? 0; + } } @visibleForTesting diff --git a/lib/src/chart/line_chart/line_chart_renderer.dart b/lib/src/chart/line_chart/line_chart_renderer.dart index 9d57d6048..33e6f134a 100644 --- a/lib/src/chart/line_chart/line_chart_renderer.dart +++ b/lib/src/chart/line_chart/line_chart_renderer.dart @@ -14,10 +14,14 @@ class LineChartLeaf extends LeafRenderObjectWidget { super.key, required this.data, required this.targetData, + required this.canBeScaled, + required this.chartVirtualRect, }); final LineChartData data; final LineChartData targetData; + final Rect? chartVirtualRect; + final bool canBeScaled; @override RenderLineChart createRenderObject(BuildContext context) => RenderLineChart( @@ -25,6 +29,8 @@ class LineChartLeaf extends LeafRenderObjectWidget { data, targetData, MediaQuery.of(context).textScaler, + chartVirtualRect, + canBeScaled: canBeScaled, ); @override @@ -33,7 +39,9 @@ class LineChartLeaf extends LeafRenderObjectWidget { ..data = data ..targetData = targetData ..textScaler = MediaQuery.of(context).textScaler - ..buildContext = context; + ..buildContext = context + ..chartVirtualRect = chartVirtualRect + ..canBeScaled = canBeScaled; } } // coverage:ignore-end @@ -45,12 +53,16 @@ class RenderLineChart extends RenderBaseChart { LineChartData data, LineChartData targetData, TextScaler textScaler, - ) : _data = data, + Rect? chartVirtualRect, { + required bool canBeScaled, + }) : _data = data, _targetData = targetData, _textScaler = textScaler, + _chartVirtualRect = chartVirtualRect, super( targetData.lineTouchData, context, + canBeScaled: canBeScaled, ); LineChartData get data => _data; @@ -78,6 +90,14 @@ class RenderLineChart extends RenderBaseChart { markNeedsPaint(); } + Rect? get chartVirtualRect => _chartVirtualRect; + Rect? _chartVirtualRect; + set chartVirtualRect(Rect? value) { + if (_chartVirtualRect == value) return; + _chartVirtualRect = value; + markNeedsPaint(); + } + // We couldn't mock [size] property of this class, that's why we have this @visibleForTesting Size? mockTestSize; @@ -86,7 +106,7 @@ class RenderLineChart extends RenderBaseChart { LineChartPainter painter = LineChartPainter(); PaintHolder get paintHolder => - PaintHolder(data, targetData, textScaler); + PaintHolder(data, targetData, textScaler, chartVirtualRect); @override void paint(PaintingContext context, Offset offset) { diff --git a/lib/src/chart/pie_chart/pie_chart_painter.dart b/lib/src/chart/pie_chart/pie_chart_painter.dart index c2172b8d5..724b3e639 100644 --- a/lib/src/chart/pie_chart/pie_chart_painter.dart +++ b/lib/src/chart/pie_chart/pie_chart_painter.dart @@ -27,12 +27,15 @@ class PieChartPainter extends BaseChartPainter { _sectionStrokePaint = Paint()..style = PaintingStyle.stroke; _centerSpacePaint = Paint()..style = PaintingStyle.fill; + + _clipPaint = Paint(); } late Paint _sectionPaint; late Paint _sectionSaveLayerPaint; late Paint _sectionStrokePaint; late Paint _centerSpacePaint; + late Paint _clipPaint; /// Paints [PieChartData] into the provided canvas. @override @@ -330,7 +333,7 @@ class PieChartPainter extends BaseChartPainter { canvasWrapper ..saveLayer( Rect.fromLTWH(0, 0, viewSize.width, viewSize.height), - Paint(), + _clipPaint, ) ..clipPath(sectionPath); diff --git a/lib/src/chart/pie_chart/pie_chart_renderer.dart b/lib/src/chart/pie_chart/pie_chart_renderer.dart index 4683ca321..0b01563b7 100644 --- a/lib/src/chart/pie_chart/pie_chart_renderer.dart +++ b/lib/src/chart/pie_chart/pie_chart_renderer.dart @@ -54,7 +54,7 @@ class RenderPieChart extends RenderBaseChart ) : _data = data, _targetData = targetData, _textScaler = textScaler, - super(targetData.pieTouchData, context); + super(targetData.pieTouchData, context, canBeScaled: false); PieChartData get data => _data; PieChartData _data; diff --git a/lib/src/chart/radar_chart/radar_chart_renderer.dart b/lib/src/chart/radar_chart/radar_chart_renderer.dart index d05d8094e..10d9355b9 100644 --- a/lib/src/chart/radar_chart/radar_chart_renderer.dart +++ b/lib/src/chart/radar_chart/radar_chart_renderer.dart @@ -47,7 +47,7 @@ class RenderRadarChart extends RenderBaseChart { ) : _data = data, _targetData = targetData, _textScaler = textScaler, - super(targetData.radarTouchData, context); + super(targetData.radarTouchData, context, canBeScaled: false); RadarChartData get data => _data; RadarChartData _data; diff --git a/lib/src/chart/scatter_chart/scatter_chart.dart b/lib/src/chart/scatter_chart/scatter_chart.dart index 88efaba5c..29819f901 100644 --- a/lib/src/chart/scatter_chart/scatter_chart.dart +++ b/lib/src/chart/scatter_chart/scatter_chart.dart @@ -19,6 +19,7 @@ class ScatterChart extends ImplicitlyAnimatedWidget { Duration duration = const Duration(milliseconds: 150), @Deprecated('Please use [curve] instead') Curve? swapAnimationCurve, Curve curve = Curves.linear, + this.transformationConfig = const FlTransformationConfig(), }) : super( duration: swapAnimationDuration ?? duration, curve: swapAnimationCurve ?? curve, @@ -27,6 +28,9 @@ class ScatterChart extends ImplicitlyAnimatedWidget { /// Determines how the [ScatterChart] should be look like. final ScatterChartData data; + /// {@macro fl_chart.AxisChartScaffoldWidget.transformationConfig} + final FlTransformationConfig transformationConfig; + /// We pass this key to our renderers which are responsible to /// render the chart itself (without anything around the chart). final Key? chartRendererKey; @@ -53,11 +57,14 @@ class _ScatterChartState extends AnimatedWidgetBaseState { return AxisChartScaffoldWidget( data: showingData, - chart: ScatterChartLeaf( + transformationConfig: widget.transformationConfig, + chartBuilder: (context, chartVirtualRect) => ScatterChartLeaf( data: _withTouchedIndicators(_scatterChartDataTween!.evaluate(animation)), targetData: _withTouchedIndicators(showingData), key: widget.chartRendererKey, + chartVirtualRect: chartVirtualRect, + canBeScaled: widget.transformationConfig.scaleAxis != FlScaleAxis.none, ), ); } diff --git a/lib/src/chart/scatter_chart/scatter_chart_painter.dart b/lib/src/chart/scatter_chart/scatter_chart_painter.dart index ad7117037..f4fa5ef23 100644 --- a/lib/src/chart/scatter_chart/scatter_chart_painter.dart +++ b/lib/src/chart/scatter_chart/scatter_chart_painter.dart @@ -24,10 +24,13 @@ class ScatterChartPainter extends AxisChartPainter { ..style = PaintingStyle.stroke ..color = Colors.transparent ..strokeWidth = 1.0; + + _clipPaint = Paint(); } late Paint _bgTouchTooltipPaint; late Paint _borderTouchTooltipPaint; + late Paint _clipPaint; /// Paints [ScatterChartData] into the provided canvas. @override @@ -36,8 +39,21 @@ class ScatterChartPainter extends AxisChartPainter { CanvasWrapper canvasWrapper, PaintHolder holder, ) { + if (holder.chartVirtualRect != null) { + canvasWrapper + ..saveLayer( + Offset.zero & canvasWrapper.size, + _clipPaint, + ) + ..clipRect(Offset.zero & canvasWrapper.size); + } super.paint(context, canvasWrapper, holder); drawSpots(context, canvasWrapper, holder); + + if (holder.chartVirtualRect != null) { + canvasWrapper.restore(); + } + drawTouchTooltips(context, canvasWrapper, holder); } @@ -60,7 +76,7 @@ class ScatterChartPainter extends AxisChartPainter { canvasWrapper.size.width, canvasWrapper.size.height, ), - Paint(), + _clipPaint, ); var left = 0.0; @@ -230,19 +246,27 @@ class ScatterChartPainter extends AxisChartPainter { final width = drawingTextPainter.width; final height = drawingTextPainter.height; - /// if we have multiple bar lines, - /// there are more than one FlCandidate on touch area, - /// we should get the most top FlSpot Offset to draw the tooltip on top of it - final mostTopOffset = Offset( + final tooltipOriginPoint = Offset( getPixelX(showOnSpot.x, viewSize, holder), getPixelY(showOnSpot.y, viewSize, holder), ); + // Get the dot size to create an extended boundary + final dotSize = showOnSpot.dotPainter.getSize(showOnSpot); + final dotRadius = dotSize.width / 2; + final viewRect = Offset.zero & viewSize; + final extendedBoundary = viewRect.inflate(dotRadius); + + // Check if any part of the dot is within the extended boundary + if (!extendedBoundary.contains(tooltipOriginPoint)) { + return; + } + final tooltipWidth = width + tooltipData.tooltipPadding.horizontal; final tooltipHeight = height + tooltipData.tooltipPadding.vertical; final tooltipLeftPosition = getTooltipLeft( - mostTopOffset.dx, + tooltipOriginPoint.dx, tooltipWidth, tooltipData.tooltipHorizontalAlignment, tooltipData.tooltipHorizontalOffset, @@ -251,7 +275,7 @@ class ScatterChartPainter extends AxisChartPainter { /// draw the background rect with rounded radius var rect = Rect.fromLTWH( tooltipLeftPosition, - mostTopOffset.dy - + tooltipOriginPoint.dy - tooltipHeight - (showOnSpot.size.height / 2) - tooltipItem.bottomMargin, diff --git a/lib/src/chart/scatter_chart/scatter_chart_renderer.dart b/lib/src/chart/scatter_chart/scatter_chart_renderer.dart index e4a925743..1073fee6f 100644 --- a/lib/src/chart/scatter_chart/scatter_chart_renderer.dart +++ b/lib/src/chart/scatter_chart/scatter_chart_renderer.dart @@ -13,10 +13,14 @@ class ScatterChartLeaf extends LeafRenderObjectWidget { super.key, required this.data, required this.targetData, + required this.chartVirtualRect, + required this.canBeScaled, }); final ScatterChartData data; final ScatterChartData targetData; + final Rect? chartVirtualRect; + final bool canBeScaled; @override RenderScatterChart createRenderObject(BuildContext context) => @@ -25,6 +29,8 @@ class ScatterChartLeaf extends LeafRenderObjectWidget { data, targetData, MediaQuery.of(context).textScaler, + chartVirtualRect, + canBeScaled: canBeScaled, ); @override @@ -36,7 +42,9 @@ class ScatterChartLeaf extends LeafRenderObjectWidget { ..data = data ..targetData = targetData ..textScaler = MediaQuery.of(context).textScaler - ..buildContext = context; + ..buildContext = context + ..chartVirtualRect = chartVirtualRect + ..canBeScaled = canBeScaled; } } // coverage:ignore-end @@ -48,10 +56,13 @@ class RenderScatterChart extends RenderBaseChart { ScatterChartData data, ScatterChartData targetData, TextScaler textScaler, - ) : _data = data, + Rect? chartVirtualRect, { + required bool canBeScaled, + }) : _data = data, _targetData = targetData, _textScaler = textScaler, - super(targetData.scatterTouchData, context); + _chartVirtualRect = chartVirtualRect, + super(targetData.scatterTouchData, context, canBeScaled: canBeScaled); ScatterChartData get data => _data; ScatterChartData _data; @@ -81,6 +92,15 @@ class RenderScatterChart extends RenderBaseChart { markNeedsPaint(); } + Rect? get chartVirtualRect => _chartVirtualRect; + Rect? _chartVirtualRect; + + set chartVirtualRect(Rect? value) { + if (_chartVirtualRect == value) return; + _chartVirtualRect = value; + markNeedsPaint(); + } + // We couldn't mock [size] property of this class, that's why we have this @visibleForTesting Size? mockTestSize; @@ -89,7 +109,7 @@ class RenderScatterChart extends RenderBaseChart { ScatterChartPainter painter = ScatterChartPainter(); PaintHolder get paintHolder => - PaintHolder(data, targetData, textScaler); + PaintHolder(data, targetData, textScaler, chartVirtualRect); @override void paint(PaintingContext context, Offset offset) { diff --git a/pubspec.yaml b/pubspec.yaml index c93266f1b..31a5a78b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,13 +18,13 @@ dependencies: equatable: ^2.0.5 flutter: sdk: flutter + vector_math: ^2.1.4 dev_dependencies: build_runner: ^2.4.6 flutter_test: sdk: flutter mockito: ^5.4.2 - vector_math: ^2.1.4 #Added to use in some generated codes of mockito very_good_analysis: ^6.0.0 screenshots: diff --git a/repo_files/documentations/handle_transformations.md b/repo_files/documentations/handle_transformations.md new file mode 100644 index 000000000..2a5ec76da --- /dev/null +++ b/repo_files/documentations/handle_transformations.md @@ -0,0 +1,119 @@ +# FL Chart Transformation Guide + +The transformation feature in `fl_chart` allows users to interact with charts through scaling and panning, similar to Flutter's `InteractiveViewer` widget. + +## Basic Usage + +To enable transformations, provide a `FlTransformationConfig` to your chart: + +```dart +LineChart( + LineChartData(...), + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + minScale: 1.0, + maxScale: 2.5, + ), +) +``` + +### Configuration Options +See [FlTransformationConfig](https://github.com/imaNNeo/fl_chart/blob/main/lib/src/chart/base/axis_chart/transformation_config.dart) for more information. + +### Chart-Specific Limitations + +- **Bar Chart**: When using `BarChartAlignment.center`, `end`, or `start`, horizontal scaling is not supported +- **Line Chart**: Supports all transformation types +- **Scatter Chart**: Supports all transformation types + +## Advanced Usage: Custom Transformation Controller + +For more control over transformations, you can provide a `TransformationController`. This allows you to: +- Programmatically control the chart's transformation +- Reset to initial state +- Implement custom zoom/pan controls + +### Limitations +At this moment, transformations made with a custom `TransformationController` are not prevented from moving the chart out of the screen. Developers are responsible for ensuring that the chart remains within the visible area and within the transformation limits. + +See the implementation of [AxisChartScaffoldWidget](https://github.com/imaNNeo/fl_chart/blob/main/lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart) for how to prevent the chart from moving out of the screen when using a custom `TransformationController`. + +### Example Implementation + +```dart +class ChartWithControls extends StatefulWidget { + @override + State createState() => _ChartWithControlsState(); +} + +class _ChartWithControlsState extends State { + late TransformationController _controller; + + @override + void initState() { + super.initState(); + _controller = TransformationController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + AspectRatio( + aspectRatio: 1.4, + child: LineChart( + LineChartData(...), + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + minScale: 1.0, + maxScale: 25.0, + transformationController: _controller, + ), + ), + ), + Row( + children: [ + IconButton( + icon: Icon(Icons.zoom_in), + onPressed: () { + _controller.value *= Matrix4.diagonal3Values(1.1, 1.1, 1); + }, + ), + IconButton( + icon: Icon(Icons.zoom_out), + onPressed: () { + _controller.value *= Matrix4.diagonal3Values(0.9, 0.9, 1); + }, + ), + IconButton( + icon: Icon(Icons.refresh), + onPressed: () { + _controller.value = Matrix4.identity(); + }, + ), + ], + ), + ], + ); + } +} +``` + +### Common Transformation Operations +See [Matrix4](https://pub.dev/documentation/vector_math/latest/vector_math_64/Matrix4-class.html) for more information on how to manipulate the matrix. + +## Best Practices + +1. Always dispose of the `TransformationController` when you're done with it +2. Set appropriate `minScale` and `maxScale` values to prevent excessive zooming +3. Consider your chart's alignment when choosing a `scaleAxis` +4. Provide visual feedback for transformation limits +5. Consider adding reset functionality for better user experience + +Remember that transformations are purely visual and don't affect the underlying data. They're particularly useful for exploring detailed data sets or allowing users to focus on specific regions of interest in your charts. diff --git a/repo_files/documentations/index.md b/repo_files/documentations/index.md index aada95361..6b8cd5474 100644 --- a/repo_files/documentations/index.md +++ b/repo_files/documentations/index.md @@ -18,4 +18,6 @@ click and learn more about them. - [Handle Touches](handle_touches.md) -- [Handle Animations](handle_animations.md) \ No newline at end of file +- [Handle Animations](handle_animations.md) + +- [Handle Transformations](handle_transformations.md) diff --git a/repo_files/documentations/migration_guides/0.70.0/MIGRATION_00_70_00.md b/repo_files/documentations/migration_guides/0.70.0/MIGRATION_00_70_00.md new file mode 100644 index 000000000..a82c8acdf --- /dev/null +++ b/repo_files/documentations/migration_guides/0.70.0/MIGRATION_00_70_00.md @@ -0,0 +1,7 @@ +# Migrate to version 0.70.0 + +## Fixed the equatable functionality in our PieChartSectionData +Please check any code that compares `PieChartSectionData` classes or other objects containing `PieChartSectionData` and make sure it is not affected by this change. + +## `BarChart` is not const anymore +We added an assert to check if transformations are allowed depending on the `BarChartData.alignment` property. If you are using `BarChart` as a const, you need to remove the const keyword from the `BarChart` constructor. The compiler will show you an error if you try to use `BarChart` as a const. diff --git a/test/chart/bar_chart/bar_chart_painter_test.dart b/test/chart/bar_chart/bar_chart_painter_test.dart index 936502421..b9e7877dd 100644 --- a/test/chart/bar_chart/bar_chart_painter_test.dart +++ b/test/chart/bar_chart/bar_chart_painter_test.dart @@ -86,6 +86,484 @@ void main() { }); }); + group('scaling related', () { + final utilsMainInstance = Utils(); + late MockUtils mockUtils; + + setUp(() { + mockUtils = MockUtils(); + Utils.changeInstance(mockUtils); + + when(mockUtils.getThemeAwareTextStyle(any, any)) + .thenAnswer((realInvocation) => textStyle1); + when(mockUtils.calculateRotationOffset(any, any)) + .thenAnswer((realInvocation) => Offset.zero); + when(mockUtils.convertRadiusToSigma(any)) + .thenAnswer((realInvocation) => 4.0); + when(mockUtils.getEfficientInterval(any, any)) + .thenAnswer((realInvocation) => 1.0); + when(mockUtils.getBestInitialIntervalValue(any, any, any)) + .thenAnswer((realInvocation) => 1.0); + when(mockUtils.normalizeBorderRadius(any, any)) + .thenAnswer((realInvocation) => BorderRadius.zero); + when(mockUtils.normalizeBorderSide(any, any)).thenAnswer( + (realInvocation) => const BorderSide(color: MockData.color0), + ); + }); + + tearDown(() { + Utils.changeInstance(utilsMainInstance); + }); + + test('clips to canvas size if chart virtual rect is provided', () { + const viewSize = Size(400, 400); + final chartVirtualRect = (Offset.zero & viewSize).inflate(100); + + final barGroups = [ + BarChartGroupData( + x: 0, + barRods: [ + BarChartRodData( + toY: 10, + width: 10, + color: const Color(0x00000000), + borderRadius: const BorderRadius.all(Radius.circular(0.1)), + ), + BarChartRodData( + toY: 8, + width: 11, + color: const Color(0x11111111), + borderRadius: const BorderRadius.all(Radius.circular(0.2)), + ), + BarChartRodData( + toY: 8, + width: 12, + color: const Color(0x22222222), + borderRadius: const BorderRadius.all(Radius.circular(0.3)), + ), + ], + barsSpace: 5, + showingTooltipIndicators: [ + 1, + 2, + ], + ), + BarChartGroupData( + x: 1, + barRods: [ + BarChartRodData( + toY: 10, + width: 10, + borderRadius: const BorderRadius.all(Radius.circular(0.4)), + ), + BarChartRodData(toY: 8, width: 10), + ], + barsSpace: 5, + ), + BarChartGroupData( + x: 2, + barRods: [ + BarChartRodData(toY: 10, width: 10), + BarChartRodData(toY: 8, width: 10), + BarChartRodData(toY: 8, width: 10), + BarChartRodData(toY: 8, width: 10), + ], + barsSpace: 5, + ), + ]; + + final tooltipData = BarTouchTooltipData( + tooltipRoundedRadius: 8, + getTooltipColor: (group) => const Color(0xf33f33f3), + maxContentWidth: 80, + rotateAngle: 12, + tooltipBorder: const BorderSide(color: Color(0xf33f33f3), width: 2), + getTooltipItem: ( + group, + groupIndex, + rod, + rodIndex, + ) { + return BarTooltipItem( + 'helllo1', + textStyle1, + textAlign: TextAlign.right, + textDirection: TextDirection.rtl, + children: [ + const TextSpan(text: 'helllo2'), + const TextSpan(text: 'helllo3'), + ], + ); + }, + ); + + final (minY, maxY) = BarChartHelper().calculateMaxAxisValues(barGroups); + + final data = BarChartData( + groupsSpace: 10, + barGroups: barGroups, + barTouchData: BarTouchData( + touchTooltipData: tooltipData, + ), + alignment: BarChartAlignment.center, + minY: minY, + maxY: maxY, + ); + + final barChartPainter = BarChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + chartVirtualRect, + ); + + final mockBuildContext = MockBuildContext(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenReturn(viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + barChartPainter.paint( + mockBuildContext, + mockCanvasWrapper, + holder, + ); + + final canvasRect = Offset.zero & viewSize; + verifyInOrder([ + mockCanvasWrapper.saveLayer(canvasRect, any), + mockCanvasWrapper.clipRect(canvasRect), + mockCanvasWrapper.drawRRect(any, any), + mockCanvasWrapper.restore(), + mockCanvasWrapper.drawRotated( + size: anyNamed('size'), + rotationOffset: anyNamed('rotationOffset'), + drawOffset: anyNamed('drawOffset'), + angle: anyNamed('angle'), + drawCallback: anyNamed('drawCallback'), + ), + ]); + }); + + test('only draws points within canvas when chart virtual rect is provided', + () { + const viewSize = Size(200, 200); + const zoomedSize = Size(300, 300); + final chartVirtualRect = const Offset(0, -150) & zoomedSize; + const maxToY = 10.0; // Y coordinate -150 - outside of canvas + const minToY = 5.0; // Y coordinate 150 - inside of canvas + + final barGroups = [ + BarChartGroupData( + x: 0, + barRods: [ + BarChartRodData( + toY: maxToY, + width: 10, + color: const Color(0x00000000), + borderRadius: const BorderRadius.all(Radius.circular(0.1)), + ), + BarChartRodData( + toY: minToY, + width: 11, + color: const Color(0x11111111), + borderRadius: const BorderRadius.all(Radius.circular(0.2)), + ), + ], + barsSpace: 5, + showingTooltipIndicators: [ + 0, + 1, + ], + ), + ]; + + final tooltipData = BarTouchTooltipData( + tooltipRoundedRadius: 8, + getTooltipColor: (group) => const Color(0xf33f33f3), + maxContentWidth: 80, + rotateAngle: 12, + tooltipBorder: const BorderSide(color: Color(0xf33f33f3), width: 2), + getTooltipItem: ( + group, + groupIndex, + rod, + rodIndex, + ) { + return BarTooltipItem( + 'helllo1', + textStyle1, + textAlign: TextAlign.right, + textDirection: TextDirection.rtl, + children: [ + const TextSpan(text: 'helllo2'), + const TextSpan(text: 'helllo3'), + ], + ); + }, + ); + + final (minY, maxY) = BarChartHelper().calculateMaxAxisValues(barGroups); + + final data = BarChartData( + groupsSpace: 10, + barGroups: barGroups, + barTouchData: BarTouchData( + touchTooltipData: tooltipData, + ), + alignment: BarChartAlignment.center, + minY: minY, + maxY: maxY, + ); + + final barChartPainter = BarChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + chartVirtualRect, + ); + + final mockBuildContext = MockBuildContext(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenReturn(viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + barChartPainter.paint( + mockBuildContext, + mockCanvasWrapper, + holder, + ); + + verify( + mockCanvasWrapper.drawRotated( + size: anyNamed('size'), + rotationOffset: anyNamed('rotationOffset'), + drawOffset: anyNamed('drawOffset'), + angle: anyNamed('angle'), + drawCallback: anyNamed('drawCallback'), + ), + ).called(1); + }); + + test('does not clip if chart virtual rect is null', () { + { + const viewSize = Size(400, 400); + + final barGroups = [ + BarChartGroupData( + x: 0, + barRods: [ + BarChartRodData( + toY: 10, + width: 10, + color: const Color(0x00000000), + borderRadius: const BorderRadius.all(Radius.circular(0.1)), + ), + BarChartRodData( + toY: 8, + width: 11, + color: const Color(0x11111111), + borderRadius: const BorderRadius.all(Radius.circular(0.2)), + ), + BarChartRodData( + toY: 8, + width: 12, + color: const Color(0x22222222), + borderRadius: const BorderRadius.all(Radius.circular(0.3)), + ), + ], + barsSpace: 5, + showingTooltipIndicators: [ + 1, + 2, + ], + ), + BarChartGroupData( + x: 1, + barRods: [ + BarChartRodData( + toY: 10, + width: 10, + borderRadius: const BorderRadius.all(Radius.circular(0.4)), + ), + BarChartRodData(toY: 8, width: 10), + ], + barsSpace: 5, + ), + BarChartGroupData( + x: 2, + barRods: [ + BarChartRodData(toY: 10, width: 10), + BarChartRodData(toY: 8, width: 10), + BarChartRodData(toY: 8, width: 10), + BarChartRodData(toY: 8, width: 10), + ], + barsSpace: 5, + ), + ]; + + final tooltipData = BarTouchTooltipData( + tooltipRoundedRadius: 8, + getTooltipColor: (group) => const Color(0xf33f33f3), + maxContentWidth: 80, + rotateAngle: 12, + tooltipBorder: const BorderSide(color: Color(0xf33f33f3), width: 2), + getTooltipItem: ( + group, + groupIndex, + rod, + rodIndex, + ) { + return BarTooltipItem( + 'helllo1', + textStyle1, + textAlign: TextAlign.right, + textDirection: TextDirection.rtl, + children: [ + const TextSpan(text: 'helllo2'), + const TextSpan(text: 'helllo3'), + ], + ); + }, + ); + + final (minY, maxY) = BarChartHelper().calculateMaxAxisValues(barGroups); + + final data = BarChartData( + groupsSpace: 10, + barGroups: barGroups, + barTouchData: BarTouchData( + touchTooltipData: tooltipData, + ), + alignment: BarChartAlignment.center, + minY: minY, + maxY: maxY, + ); + + final barChartPainter = BarChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + ); + + final mockBuildContext = MockBuildContext(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenReturn(viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + barChartPainter.paint( + mockBuildContext, + mockCanvasWrapper, + holder, + ); + + final canvasRect = Offset.zero & viewSize; + verifyNever(mockCanvasWrapper.saveLayer(canvasRect, any)); + verifyNever(mockCanvasWrapper.clipRect(canvasRect)); + verifyNever(mockCanvasWrapper.restore()); + } + }); + + test('draws all points if chart virtual rect is null', () { + const viewSize = Size(200, 200); + const maxToY = 10.0; // Y coordinate -150 - outside of canvas + const minToY = 5.0; // Y coordinate 150 - inside of canvas + + final barGroups = [ + BarChartGroupData( + x: 0, + barRods: [ + BarChartRodData( + toY: maxToY, + width: 10, + color: const Color(0x00000000), + borderRadius: const BorderRadius.all(Radius.circular(0.1)), + ), + BarChartRodData( + toY: minToY, + width: 11, + color: const Color(0x11111111), + borderRadius: const BorderRadius.all(Radius.circular(0.2)), + ), + ], + barsSpace: 5, + showingTooltipIndicators: [ + 0, + 1, + ], + ), + ]; + + final tooltipData = BarTouchTooltipData( + tooltipRoundedRadius: 8, + getTooltipColor: (group) => const Color(0xf33f33f3), + maxContentWidth: 80, + rotateAngle: 12, + tooltipBorder: const BorderSide(color: Color(0xf33f33f3), width: 2), + getTooltipItem: ( + group, + groupIndex, + rod, + rodIndex, + ) { + return BarTooltipItem( + 'helllo1', + textStyle1, + textAlign: TextAlign.right, + textDirection: TextDirection.rtl, + children: [ + const TextSpan(text: 'helllo2'), + const TextSpan(text: 'helllo3'), + ], + ); + }, + ); + + final (minY, maxY) = BarChartHelper().calculateMaxAxisValues(barGroups); + + final data = BarChartData( + groupsSpace: 10, + barGroups: barGroups, + barTouchData: BarTouchData( + touchTooltipData: tooltipData, + ), + alignment: BarChartAlignment.center, + minY: minY, + maxY: maxY, + ); + + final barChartPainter = BarChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + ); + + final mockBuildContext = MockBuildContext(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenReturn(viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + barChartPainter.paint( + mockBuildContext, + mockCanvasWrapper, + holder, + ); + + verify( + mockCanvasWrapper.drawRotated( + size: anyNamed('size'), + rotationOffset: anyNamed('rotationOffset'), + drawOffset: anyNamed('drawOffset'), + angle: anyNamed('angle'), + drawCallback: anyNamed('drawCallback'), + ), + ).called(2); + }); + }); + group('calculateGroupsX()', () { test('test 1', () { const viewSize = Size(200, 100); @@ -2060,6 +2538,135 @@ void main() { expect(result22!.touchedBarGroupIndex, 1); expect(result22.touchedRodDataIndex, 0); }); + + test( + 'returns null when chart virtual rect is provided and touch is outside ' + 'of canvas', + () { + const viewSize = Size(50, 50); + + final barGroups = [ + BarChartGroupData( + x: 1, + barRods: [ + BarChartRodData( + toY: 5, + backDrawRodData: BackgroundBarChartRodData( + show: true, + fromY: -5, + toY: 5, + ), + ), + ], + ), + BarChartGroupData( + x: 1, + barRods: [ + BarChartRodData( + toY: -6, + backDrawRodData: BackgroundBarChartRodData( + show: true, + fromY: 5, + toY: -6, + ), + ), + ], + ), + ]; + + final data = BarChartData( + barGroups: barGroups, + titlesData: const FlTitlesData(show: false), + alignment: BarChartAlignment.start, + groupsSpace: 10, + minY: -10, + maxY: 15, + barTouchData: BarTouchData( + enabled: true, + handleBuiltInTouches: true, + allowTouchBarBackDraw: true, + touchExtraThreshold: const EdgeInsets.all(1), + ), + ); + + final painter = BarChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + Offset.zero & const Size(50, 50), + ); + + final result1 = + painter.handleTouch(const Offset(4, 60), viewSize, holder); + expect(result1, null); + }, + ); + + test( + 'returns result when chart virtual rect is provided and touch is inside ' + 'of canvas', + () { + const viewSize = Size(50, 50); + + final barGroups = [ + BarChartGroupData( + x: 1, + barRods: [ + BarChartRodData( + toY: 5, + backDrawRodData: BackgroundBarChartRodData( + show: true, + fromY: -5, + toY: 5, + ), + ), + ], + ), + BarChartGroupData( + x: 1, + barRods: [ + BarChartRodData( + toY: -6, + backDrawRodData: BackgroundBarChartRodData( + show: true, + fromY: 5, + toY: -6, + ), + ), + ], + ), + ]; + + final data = BarChartData( + barGroups: barGroups, + titlesData: const FlTitlesData(show: false), + alignment: BarChartAlignment.start, + groupsSpace: 10, + minY: -10, + maxY: 15, + barTouchData: BarTouchData( + enabled: true, + handleBuiltInTouches: true, + allowTouchBarBackDraw: true, + touchExtraThreshold: const EdgeInsets.all(1), + ), + ); + + final painter = BarChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + Offset.zero & const Size(50, 50), + ); + + final result1 = + painter.handleTouch(const Offset(4, 30), viewSize, holder); + expect(result1!.touchedBarGroupIndex, 0); + expect(result1.touchedRodDataIndex, 0); + }, + ); }); group('drawExtraLines()', () { diff --git a/test/chart/bar_chart/bar_chart_renderer_test.dart b/test/chart/bar_chart/bar_chart_renderer_test.dart index f36210c48..100c07b2e 100644 --- a/test/chart/bar_chart/bar_chart_renderer_test.dart +++ b/test/chart/bar_chart/bar_chart_renderer_test.dart @@ -48,6 +48,8 @@ void main() { data, targetData, textScaler, + null, + canBeScaled: false, ); final mockPainter = MockBarChartPainter(); @@ -120,5 +122,42 @@ void main() { expect(renderBarChart.targetData, data); expect(renderBarChart.textScaler, const TextScaler.linear(22)); }); + + test('passes chart virtual rect to paint holder', () { + final rect1 = Offset.zero & const Size(100, 100); + final renderBarChart = RenderBarChart( + mockBuildContext, + data, + targetData, + textScaler, + null, + canBeScaled: false, + ); + + expect(renderBarChart.chartVirtualRect, isNull); + expect(renderBarChart.paintHolder.chartVirtualRect, isNull); + + renderBarChart.chartVirtualRect = rect1; + + expect(renderBarChart.chartVirtualRect, rect1); + expect(renderBarChart.paintHolder.chartVirtualRect, rect1); + }); + + test('uses canBeScaled', () { + final renderBarChart = RenderBarChart( + mockBuildContext, + data, + targetData, + textScaler, + null, + canBeScaled: false, + ); + + expect(renderBarChart.canBeScaled, false); + + renderBarChart.canBeScaled = true; + + expect(renderBarChart.canBeScaled, true); + }); }); } diff --git a/test/chart/bar_chart/bar_chart_renderer_test.mocks.dart b/test/chart/bar_chart/bar_chart_renderer_test.mocks.dart index 7e43d8ba3..7a7585fde 100644 --- a/test/chart/bar_chart/bar_chart_renderer_test.mocks.dart +++ b/test/chart/bar_chart/bar_chart_renderer_test.mocks.dart @@ -1355,14 +1355,14 @@ class MockBarChartPainter extends _i1.Mock implements _i10.BarChartPainter { @override _i13.BarTouchedSpot? handleTouch( _i2.Offset? localPosition, - _i2.Size? viewSize, + _i2.Size? size, _i12.PaintHolder<_i13.BarChartData>? holder, ) => (super.noSuchMethod(Invocation.method( #handleTouch, [ localPosition, - viewSize, + size, holder, ], )) as _i13.BarTouchedSpot?); diff --git a/test/chart/bar_chart/bar_chart_test.dart b/test/chart/bar_chart/bar_chart_test.dart new file mode 100644 index 000000000..13aca5c42 --- /dev/null +++ b/test/chart/bar_chart/bar_chart_test.dart @@ -0,0 +1,933 @@ +import 'package:fl_chart/src/chart/bar_chart/bar_chart.dart'; +import 'package:fl_chart/src/chart/bar_chart/bar_chart_data.dart'; +import 'package:fl_chart/src/chart/bar_chart/bar_chart_renderer.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget createTestWidget({ + required BarChart chart, + }) { + return MaterialApp( + home: chart, + ); + } + + group('BarChart', () { + group('throws AssertionError for', () { + final verticallyScalableAlignments = [ + BarChartAlignment.start, + BarChartAlignment.center, + BarChartAlignment.end, + ]; + for (final alignment in verticallyScalableAlignments) { + testWidgets('FlScaleAxis.horizontal with $alignment', + (WidgetTester tester) async { + expect( + () => tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData( + alignment: alignment, + ), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ), + throwsAssertionError, + ); + }); + } + + for (final alignment in verticallyScalableAlignments) { + testWidgets('FlScaleAxis.free with $alignment', + (WidgetTester tester) async { + expect( + () => tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData( + alignment: alignment, + ), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ), + throwsAssertionError, + ); + }); + } + }); + + group('allows passing', () { + for (final alignment in BarChartAlignment.values) { + testWidgets('FlScaleAxis.none with $alignment', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(alignment: alignment), + // ignore: avoid_redundant_argument_values + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + ), + ), + ), + ); + }); + } + + for (final alignment in BarChartAlignment.values) { + testWidgets('FlScaleAxis.vertical with $alignment', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(alignment: alignment), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + }); + } + + final scalableAlignments = [ + BarChartAlignment.spaceAround, + BarChartAlignment.spaceBetween, + BarChartAlignment.spaceEvenly, + ]; + + for (final alignment in scalableAlignments) { + testWidgets('FlScaleAxis.free with $alignment', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(alignment: alignment), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + }); + } + + for (final alignment in scalableAlignments) { + testWidgets('FlScaleAxis.horizontal with $alignment', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(alignment: alignment), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + }); + } + }); + + testWidgets('has correct default values', (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + ), + ), + ); + + final barChart = tester.widget(find.byType(BarChart)); + expect(barChart.transformationConfig, const FlTransformationConfig()); + }); + + testWidgets('passes interaction parameters to AxisChartScaffoldWidget', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + ), + ), + ); + + final axisChartScaffoldWidget = tester.widget( + find.byType(AxisChartScaffoldWidget), + ); + + expect( + axisChartScaffoldWidget.transformationConfig, + const FlTransformationConfig(), + ); + + await tester.pumpAndSettle(); + + final transformationConfig = FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + maxScale: 10, + minScale: 1.5, + transformationController: TransformationController(), + ); + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: transformationConfig, + ), + ), + ); + + final axisChartScaffoldWidget1 = tester.widget( + find.byType(AxisChartScaffoldWidget), + ); + + expect( + axisChartScaffoldWidget1.transformationConfig, + transformationConfig, + ); + }); + + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets('passes canBeScaled true for $scaleAxis', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + ), + ), + ), + ); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + expect(barChartLeaf.canBeScaled, true); + }); + } + + testWidgets('passes canBeScaled false for FlScaleAxis.none', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + // ignore: avoid_redundant_argument_values + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + ), + ), + ), + ); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + expect(barChartLeaf.canBeScaled, false); + }); + + group('touch gesture', () { + testWidgets('does not scale with FlScaleAxis.none', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + ), + ), + ); + + final barChartCenterOffset = + tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = barChartCenterOffset; + final scaleStart2 = barChartCenterOffset; + final scaleEnd1 = barChartCenterOffset + const Offset(100, 100); + final scaleEnd2 = barChartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + + expect(barChartLeaf.chartVirtualRect, isNull); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(alignment: BarChartAlignment.spaceEvenly), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final barChartCenterOffset = + tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = barChartCenterOffset; + final scaleStart2 = barChartCenterOffset; + final scaleEnd1 = barChartCenterOffset + const Offset(100, 100); + final scaleEnd2 = barChartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(BarChartLeaf), + ); + final chartVirtualRect = barChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size, greaterThan(renderBox.size)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, isNegative); + }); + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(BarChartLeaf), + ); + final chartVirtualRect = barChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size.height, renderBox.size.height); + expect(chartVirtualRect.size.width, greaterThan(renderBox.size.width)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(BarChartLeaf), + ); + final chartVirtualRect = barChartLeaf.chartVirtualRect!; + + expect( + chartVirtualRect.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect.size.width, renderBox.size.width); + expect(chartVirtualRect.left, 0); + expect(chartVirtualRect.top, isNegative); + }); + + group('pans', () { + testWidgets('only horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeafBeforePan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectBeforePan = + barChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final barChartLeafAfterPan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectAfterPan = + barChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectBeforePan.size, chartVirtualRectAfterPan.size); + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect(chartVirtualRectAfterPan.top, 0); + }); + + testWidgets('only vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeafBeforePan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectBeforePan = + barChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.left, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final barChartLeafAfterPan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectAfterPan = + barChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectAfterPan.left, 0); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + + testWidgets('freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeafBeforePan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectBeforePan = + barChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, isNegative); + expect(chartVirtualRectBeforePan.left, isNegative); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final barChartLeafAfterPan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectAfterPan = + barChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + }); + }); + + group('trackpad scroll', () { + group('pans', () { + testWidgets('only horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeafBeforePan = tester.widget( + find.byType(BarChartLeaf), + ); + + final chartVirtualRectBeforePan = + barChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, 0); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final barChartLeafAfterPan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectAfterPan = + barChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect(chartVirtualRectAfterPan.top, 0); + }); + + testWidgets('vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeafBeforePan = tester.widget( + find.byType(BarChartLeaf), + ); + + final chartVirtualRectBeforePan = + barChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.left, 0); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final barChartLeafAfterPan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectAfterPan = + barChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectAfterPan.left, 0); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + + testWidgets('freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final barChartLeafBeforePan = tester.widget( + find.byType(BarChartLeaf), + ); + + final chartVirtualRectBeforePan = + barChartLeafBeforePan.chartVirtualRect!; + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final barChartLeafAfterPan = tester.widget( + find.byType(BarChartLeaf), + ); + final chartVirtualRectAfterPan = + barChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + }); + + testWidgets( + 'does not scale with FlScaleAxis.none when ' + 'trackpadScrollCausesScale is true', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + expect(barChartLeaf.chartVirtualRect, null); + }, + ); + + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets( + 'does not scale when trackpadScrollCausesScale is false ' + 'for $scaleAxis', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + // ignore: avoid_redundant_argument_values + trackpadScrollCausesScale: false, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter( + find.byType(BarChartLeaf), + ); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + expect(barChartLeaf.chartVirtualRect, null); + }, + ); + } + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(BarChartLeaf), + ); + final chartVirtualRect = barChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size.height, renderBox.size.height); + expect(chartVirtualRect.size.width, greaterThan(renderBox.size.width)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final barChartLeaf = tester.widget( + find.byType(BarChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(BarChartLeaf), + ); + final chartVirtualRect = barChartLeaf.chartVirtualRect!; + + expect( + chartVirtualRect.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect.size.width, renderBox.size.width); + expect(chartVirtualRect.left, 0); + expect(chartVirtualRect.top, isNegative); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: BarChart( + BarChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byType(BarChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final barChartLeaf = + tester.widget(find.byType(BarChartLeaf)); + final renderBox = tester.renderObject( + find.byType(BarChartLeaf), + ); + final chartVirtualRect = barChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size, greaterThan(renderBox.size)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, isNegative); + }); + }); + }); +} diff --git a/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart b/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart index 75774e4be..591267f61 100644 --- a/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart +++ b/test/chart/base/axis_chart/axis_chart_scaffold_widget_test.dart @@ -1,11 +1,20 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/side_titles/side_titles_widget.dart'; +import 'package:fl_chart/src/chart/base/custom_interactive_viewer.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + const Rect? isNotScaled = null; + final isScaled = isA(); + const viewSize = Size(400, 400); + const dummyChartKey = Key('chart'); + const dummyChart = SizedBox(key: dummyChartKey); + final lineChartDataBase = LineChartData( minX: 0, maxX: 10, @@ -167,7 +176,7 @@ void main() { width: viewSize.width, height: viewSize.height, child: AxisChartScaffoldWidget( - chart: LayoutBuilder( + chartBuilder: (context, chartVirtualRect) => LayoutBuilder( builder: (context, constraints) { chartDrawingSize = constraints.biggest; return const ColoredBox( @@ -200,7 +209,7 @@ void main() { width: viewSize.width, height: viewSize.height, child: AxisChartScaffoldWidget( - chart: LayoutBuilder( + chartBuilder: (context, chartVirtualRect) => LayoutBuilder( builder: (context, constraints) { chartDrawingSize = constraints.biggest; return const ColoredBox( @@ -258,7 +267,7 @@ void main() { width: viewSize.width, height: viewSize.height, child: AxisChartScaffoldWidget( - chart: LayoutBuilder( + chartBuilder: (context, chartVirtualRect) => LayoutBuilder( builder: (context, constraints) { chartDrawingSize = constraints.biggest; return const ColoredBox( @@ -297,7 +306,7 @@ void main() { width: viewSize.width, height: viewSize.height, child: AxisChartScaffoldWidget( - chart: LayoutBuilder( + chartBuilder: (context, chartVirtualRect) => LayoutBuilder( builder: (context, constraints) { chartDrawingSize = constraints.biggest; return const ColoredBox( @@ -335,7 +344,7 @@ void main() { width: viewSize.width, height: viewSize.height, child: AxisChartScaffoldWidget( - chart: LayoutBuilder( + chartBuilder: (context, chartVirtualRect) => LayoutBuilder( builder: (context, constraints) { chartDrawingSize = constraints.biggest; return const ColoredBox( @@ -356,4 +365,1160 @@ void main() { expect(find.byType(Icon), findsOneWidget); }, ); + + group('AxisChartScaffoldWidget', () { + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets( + 'wraps chart in interactive viewer when scaling is $scaleAxis', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + data: lineChartDataWithAllTitles, + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + ), + chartBuilder: (context, chartVirtualRect) => dummyChart, + ), + ), + ), + ), + ), + ); + + final interactiveViewer = find.ancestor( + of: find.byKey(dummyChartKey), + matching: find.byType(CustomInteractiveViewer), + ); + expect(interactiveViewer, findsOneWidget); + }, + ); + } + + testWidgets( + 'does not wrap chart in interactive viewer when scaling is disabled', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + data: lineChartDataWithAllTitles, + // ignore: avoid_redundant_argument_values + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + ), + chartBuilder: (context, chartVirtualRect) => dummyChart, + ), + ), + ), + ), + ), + ); + + final interactiveViewer = find.ancestor( + of: find.byKey(dummyChartKey), + matching: find.byType(CustomInteractiveViewer), + ); + expect(interactiveViewer, findsNothing); + }, + ); + + testWidgets('passes interaction parameters to interactive viewer', + (WidgetTester tester) async { + Future pumpTestWidget(AxisChartScaffoldWidget widget) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: widget, + ), + ), + ), + ), + ); + } + + await pumpTestWidget( + AxisChartScaffoldWidget( + data: lineChartDataWithAllTitles, + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + chartBuilder: (context, chartVirtualRect) => dummyChart, + ), + ); + + final interactiveViewer1 = tester.widget( + find.byType(CustomInteractiveViewer), + ); + + expect(interactiveViewer1.trackpadScrollCausesScale, false); + expect(interactiveViewer1.maxScale, 2.5); + expect(interactiveViewer1.minScale, 1); + expect(interactiveViewer1.clipBehavior, Clip.none); + expect( + interactiveViewer1.transformationController, + isA().having( + (controller) => controller.value, + 'value', + Matrix4.identity(), + ), + ); + + final transformationController = TransformationController(); + await pumpTestWidget( + AxisChartScaffoldWidget( + data: lineChartDataWithAllTitles, + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + maxScale: 10, + minScale: 1.5, + transformationController: transformationController, + ), + chartBuilder: (context, chartVirtualRect) => dummyChart, + ), + ); + + final interactiveViewer2 = tester.widget( + find.byType(CustomInteractiveViewer), + ); + expect(interactiveViewer2.trackpadScrollCausesScale, true); + expect(interactiveViewer2.maxScale, 10); + expect(interactiveViewer2.minScale, 1.5); + expect(interactiveViewer2.clipBehavior, Clip.none); + expect( + interactiveViewer2.transformationController, + transformationController, + ); + }); + + testWidgets('asserts minScale is greater than 1', + (WidgetTester tester) async { + expect( + () => AxisChartScaffoldWidget( + data: lineChartDataWithAllTitles, + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + minScale: 0.5, + ), + chartBuilder: (context, chartVirtualRect) => dummyChart, + ), + throwsAssertionError, + ); + }); + + testWidgets('asserts maxScale is greater than or equal to minScale', + (WidgetTester tester) async { + expect( + () => AxisChartScaffoldWidget( + data: lineChartDataWithAllTitles, + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + maxScale: 0.5, + ), + chartBuilder: (context, chartVirtualRect) => dummyChart, + ), + throwsAssertionError, + ); + }); + + group('scaling and panning', () { + group('touch gesture', () { + testWidgets('does not scale with FlScaleAxis.none', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byKey(dummyChartKey)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + expect(chartVirtualRect, isNull); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final renderBox = tester.renderObject( + find.byKey(dummyChartKey), + ); + final chartCenterOffset = tester.getCenter(find.byKey(dummyChartKey)); + final scaleStart1 = chartCenterOffset + const Offset(10, 10); + final scaleStart2 = chartCenterOffset - const Offset(10, 10); + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + expect(chartVirtualRect!.size, greaterThan(renderBox.size)); + expect(chartVirtualRect!.left, isNegative); + expect(chartVirtualRect!.top, isNegative); + }); + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final renderBox = tester.renderObject( + find.byKey(dummyChartKey), + ); + final chartCenterOffset = tester.getCenter(find.byKey(dummyChartKey)); + final scaleStart1 = chartCenterOffset + const Offset(10, 10); + final scaleStart2 = chartCenterOffset - const Offset(10, 10); + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + expect(chartVirtualRect!.size.height, renderBox.size.height); + expect( + chartVirtualRect!.size.width, + greaterThan(renderBox.size.width), + ); + expect(chartVirtualRect!.left, isNegative); + expect(chartVirtualRect!.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final renderBox = tester.renderObject( + find.byKey(dummyChartKey), + ); + final chartCenterOffset = tester.getCenter(find.byKey(dummyChartKey)); + final scaleStart1 = chartCenterOffset + const Offset(10, 10); + final scaleStart2 = chartCenterOffset - const Offset(10, 10); + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + expect( + chartVirtualRect!.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect!.size.width, renderBox.size.width); + expect(chartVirtualRect!.left, 0); + expect(chartVirtualRect!.top, isNegative); + }); + }); + + group('trackpad scroll', () { + testWidgets( + 'does not scale with FlScaleAxis.none when trackpadScrollCausesScale is true', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + trackpadScrollCausesScale: true, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = + tester.getCenter(find.byKey(dummyChartKey)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + expect(chartVirtualRect, isNull); + }, + ); + + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets( + 'does not scale when trackpadScrollCausesScale is false ' + 'for $scaleAxis', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter( + find.byKey(dummyChartKey), + ); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + expect(chartVirtualRect, isNull); + }, + ); + } + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + trackpadScrollCausesScale: true, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final renderBox = tester.renderObject( + find.byKey(dummyChartKey), + ); + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byKey(dummyChartKey)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + expect(chartVirtualRect!.size.height, renderBox.size.height); + expect( + chartVirtualRect!.size.width, + greaterThan(renderBox.size.width), + ); + expect(chartVirtualRect!.left, isNegative); + expect(chartVirtualRect!.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + trackpadScrollCausesScale: true, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final renderBox = tester.renderObject( + find.byKey(dummyChartKey), + ); + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byKey(dummyChartKey)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + expect( + chartVirtualRect!.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect!.size.width, renderBox.size.width); + expect(chartVirtualRect!.left, 0); + expect(chartVirtualRect!.top, isNegative); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final renderBox = tester.renderObject( + find.byKey(dummyChartKey), + ); + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byKey(dummyChartKey)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + expect(chartVirtualRect!.size, greaterThan(renderBox.size)); + expect(chartVirtualRect!.left, isNegative); + expect(chartVirtualRect!.top, isNegative); + }); + }); + + group('pans', () { + testWidgets('only horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return const ColoredBox( + color: Colors.red, + ); + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(ColoredBox)); + final scaleStart1 = chartCenterOffset + const Offset(10, 10); + final scaleStart2 = chartCenterOffset - const Offset(10, 10); + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final chartVirtualRectBeforePan = chartVirtualRect; + expect(chartVirtualRectBeforePan!.top, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + expect(chartVirtualRect!.size, chartVirtualRectBeforePan.size); + expect( + chartVirtualRect!.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect(chartVirtualRect!.top, 0); + }); + + testWidgets('only vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return const ColoredBox( + color: Colors.red, + ); + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(ColoredBox)); + final scaleStart1 = chartCenterOffset + const Offset(10, 10); + final scaleStart2 = chartCenterOffset - const Offset(10, 10); + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final chartVirtualRectBeforePan = chartVirtualRect; + expect(chartVirtualRectBeforePan!.left, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + expect(chartVirtualRect!.left, 0); + expect( + chartVirtualRect!.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + + testWidgets('freely with FlScaleAxis.free', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return const ColoredBox( + color: Colors.red, + ); + }, + data: lineChartDataWithNoTitles, + ), + ), + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(ColoredBox)); + final scaleStart1 = chartCenterOffset + const Offset(10, 10); + final scaleStart2 = chartCenterOffset - const Offset(10, 10); + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final chartVirtualRectBeforePan = chartVirtualRect; + expect(chartVirtualRectBeforePan!.left, isNegative); + expect(chartVirtualRectBeforePan.top, isNegative); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + expect( + chartVirtualRect!.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect( + chartVirtualRect!.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + }); + }); + + testWidgets('passes chart rect to SideTitlesWidgets', + (WidgetTester tester) async { + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: AxisChartScaffoldWidget( + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return const ColoredBox( + color: Colors.red, + ); + }, + data: lineChartDataWithAllTitles, + ), + ), + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(ColoredBox)); + final scaleStart1 = chartCenterOffset + const Offset(10, 10); + final scaleStart2 = chartCenterOffset - const Offset(10, 10); + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final sideTitlesWidgets = tester.allWidgets.whereType(); + expect(sideTitlesWidgets.length, 4); + for (final sideTitlesWidget in sideTitlesWidgets) { + expect(sideTitlesWidget.chartVirtualRect, chartVirtualRect); + } + }); + + testWidgets( + 'updates chart rect after the first frame when controller scale != 1.0', + (WidgetTester tester) async { + final controller = TransformationController( + Matrix4.identity()..scale(3.0), + ); + Rect? chartVirtualRect; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: AxisChartScaffoldWidget( + data: lineChartDataWithNoTitles, + transformationConfig: FlTransformationConfig( + transformationController: controller, + ), + chartBuilder: (context, rect) { + chartVirtualRect = rect; + return dummyChart; + }, + ), + ), + ), + ), + ); + + expect(chartVirtualRect, isNull); + await tester.pump(); + expect(chartVirtualRect, isNotNull); + }, + ); + + testWidgets('post frame callback checks if widget is mounted', + (WidgetTester tester) async { + // This test only works correctly when the chart is a LineChart + // with scaleAxis set to none. Should throw assertion on "setState" + // if callback does not check if widget is mounted. + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView.builder( + itemCount: 20, + itemBuilder: (context, index) => SizedBox( + height: 300, + child: LineChart( + lineChartDataWithNoTitles, + // ignore: avoid_redundant_argument_values + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + ), + ), + ), + ), + ), + ), + ); + + await tester.drag( + find.byType(ListView), + const Offset(0, -1000), + ); + + await tester.pump(); + }); + + group('didUpdateWidget', () { + const chartScaffoldKey = Key('chartScaffold'); + + final chartVirtualRects = []; + + tearDown(chartVirtualRects.clear); + + Widget createTestWidget({ + TransformationController? controller, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: AxisChartScaffoldWidget( + key: chartScaffoldKey, + data: lineChartDataWithNoTitles, + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + transformationController: controller, + ), + chartBuilder: (context, rect) { + chartVirtualRects.add(rect); + return dummyChart; + }, + ), + ), + ), + ); + } + + TransformationController? getTransformationController( + WidgetTester tester, + ) { + return tester + .widget( + find.byType(CustomInteractiveViewer), + ) + .transformationController; + } + + testWidgets( + 'oldWidget.controller is null and widget.controller is null: ' + 'keeps old controller', + (WidgetTester tester) async { + final actualchartVirtualRects = [isNotScaled, isNotScaled]; + await tester.pumpWidget(createTestWidget()); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects); + + final transformationController = getTransformationController(tester); + transformationController!.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + await tester.pumpWidget(createTestWidget()); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + final transformationController2 = getTransformationController(tester); + expect(transformationController2, transformationController); + transformationController2!.value = Matrix4.identity()..scale(3.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + }, + ); + + testWidgets( + 'oldWidget.controller is null and widget.controller is not null: ' + 'disposes old controller and sets up widget.controller with listeners', + (WidgetTester tester) async { + final actualchartVirtualRects = [isNotScaled, isNotScaled]; + await tester.pumpWidget(createTestWidget()); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects); + + final transformationController = getTransformationController(tester); + transformationController!.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + final transformationController2 = TransformationController(); + + await tester.pumpWidget( + createTestWidget(controller: transformationController2), + ); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isNotScaled)); + + expect(transformationController2, isNot(transformationController)); + expect( + () => transformationController.addListener(() {}), + throwsA(isA()), + ); + transformationController2.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + }, + ); + + testWidgets( + 'oldWidget.controller is not null and widget.controller is null: ' + 'removes listeners from old controller and sets up new controller ' + 'with listeners', + (WidgetTester tester) async { + final actualchartVirtualRects = [isNotScaled, isNotScaled]; + final transformationController = TransformationController(); + await tester.pumpWidget( + createTestWidget(controller: transformationController), + ); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects); + + transformationController.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + await tester.pumpWidget(createTestWidget()); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isNotScaled)); + + final transformationController2 = getTransformationController(tester); + expect(transformationController2, isNot(transformationController)); + // ignore: invalid_use_of_protected_member + expect(transformationController.hasListeners, false); + transformationController.addListener(() {}); // throws if disposed + transformationController2!.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + }, + ); + + testWidgets( + 'oldWidget.controller is not null and widget.controller is not null, ' + 'controllers are different: ' + 'removes listeners from old controller and sets up ' + 'widget.controller with listeners', + (WidgetTester tester) async { + final actualchartVirtualRects = [isNotScaled, isNotScaled]; + final transformationController = TransformationController(); + await tester.pumpWidget( + createTestWidget(controller: transformationController), + ); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects); + + transformationController.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + final transformationController2 = TransformationController(); + + await tester.pumpWidget( + createTestWidget(controller: transformationController2), + ); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isNotScaled)); + + expect(transformationController2, isNot(transformationController)); + // ignore: invalid_use_of_protected_member + expect(transformationController.hasListeners, false); + transformationController.addListener(() {}); // throws if disposed + transformationController2.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + }, + ); + + testWidgets( + 'oldWidget.controller is not null and widget.controller is not null, ' + 'controllers are the same: keeps old controller', + (WidgetTester tester) async { + final actualchartVirtualRects = [isNotScaled, isNotScaled]; + final transformationController = TransformationController(); + await tester.pumpWidget( + createTestWidget( + controller: transformationController, + ), + ); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects); + + transformationController.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + await tester.pumpWidget( + createTestWidget( + controller: transformationController, + ), + ); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + final transformationController2 = getTransformationController(tester); + expect(transformationController2, transformationController); + transformationController.value = Matrix4.identity()..scale(3.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + }, + ); + }); + + testWidgets( + 'sets chartVirtualRect to null, when scaling is updated to 1.0', + (WidgetTester tester) async { + final transformationController = TransformationController(); + final chartVirtualRects = []; + final actualchartVirtualRects = [isNotScaled, isNotScaled]; + await tester.pumpWidget( + MaterialApp( + home: AxisChartScaffoldWidget( + data: lineChartDataWithNoTitles, + transformationConfig: FlTransformationConfig( + transformationController: transformationController, + ), + chartBuilder: (context, rect) { + chartVirtualRects.add(rect); + return dummyChart; + }, + ), + ), + ); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects); + + transformationController.value = Matrix4.identity()..scale(2.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isScaled)); + + transformationController.value = Matrix4.identity()..scale(1.0); + await tester.pump(); + expect(chartVirtualRects, actualchartVirtualRects..add(isNotScaled)); + }, + ); + + testWidgets('does not dispose external controller', + (WidgetTester tester) async { + final controller = TransformationController(); + await tester.pumpWidget( + MaterialApp( + home: AxisChartScaffoldWidget( + data: lineChartDataWithNoTitles, + transformationConfig: FlTransformationConfig( + transformationController: controller, + ), + chartBuilder: (context, rect) { + return dummyChart; + }, + ), + ), + ); + await tester.pumpWidget(Container()); + // ignore: invalid_use_of_protected_member + expect(controller.hasListeners, false); + controller.addListener(() {}); // throws if disposed + }); + }); } diff --git a/test/chart/base/axis_chart/transformation_config_test.dart b/test/chart/base/axis_chart/transformation_config_test.dart new file mode 100644 index 000000000..1aebad925 --- /dev/null +++ b/test/chart/base/axis_chart/transformation_config_test.dart @@ -0,0 +1,31 @@ +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FlTransformationConfig', () { + test('throws assertion error when minScale is less than 1', () { + expect( + () => FlTransformationConfig(minScale: 0.99), + throwsAssertionError, + ); + }); + + test('throws assertion error when maxScale is less than minScale', () { + expect( + () => FlTransformationConfig(minScale: 1.1, maxScale: 1), + throwsAssertionError, + ); + }); + + test('has correct default values', () { + const config = FlTransformationConfig(); + + expect(config.minScale, 1); + expect(config.maxScale, 2.5); + expect(config.trackpadScrollCausesScale, false); + expect(config.scaleAxis, FlScaleAxis.none); + expect(config.transformationController, isNull); + }); + }); +} diff --git a/test/chart/base/render_base_chart_test.dart b/test/chart/base/render_base_chart_test.dart new file mode 100644 index 000000000..b25bf0252 --- /dev/null +++ b/test/chart/base/render_base_chart_test.dart @@ -0,0 +1,188 @@ +import 'package:fl_chart/src/chart/base/base_chart/base_chart_data.dart'; +import 'package:fl_chart/src/chart/base/base_chart/fl_touch_event.dart'; +import 'package:fl_chart/src/chart/base/base_chart/render_base_chart.dart'; +import 'package:fl_chart/src/chart/line_chart/line_chart_data.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'render_base_chart_test.mocks.dart'; + +@GenerateMocks([ + BuildContext, + PanGestureRecognizer, + TapGestureRecognizer, + LongPressGestureRecognizer, +]) +void main() { + group('RenderBaseChart', () { + late BuildContext mockContext; + late PanGestureRecognizer panGestureRecognizer; + late TapGestureRecognizer tapGestureRecognizer; + late LongPressGestureRecognizer longPressGestureRecognizer; + late TestTouchData data; + void touchCallback(_, __) {} + + setUp(() { + mockContext = MockBuildContext(); + panGestureRecognizer = MockPanGestureRecognizer(); + tapGestureRecognizer = MockTapGestureRecognizer(); + longPressGestureRecognizer = MockLongPressGestureRecognizer(); + data = TestTouchData( + false, + touchCallback, + null, + null, + ); + }); + + group('handleEvent', () { + test('respects canBeScaled for pan gestures for PointerDownEvent', () { + const pointerDownEvent = PointerDownEvent(); + final scalableChart = TestRenderBaseChart( + mockContext, + data, + canBeScaled: true, + panGestureRecognizerOverride: panGestureRecognizer, + tapGestureRecognizerOverride: tapGestureRecognizer, + longPressGestureRecognizerOverride: longPressGestureRecognizer, + ); + + final nonScalableChart = TestRenderBaseChart( + mockContext, + data, + canBeScaled: false, + panGestureRecognizerOverride: panGestureRecognizer, + tapGestureRecognizerOverride: tapGestureRecognizer, + longPressGestureRecognizerOverride: longPressGestureRecognizer, + ); + + final hitTestEntry = BoxHitTestEntry( + scalableChart, + Offset.zero, + ); + + scalableChart.handleEvent(pointerDownEvent, hitTestEntry); + verifyNever(panGestureRecognizer.addPointer(pointerDownEvent)); + verify(longPressGestureRecognizer.addPointer(pointerDownEvent)) + .called(1); + verify(tapGestureRecognizer.addPointer(pointerDownEvent)).called(1); + + nonScalableChart.handleEvent(pointerDownEvent, hitTestEntry); + verify(panGestureRecognizer.addPointer(pointerDownEvent)).called(1); + verify(longPressGestureRecognizer.addPointer(pointerDownEvent)) + .called(1); + verify(tapGestureRecognizer.addPointer(pointerDownEvent)).called(1); + }); + + test( + 'does not add pointers for PointerDownEvent when no ' + 'touchCallback provided', + () { + const pointerDownEvent = PointerDownEvent(); + final chart = TestRenderBaseChart( + mockContext, + TestTouchData( + false, + null, + null, + null, + ), + canBeScaled: true, + panGestureRecognizerOverride: panGestureRecognizer, + tapGestureRecognizerOverride: tapGestureRecognizer, + longPressGestureRecognizerOverride: longPressGestureRecognizer, + ); + + final hitTestEntry = BoxHitTestEntry( + chart, + Offset.zero, + ); + chart.handleEvent(pointerDownEvent, hitTestEntry); + + verifyNever(panGestureRecognizer.addPointer(pointerDownEvent)); + verifyNever(tapGestureRecognizer.addPointer(pointerDownEvent)); + verifyNever(longPressGestureRecognizer.addPointer(pointerDownEvent)); + }, + ); + + test('calls touchCallback for PointerHoverEvent', () { + late FlTouchEvent testEvent; + late LineTouchResponse? testResponse; + void callback(FlTouchEvent event, LineTouchResponse? response) { + testEvent = event; + testResponse = response; + } + + const pointerHoverEvent = PointerHoverEvent(); + final chart = TestRenderBaseChart( + mockContext, + TestTouchData( + false, + callback, + null, + null, + ), + canBeScaled: false, + panGestureRecognizerOverride: panGestureRecognizer, + tapGestureRecognizerOverride: tapGestureRecognizer, + longPressGestureRecognizerOverride: longPressGestureRecognizer, + ); + + final hitTestEntry = BoxHitTestEntry( + chart, + Offset.zero, + ); + chart.handleEvent(pointerHoverEvent, hitTestEntry); + + expect(testEvent, isA()); + expect(testResponse, isA()); + }); + }); + }); +} + +// Modify TestRenderBaseChart to track gesture recognizer calls +class TestRenderBaseChart extends RenderBaseChart { + TestRenderBaseChart( + BuildContext context, + FlTouchData? touchData, { + required bool canBeScaled, + required this.panGestureRecognizerOverride, + required this.tapGestureRecognizerOverride, + required this.longPressGestureRecognizerOverride, + }) : super(touchData, context, canBeScaled: canBeScaled); + + int panGestureAddPointerCallCount = 0; + int longPressGestureAddPointerCallCount = 0; + int tapGestureAddPointerCallCount = 0; + + final PanGestureRecognizer panGestureRecognizerOverride; + final TapGestureRecognizer tapGestureRecognizerOverride; + final LongPressGestureRecognizer longPressGestureRecognizerOverride; + + @override + void initGestureRecognizers() { + super.initGestureRecognizers(); + panGestureRecognizer = panGestureRecognizerOverride; + tapGestureRecognizer = tapGestureRecognizerOverride; + longPressGestureRecognizer = longPressGestureRecognizerOverride; + } + + @override + LineTouchResponse getResponseAtLocation(Offset localPosition) { + return const LineTouchResponse([]); + } +} + +class TestTouchData extends FlTouchData { + TestTouchData( + super.enabled, + super.touchCallback, + super.mouseCursorResolver, + super.longPressDuration, + ); +} diff --git a/test/chart/base/render_base_chart_test.mocks.dart b/test/chart/base/render_base_chart_test.mocks.dart new file mode 100644 index 000000000..6dd17827b --- /dev/null +++ b/test/chart/base/render_base_chart_test.mocks.dart @@ -0,0 +1,2060 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in fl_chart/test/chart/base/render_base_chart_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:ui' as _i12; + +import 'package:flutter/foundation.dart' as _i3; +import 'package:flutter/rendering.dart' as _i10; +import 'package:flutter/src/gestures/drag_details.dart' as _i9; +import 'package:flutter/src/gestures/events.dart' as _i11; +import 'package:flutter/src/gestures/long_press.dart' as _i14; +import 'package:flutter/src/gestures/monodrag.dart' as _i7; +import 'package:flutter/src/gestures/recognizer.dart' as _i5; +import 'package:flutter/src/gestures/tap.dart' as _i13; +import 'package:flutter/src/gestures/velocity_tracker.dart' as _i4; +import 'package:flutter/src/widgets/framework.dart' as _i2; +import 'package:flutter/src/widgets/notification_listener.dart' as _i6; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i8; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeWidget_0 extends _i1.SmartFake implements _i2.Widget { + _FakeWidget_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeInheritedWidget_1 extends _i1.SmartFake + implements _i2.InheritedWidget { + _FakeInheritedWidget_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeDiagnosticsNode_2 extends _i1.SmartFake + implements _i3.DiagnosticsNode { + _FakeDiagnosticsNode_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({ + _i3.TextTreeConfiguration? parentConfiguration, + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info, + }) => + super.toString(); +} + +class _FakeVelocityTracker_3 extends _i1.SmartFake + implements _i4.VelocityTracker { + _FakeVelocityTracker_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeOffsetPair_4 extends _i1.SmartFake implements _i5.OffsetPair { + _FakeOffsetPair_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [BuildContext]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockBuildContext extends _i1.Mock implements _i2.BuildContext { + MockBuildContext() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Widget get widget => (super.noSuchMethod( + Invocation.getter(#widget), + returnValue: _FakeWidget_0( + this, + Invocation.getter(#widget), + ), + ) as _i2.Widget); + + @override + bool get mounted => (super.noSuchMethod( + Invocation.getter(#mounted), + returnValue: false, + ) as bool); + + @override + bool get debugDoingBuild => (super.noSuchMethod( + Invocation.getter(#debugDoingBuild), + returnValue: false, + ) as bool); + + @override + _i2.InheritedWidget dependOnInheritedElement( + _i2.InheritedElement? ancestor, { + Object? aspect, + }) => + (super.noSuchMethod( + Invocation.method( + #dependOnInheritedElement, + [ancestor], + {#aspect: aspect}, + ), + returnValue: _FakeInheritedWidget_1( + this, + Invocation.method( + #dependOnInheritedElement, + [ancestor], + {#aspect: aspect}, + ), + ), + ) as _i2.InheritedWidget); + + @override + void visitAncestorElements(_i2.ConditionalElementVisitor? visitor) => + super.noSuchMethod( + Invocation.method( + #visitAncestorElements, + [visitor], + ), + returnValueForMissingStub: null, + ); + + @override + void visitChildElements(_i2.ElementVisitor? visitor) => super.noSuchMethod( + Invocation.method( + #visitChildElements, + [visitor], + ), + returnValueForMissingStub: null, + ); + + @override + void dispatchNotification(_i6.Notification? notification) => + super.noSuchMethod( + Invocation.method( + #dispatchNotification, + [notification], + ), + returnValueForMissingStub: null, + ); + + @override + _i3.DiagnosticsNode describeElement( + String? name, { + _i3.DiagnosticsTreeStyle? style = _i3.DiagnosticsTreeStyle.errorProperty, + }) => + (super.noSuchMethod( + Invocation.method( + #describeElement, + [name], + {#style: style}, + ), + returnValue: _FakeDiagnosticsNode_2( + this, + Invocation.method( + #describeElement, + [name], + {#style: style}, + ), + ), + ) as _i3.DiagnosticsNode); + + @override + _i3.DiagnosticsNode describeWidget( + String? name, { + _i3.DiagnosticsTreeStyle? style = _i3.DiagnosticsTreeStyle.errorProperty, + }) => + (super.noSuchMethod( + Invocation.method( + #describeWidget, + [name], + {#style: style}, + ), + returnValue: _FakeDiagnosticsNode_2( + this, + Invocation.method( + #describeWidget, + [name], + {#style: style}, + ), + ), + ) as _i3.DiagnosticsNode); + + @override + List<_i3.DiagnosticsNode> describeMissingAncestor( + {required Type? expectedAncestorType}) => + (super.noSuchMethod( + Invocation.method( + #describeMissingAncestor, + [], + {#expectedAncestorType: expectedAncestorType}, + ), + returnValue: <_i3.DiagnosticsNode>[], + ) as List<_i3.DiagnosticsNode>); + + @override + _i3.DiagnosticsNode describeOwnershipChain(String? name) => + (super.noSuchMethod( + Invocation.method( + #describeOwnershipChain, + [name], + ), + returnValue: _FakeDiagnosticsNode_2( + this, + Invocation.method( + #describeOwnershipChain, + [name], + ), + ), + ) as _i3.DiagnosticsNode); +} + +/// A class which mocks [PanGestureRecognizer]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPanGestureRecognizer extends _i1.Mock + implements _i7.PanGestureRecognizer { + MockPanGestureRecognizer() { + _i1.throwOnMissingStub(this); + } + + @override + String get debugDescription => (super.noSuchMethod( + Invocation.getter(#debugDescription), + returnValue: _i8.dummyValue( + this, + Invocation.getter(#debugDescription), + ), + ) as String); + + @override + _i5.DragStartBehavior get dragStartBehavior => (super.noSuchMethod( + Invocation.getter(#dragStartBehavior), + returnValue: _i5.DragStartBehavior.down, + ) as _i5.DragStartBehavior); + + @override + set dragStartBehavior(_i5.DragStartBehavior? _dragStartBehavior) => + super.noSuchMethod( + Invocation.setter( + #dragStartBehavior, + _dragStartBehavior, + ), + returnValueForMissingStub: null, + ); + + @override + _i5.MultitouchDragStrategy get multitouchDragStrategy => (super.noSuchMethod( + Invocation.getter(#multitouchDragStrategy), + returnValue: _i5.MultitouchDragStrategy.latestPointer, + ) as _i5.MultitouchDragStrategy); + + @override + set multitouchDragStrategy( + _i5.MultitouchDragStrategy? _multitouchDragStrategy) => + super.noSuchMethod( + Invocation.setter( + #multitouchDragStrategy, + _multitouchDragStrategy, + ), + returnValueForMissingStub: null, + ); + + @override + set onDown(_i9.GestureDragDownCallback? _onDown) => super.noSuchMethod( + Invocation.setter( + #onDown, + _onDown, + ), + returnValueForMissingStub: null, + ); + + @override + set onStart(_i9.GestureDragStartCallback? _onStart) => super.noSuchMethod( + Invocation.setter( + #onStart, + _onStart, + ), + returnValueForMissingStub: null, + ); + + @override + set onUpdate(_i9.GestureDragUpdateCallback? _onUpdate) => super.noSuchMethod( + Invocation.setter( + #onUpdate, + _onUpdate, + ), + returnValueForMissingStub: null, + ); + + @override + set onEnd(_i7.GestureDragEndCallback? _onEnd) => super.noSuchMethod( + Invocation.setter( + #onEnd, + _onEnd, + ), + returnValueForMissingStub: null, + ); + + @override + set onCancel(_i7.GestureDragCancelCallback? _onCancel) => super.noSuchMethod( + Invocation.setter( + #onCancel, + _onCancel, + ), + returnValueForMissingStub: null, + ); + + @override + set minFlingDistance(double? _minFlingDistance) => super.noSuchMethod( + Invocation.setter( + #minFlingDistance, + _minFlingDistance, + ), + returnValueForMissingStub: null, + ); + + @override + set minFlingVelocity(double? _minFlingVelocity) => super.noSuchMethod( + Invocation.setter( + #minFlingVelocity, + _minFlingVelocity, + ), + returnValueForMissingStub: null, + ); + + @override + set maxFlingVelocity(double? _maxFlingVelocity) => super.noSuchMethod( + Invocation.setter( + #maxFlingVelocity, + _maxFlingVelocity, + ), + returnValueForMissingStub: null, + ); + + @override + bool get onlyAcceptDragOnThreshold => (super.noSuchMethod( + Invocation.getter(#onlyAcceptDragOnThreshold), + returnValue: false, + ) as bool); + + @override + set onlyAcceptDragOnThreshold(bool? _onlyAcceptDragOnThreshold) => + super.noSuchMethod( + Invocation.setter( + #onlyAcceptDragOnThreshold, + _onlyAcceptDragOnThreshold, + ), + returnValueForMissingStub: null, + ); + + @override + _i7.GestureVelocityTrackerBuilder get velocityTrackerBuilder => + (super.noSuchMethod( + Invocation.getter(#velocityTrackerBuilder), + returnValue: (_i10.PointerEvent event) => _FakeVelocityTracker_3( + this, + Invocation.getter(#velocityTrackerBuilder), + ), + ) as _i7.GestureVelocityTrackerBuilder); + + @override + set velocityTrackerBuilder( + _i7.GestureVelocityTrackerBuilder? _velocityTrackerBuilder) => + super.noSuchMethod( + Invocation.setter( + #velocityTrackerBuilder, + _velocityTrackerBuilder, + ), + returnValueForMissingStub: null, + ); + + @override + _i5.OffsetPair get lastPosition => (super.noSuchMethod( + Invocation.getter(#lastPosition), + returnValue: _FakeOffsetPair_4( + this, + Invocation.getter(#lastPosition), + ), + ) as _i5.OffsetPair); + + @override + double get globalDistanceMoved => (super.noSuchMethod( + Invocation.getter(#globalDistanceMoved), + returnValue: 0.0, + ) as double); + + @override + set team(_i5.GestureArenaTeam? value) => super.noSuchMethod( + Invocation.setter( + #team, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set gestureSettings(_i11.DeviceGestureSettings? _gestureSettings) => + super.noSuchMethod( + Invocation.setter( + #gestureSettings, + _gestureSettings, + ), + returnValueForMissingStub: null, + ); + + @override + set supportedDevices(Set<_i12.PointerDeviceKind>? _supportedDevices) => + super.noSuchMethod( + Invocation.setter( + #supportedDevices, + _supportedDevices, + ), + returnValueForMissingStub: null, + ); + + @override + _i5.AllowedButtonsFilter get allowedButtonsFilter => (super.noSuchMethod( + Invocation.getter(#allowedButtonsFilter), + returnValue: (int buttons) => false, + ) as _i5.AllowedButtonsFilter); + + @override + bool isFlingGesture( + _i4.VelocityEstimate? estimate, + _i12.PointerDeviceKind? kind, + ) => + (super.noSuchMethod( + Invocation.method( + #isFlingGesture, + [ + estimate, + kind, + ], + ), + returnValue: false, + ) as bool); + + @override + _i9.DragEndDetails? considerFling( + _i4.VelocityEstimate? estimate, + _i12.PointerDeviceKind? kind, + ) => + (super.noSuchMethod(Invocation.method( + #considerFling, + [ + estimate, + kind, + ], + )) as _i9.DragEndDetails?); + + @override + bool hasSufficientGlobalDistanceToAccept( + _i12.PointerDeviceKind? pointerDeviceKind, + double? deviceTouchSlop, + ) => + (super.noSuchMethod( + Invocation.method( + #hasSufficientGlobalDistanceToAccept, + [ + pointerDeviceKind, + deviceTouchSlop, + ], + ), + returnValue: false, + ) as bool); + + @override + bool isPointerAllowed(_i10.PointerEvent? event) => (super.noSuchMethod( + Invocation.method( + #isPointerAllowed, + [event], + ), + returnValue: false, + ) as bool); + + @override + void addAllowedPointer(_i10.PointerDownEvent? event) => super.noSuchMethod( + Invocation.method( + #addAllowedPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addAllowedPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #addAllowedPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void handleEvent(_i10.PointerEvent? event) => super.noSuchMethod( + Invocation.method( + #handleEvent, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void acceptGesture(int? pointer) => super.noSuchMethod( + Invocation.method( + #acceptGesture, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void rejectGesture(int? pointer) => super.noSuchMethod( + Invocation.method( + #rejectGesture, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void didStopTrackingLastPointer(int? pointer) => super.noSuchMethod( + Invocation.method( + #didStopTrackingLastPointer, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void debugFillProperties(_i3.DiagnosticPropertiesBuilder? properties) => + super.noSuchMethod( + Invocation.method( + #debugFillProperties, + [properties], + ), + returnValueForMissingStub: null, + ); + + @override + void handleNonAllowedPointer(_i10.PointerDownEvent? event) => + super.noSuchMethod( + Invocation.method( + #handleNonAllowedPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void resolve(_i5.GestureDisposition? disposition) => super.noSuchMethod( + Invocation.method( + #resolve, + [disposition], + ), + returnValueForMissingStub: null, + ); + + @override + void resolvePointer( + int? pointer, + _i5.GestureDisposition? disposition, + ) => + super.noSuchMethod( + Invocation.method( + #resolvePointer, + [ + pointer, + disposition, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void startTrackingPointer( + int? pointer, [ + _i10.Matrix4? transform, + ]) => + super.noSuchMethod( + Invocation.method( + #startTrackingPointer, + [ + pointer, + transform, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void stopTrackingPointer(int? pointer) => super.noSuchMethod( + Invocation.method( + #stopTrackingPointer, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void stopTrackingIfPointerNoLongerDown(_i10.PointerEvent? event) => + super.noSuchMethod( + Invocation.method( + #stopTrackingIfPointerNoLongerDown, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #addPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addPointer(_i10.PointerDownEvent? event) => super.noSuchMethod( + Invocation.method( + #addPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void handleNonAllowedPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #handleNonAllowedPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + bool isPointerPanZoomAllowed(_i11.PointerPanZoomStartEvent? event) => + (super.noSuchMethod( + Invocation.method( + #isPointerPanZoomAllowed, + [event], + ), + returnValue: false, + ) as bool); + + @override + _i12.PointerDeviceKind getKindForPointer(int? pointer) => (super.noSuchMethod( + Invocation.method( + #getKindForPointer, + [pointer], + ), + returnValue: _i12.PointerDeviceKind.touch, + ) as _i12.PointerDeviceKind); + + @override + T? invokeCallback( + String? name, + _i5.RecognizerCallback? callback, { + String Function()? debugReport, + }) => + (super.noSuchMethod(Invocation.method( + #invokeCallback, + [ + name, + callback, + ], + {#debugReport: debugReport}, + )) as T?); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); + + @override + String toStringShallow({ + String? joiner = r', ', + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + }) => + (super.noSuchMethod( + Invocation.method( + #toStringShallow, + [], + { + #joiner: joiner, + #minLevel: minLevel, + }, + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringShallow, + [], + { + #joiner: joiner, + #minLevel: minLevel, + }, + ), + ), + ) as String); + + @override + String toStringDeep({ + String? prefixLineOne = r'', + String? prefixOtherLines, + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + int? wrapWidth = 65, + }) => + (super.noSuchMethod( + Invocation.method( + #toStringDeep, + [], + { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }, + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringDeep, + [], + { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }, + ), + ), + ) as String); + + @override + String toStringShort() => (super.noSuchMethod( + Invocation.method( + #toStringShort, + [], + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringShort, + [], + ), + ), + ) as String); + + @override + _i3.DiagnosticsNode toDiagnosticsNode({ + String? name, + _i3.DiagnosticsTreeStyle? style, + }) => + (super.noSuchMethod( + Invocation.method( + #toDiagnosticsNode, + [], + { + #name: name, + #style: style, + }, + ), + returnValue: _FakeDiagnosticsNode_2( + this, + Invocation.method( + #toDiagnosticsNode, + [], + { + #name: name, + #style: style, + }, + ), + ), + ) as _i3.DiagnosticsNode); + + @override + List<_i3.DiagnosticsNode> debugDescribeChildren() => (super.noSuchMethod( + Invocation.method( + #debugDescribeChildren, + [], + ), + returnValue: <_i3.DiagnosticsNode>[], + ) as List<_i3.DiagnosticsNode>); +} + +/// A class which mocks [TapGestureRecognizer]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTapGestureRecognizer extends _i1.Mock + implements _i13.TapGestureRecognizer { + MockTapGestureRecognizer() { + _i1.throwOnMissingStub(this); + } + + @override + set onTapDown(_i13.GestureTapDownCallback? _onTapDown) => super.noSuchMethod( + Invocation.setter( + #onTapDown, + _onTapDown, + ), + returnValueForMissingStub: null, + ); + + @override + set onTapUp(_i13.GestureTapUpCallback? _onTapUp) => super.noSuchMethod( + Invocation.setter( + #onTapUp, + _onTapUp, + ), + returnValueForMissingStub: null, + ); + + @override + set onTap(_i13.GestureTapCallback? _onTap) => super.noSuchMethod( + Invocation.setter( + #onTap, + _onTap, + ), + returnValueForMissingStub: null, + ); + + @override + set onTapCancel(_i13.GestureTapCancelCallback? _onTapCancel) => + super.noSuchMethod( + Invocation.setter( + #onTapCancel, + _onTapCancel, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryTap(_i13.GestureTapCallback? _onSecondaryTap) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryTap, + _onSecondaryTap, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryTapDown(_i13.GestureTapDownCallback? _onSecondaryTapDown) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryTapDown, + _onSecondaryTapDown, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryTapUp(_i13.GestureTapUpCallback? _onSecondaryTapUp) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryTapUp, + _onSecondaryTapUp, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryTapCancel( + _i13.GestureTapCancelCallback? _onSecondaryTapCancel) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryTapCancel, + _onSecondaryTapCancel, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryTapDown(_i13.GestureTapDownCallback? _onTertiaryTapDown) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryTapDown, + _onTertiaryTapDown, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryTapUp(_i13.GestureTapUpCallback? _onTertiaryTapUp) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryTapUp, + _onTertiaryTapUp, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryTapCancel( + _i13.GestureTapCancelCallback? _onTertiaryTapCancel) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryTapCancel, + _onTertiaryTapCancel, + ), + returnValueForMissingStub: null, + ); + + @override + String get debugDescription => (super.noSuchMethod( + Invocation.getter(#debugDescription), + returnValue: _i8.dummyValue( + this, + Invocation.getter(#debugDescription), + ), + ) as String); + + @override + _i5.GestureRecognizerState get state => (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _i5.GestureRecognizerState.ready, + ) as _i5.GestureRecognizerState); + + @override + set team(_i5.GestureArenaTeam? value) => super.noSuchMethod( + Invocation.setter( + #team, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set gestureSettings(_i11.DeviceGestureSettings? _gestureSettings) => + super.noSuchMethod( + Invocation.setter( + #gestureSettings, + _gestureSettings, + ), + returnValueForMissingStub: null, + ); + + @override + set supportedDevices(Set<_i12.PointerDeviceKind>? _supportedDevices) => + super.noSuchMethod( + Invocation.setter( + #supportedDevices, + _supportedDevices, + ), + returnValueForMissingStub: null, + ); + + @override + _i5.AllowedButtonsFilter get allowedButtonsFilter => (super.noSuchMethod( + Invocation.getter(#allowedButtonsFilter), + returnValue: (int buttons) => false, + ) as _i5.AllowedButtonsFilter); + + @override + bool isPointerAllowed(_i10.PointerDownEvent? event) => (super.noSuchMethod( + Invocation.method( + #isPointerAllowed, + [event], + ), + returnValue: false, + ) as bool); + + @override + void handleTapDown({required _i10.PointerDownEvent? down}) => + super.noSuchMethod( + Invocation.method( + #handleTapDown, + [], + {#down: down}, + ), + returnValueForMissingStub: null, + ); + + @override + void handleTapUp({ + required _i10.PointerDownEvent? down, + required _i10.PointerUpEvent? up, + }) => + super.noSuchMethod( + Invocation.method( + #handleTapUp, + [], + { + #down: down, + #up: up, + }, + ), + returnValueForMissingStub: null, + ); + + @override + void handleTapCancel({ + required _i10.PointerDownEvent? down, + _i10.PointerCancelEvent? cancel, + required String? reason, + }) => + super.noSuchMethod( + Invocation.method( + #handleTapCancel, + [], + { + #down: down, + #cancel: cancel, + #reason: reason, + }, + ), + returnValueForMissingStub: null, + ); + + @override + void addAllowedPointer(_i10.PointerDownEvent? event) => super.noSuchMethod( + Invocation.method( + #addAllowedPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void startTrackingPointer( + int? pointer, [ + _i10.Matrix4? transform, + ]) => + super.noSuchMethod( + Invocation.method( + #startTrackingPointer, + [ + pointer, + transform, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void handlePrimaryPointer(_i10.PointerEvent? event) => super.noSuchMethod( + Invocation.method( + #handlePrimaryPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void resolve(_i5.GestureDisposition? disposition) => super.noSuchMethod( + Invocation.method( + #resolve, + [disposition], + ), + returnValueForMissingStub: null, + ); + + @override + void didExceedDeadline() => super.noSuchMethod( + Invocation.method( + #didExceedDeadline, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void acceptGesture(int? pointer) => super.noSuchMethod( + Invocation.method( + #acceptGesture, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void rejectGesture(int? pointer) => super.noSuchMethod( + Invocation.method( + #rejectGesture, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void debugFillProperties(_i3.DiagnosticPropertiesBuilder? properties) => + super.noSuchMethod( + Invocation.method( + #debugFillProperties, + [properties], + ), + returnValueForMissingStub: null, + ); + + @override + void handleNonAllowedPointer(_i10.PointerDownEvent? event) => + super.noSuchMethod( + Invocation.method( + #handleNonAllowedPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void handleEvent(_i10.PointerEvent? event) => super.noSuchMethod( + Invocation.method( + #handleEvent, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void didExceedDeadlineWithEvent(_i10.PointerDownEvent? event) => + super.noSuchMethod( + Invocation.method( + #didExceedDeadlineWithEvent, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void didStopTrackingLastPointer(int? pointer) => super.noSuchMethod( + Invocation.method( + #didStopTrackingLastPointer, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void resolvePointer( + int? pointer, + _i5.GestureDisposition? disposition, + ) => + super.noSuchMethod( + Invocation.method( + #resolvePointer, + [ + pointer, + disposition, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void stopTrackingPointer(int? pointer) => super.noSuchMethod( + Invocation.method( + #stopTrackingPointer, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void stopTrackingIfPointerNoLongerDown(_i10.PointerEvent? event) => + super.noSuchMethod( + Invocation.method( + #stopTrackingIfPointerNoLongerDown, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #addPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addAllowedPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #addAllowedPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addPointer(_i10.PointerDownEvent? event) => super.noSuchMethod( + Invocation.method( + #addPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void handleNonAllowedPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #handleNonAllowedPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + bool isPointerPanZoomAllowed(_i11.PointerPanZoomStartEvent? event) => + (super.noSuchMethod( + Invocation.method( + #isPointerPanZoomAllowed, + [event], + ), + returnValue: false, + ) as bool); + + @override + _i12.PointerDeviceKind getKindForPointer(int? pointer) => (super.noSuchMethod( + Invocation.method( + #getKindForPointer, + [pointer], + ), + returnValue: _i12.PointerDeviceKind.touch, + ) as _i12.PointerDeviceKind); + + @override + T? invokeCallback( + String? name, + _i5.RecognizerCallback? callback, { + String Function()? debugReport, + }) => + (super.noSuchMethod(Invocation.method( + #invokeCallback, + [ + name, + callback, + ], + {#debugReport: debugReport}, + )) as T?); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); + + @override + String toStringShallow({ + String? joiner = r', ', + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + }) => + (super.noSuchMethod( + Invocation.method( + #toStringShallow, + [], + { + #joiner: joiner, + #minLevel: minLevel, + }, + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringShallow, + [], + { + #joiner: joiner, + #minLevel: minLevel, + }, + ), + ), + ) as String); + + @override + String toStringDeep({ + String? prefixLineOne = r'', + String? prefixOtherLines, + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + int? wrapWidth = 65, + }) => + (super.noSuchMethod( + Invocation.method( + #toStringDeep, + [], + { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }, + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringDeep, + [], + { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }, + ), + ), + ) as String); + + @override + String toStringShort() => (super.noSuchMethod( + Invocation.method( + #toStringShort, + [], + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringShort, + [], + ), + ), + ) as String); + + @override + _i3.DiagnosticsNode toDiagnosticsNode({ + String? name, + _i3.DiagnosticsTreeStyle? style, + }) => + (super.noSuchMethod( + Invocation.method( + #toDiagnosticsNode, + [], + { + #name: name, + #style: style, + }, + ), + returnValue: _FakeDiagnosticsNode_2( + this, + Invocation.method( + #toDiagnosticsNode, + [], + { + #name: name, + #style: style, + }, + ), + ), + ) as _i3.DiagnosticsNode); + + @override + List<_i3.DiagnosticsNode> debugDescribeChildren() => (super.noSuchMethod( + Invocation.method( + #debugDescribeChildren, + [], + ), + returnValue: <_i3.DiagnosticsNode>[], + ) as List<_i3.DiagnosticsNode>); +} + +/// A class which mocks [LongPressGestureRecognizer]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLongPressGestureRecognizer extends _i1.Mock + implements _i14.LongPressGestureRecognizer { + MockLongPressGestureRecognizer() { + _i1.throwOnMissingStub(this); + } + + @override + set onLongPressDown(_i14.GestureLongPressDownCallback? _onLongPressDown) => + super.noSuchMethod( + Invocation.setter( + #onLongPressDown, + _onLongPressDown, + ), + returnValueForMissingStub: null, + ); + + @override + set onLongPressCancel( + _i14.GestureLongPressCancelCallback? _onLongPressCancel) => + super.noSuchMethod( + Invocation.setter( + #onLongPressCancel, + _onLongPressCancel, + ), + returnValueForMissingStub: null, + ); + + @override + set onLongPress(_i14.GestureLongPressCallback? _onLongPress) => + super.noSuchMethod( + Invocation.setter( + #onLongPress, + _onLongPress, + ), + returnValueForMissingStub: null, + ); + + @override + set onLongPressStart(_i14.GestureLongPressStartCallback? _onLongPressStart) => + super.noSuchMethod( + Invocation.setter( + #onLongPressStart, + _onLongPressStart, + ), + returnValueForMissingStub: null, + ); + + @override + set onLongPressMoveUpdate( + _i14.GestureLongPressMoveUpdateCallback? _onLongPressMoveUpdate) => + super.noSuchMethod( + Invocation.setter( + #onLongPressMoveUpdate, + _onLongPressMoveUpdate, + ), + returnValueForMissingStub: null, + ); + + @override + set onLongPressUp(_i14.GestureLongPressUpCallback? _onLongPressUp) => + super.noSuchMethod( + Invocation.setter( + #onLongPressUp, + _onLongPressUp, + ), + returnValueForMissingStub: null, + ); + + @override + set onLongPressEnd(_i14.GestureLongPressEndCallback? _onLongPressEnd) => + super.noSuchMethod( + Invocation.setter( + #onLongPressEnd, + _onLongPressEnd, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryLongPressDown( + _i14.GestureLongPressDownCallback? _onSecondaryLongPressDown) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryLongPressDown, + _onSecondaryLongPressDown, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryLongPressCancel( + _i14.GestureLongPressCancelCallback? _onSecondaryLongPressCancel) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryLongPressCancel, + _onSecondaryLongPressCancel, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryLongPress( + _i14.GestureLongPressCallback? _onSecondaryLongPress) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryLongPress, + _onSecondaryLongPress, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryLongPressStart( + _i14.GestureLongPressStartCallback? _onSecondaryLongPressStart) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryLongPressStart, + _onSecondaryLongPressStart, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryLongPressMoveUpdate( + _i14.GestureLongPressMoveUpdateCallback? + _onSecondaryLongPressMoveUpdate) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryLongPressMoveUpdate, + _onSecondaryLongPressMoveUpdate, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryLongPressUp( + _i14.GestureLongPressUpCallback? _onSecondaryLongPressUp) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryLongPressUp, + _onSecondaryLongPressUp, + ), + returnValueForMissingStub: null, + ); + + @override + set onSecondaryLongPressEnd( + _i14.GestureLongPressEndCallback? _onSecondaryLongPressEnd) => + super.noSuchMethod( + Invocation.setter( + #onSecondaryLongPressEnd, + _onSecondaryLongPressEnd, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryLongPressDown( + _i14.GestureLongPressDownCallback? _onTertiaryLongPressDown) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryLongPressDown, + _onTertiaryLongPressDown, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryLongPressCancel( + _i14.GestureLongPressCancelCallback? _onTertiaryLongPressCancel) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryLongPressCancel, + _onTertiaryLongPressCancel, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryLongPress( + _i14.GestureLongPressCallback? _onTertiaryLongPress) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryLongPress, + _onTertiaryLongPress, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryLongPressStart( + _i14.GestureLongPressStartCallback? _onTertiaryLongPressStart) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryLongPressStart, + _onTertiaryLongPressStart, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryLongPressMoveUpdate( + _i14.GestureLongPressMoveUpdateCallback? + _onTertiaryLongPressMoveUpdate) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryLongPressMoveUpdate, + _onTertiaryLongPressMoveUpdate, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryLongPressUp( + _i14.GestureLongPressUpCallback? _onTertiaryLongPressUp) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryLongPressUp, + _onTertiaryLongPressUp, + ), + returnValueForMissingStub: null, + ); + + @override + set onTertiaryLongPressEnd( + _i14.GestureLongPressEndCallback? _onTertiaryLongPressEnd) => + super.noSuchMethod( + Invocation.setter( + #onTertiaryLongPressEnd, + _onTertiaryLongPressEnd, + ), + returnValueForMissingStub: null, + ); + + @override + String get debugDescription => (super.noSuchMethod( + Invocation.getter(#debugDescription), + returnValue: _i8.dummyValue( + this, + Invocation.getter(#debugDescription), + ), + ) as String); + + @override + _i5.GestureRecognizerState get state => (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _i5.GestureRecognizerState.ready, + ) as _i5.GestureRecognizerState); + + @override + set team(_i5.GestureArenaTeam? value) => super.noSuchMethod( + Invocation.setter( + #team, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set gestureSettings(_i11.DeviceGestureSettings? _gestureSettings) => + super.noSuchMethod( + Invocation.setter( + #gestureSettings, + _gestureSettings, + ), + returnValueForMissingStub: null, + ); + + @override + set supportedDevices(Set<_i12.PointerDeviceKind>? _supportedDevices) => + super.noSuchMethod( + Invocation.setter( + #supportedDevices, + _supportedDevices, + ), + returnValueForMissingStub: null, + ); + + @override + _i5.AllowedButtonsFilter get allowedButtonsFilter => (super.noSuchMethod( + Invocation.getter(#allowedButtonsFilter), + returnValue: (int buttons) => false, + ) as _i5.AllowedButtonsFilter); + + @override + bool isPointerAllowed(_i10.PointerDownEvent? event) => (super.noSuchMethod( + Invocation.method( + #isPointerAllowed, + [event], + ), + returnValue: false, + ) as bool); + + @override + void didExceedDeadline() => super.noSuchMethod( + Invocation.method( + #didExceedDeadline, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void handlePrimaryPointer(_i10.PointerEvent? event) => super.noSuchMethod( + Invocation.method( + #handlePrimaryPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void resolve(_i5.GestureDisposition? disposition) => super.noSuchMethod( + Invocation.method( + #resolve, + [disposition], + ), + returnValueForMissingStub: null, + ); + + @override + void acceptGesture(int? pointer) => super.noSuchMethod( + Invocation.method( + #acceptGesture, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void addAllowedPointer(_i10.PointerDownEvent? event) => super.noSuchMethod( + Invocation.method( + #addAllowedPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void handleNonAllowedPointer(_i10.PointerDownEvent? event) => + super.noSuchMethod( + Invocation.method( + #handleNonAllowedPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void handleEvent(_i10.PointerEvent? event) => super.noSuchMethod( + Invocation.method( + #handleEvent, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void didExceedDeadlineWithEvent(_i10.PointerDownEvent? event) => + super.noSuchMethod( + Invocation.method( + #didExceedDeadlineWithEvent, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void rejectGesture(int? pointer) => super.noSuchMethod( + Invocation.method( + #rejectGesture, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void didStopTrackingLastPointer(int? pointer) => super.noSuchMethod( + Invocation.method( + #didStopTrackingLastPointer, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void debugFillProperties(_i3.DiagnosticPropertiesBuilder? properties) => + super.noSuchMethod( + Invocation.method( + #debugFillProperties, + [properties], + ), + returnValueForMissingStub: null, + ); + + @override + void resolvePointer( + int? pointer, + _i5.GestureDisposition? disposition, + ) => + super.noSuchMethod( + Invocation.method( + #resolvePointer, + [ + pointer, + disposition, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void startTrackingPointer( + int? pointer, [ + _i10.Matrix4? transform, + ]) => + super.noSuchMethod( + Invocation.method( + #startTrackingPointer, + [ + pointer, + transform, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void stopTrackingPointer(int? pointer) => super.noSuchMethod( + Invocation.method( + #stopTrackingPointer, + [pointer], + ), + returnValueForMissingStub: null, + ); + + @override + void stopTrackingIfPointerNoLongerDown(_i10.PointerEvent? event) => + super.noSuchMethod( + Invocation.method( + #stopTrackingIfPointerNoLongerDown, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #addPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addAllowedPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #addAllowedPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void addPointer(_i10.PointerDownEvent? event) => super.noSuchMethod( + Invocation.method( + #addPointer, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + void handleNonAllowedPointerPanZoom(_i11.PointerPanZoomStartEvent? event) => + super.noSuchMethod( + Invocation.method( + #handleNonAllowedPointerPanZoom, + [event], + ), + returnValueForMissingStub: null, + ); + + @override + bool isPointerPanZoomAllowed(_i11.PointerPanZoomStartEvent? event) => + (super.noSuchMethod( + Invocation.method( + #isPointerPanZoomAllowed, + [event], + ), + returnValue: false, + ) as bool); + + @override + _i12.PointerDeviceKind getKindForPointer(int? pointer) => (super.noSuchMethod( + Invocation.method( + #getKindForPointer, + [pointer], + ), + returnValue: _i12.PointerDeviceKind.touch, + ) as _i12.PointerDeviceKind); + + @override + T? invokeCallback( + String? name, + _i5.RecognizerCallback? callback, { + String Function()? debugReport, + }) => + (super.noSuchMethod(Invocation.method( + #invokeCallback, + [ + name, + callback, + ], + {#debugReport: debugReport}, + )) as T?); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); + + @override + String toStringShallow({ + String? joiner = r', ', + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + }) => + (super.noSuchMethod( + Invocation.method( + #toStringShallow, + [], + { + #joiner: joiner, + #minLevel: minLevel, + }, + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringShallow, + [], + { + #joiner: joiner, + #minLevel: minLevel, + }, + ), + ), + ) as String); + + @override + String toStringDeep({ + String? prefixLineOne = r'', + String? prefixOtherLines, + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + int? wrapWidth = 65, + }) => + (super.noSuchMethod( + Invocation.method( + #toStringDeep, + [], + { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }, + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringDeep, + [], + { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }, + ), + ), + ) as String); + + @override + String toStringShort() => (super.noSuchMethod( + Invocation.method( + #toStringShort, + [], + ), + returnValue: _i8.dummyValue( + this, + Invocation.method( + #toStringShort, + [], + ), + ), + ) as String); + + @override + _i3.DiagnosticsNode toDiagnosticsNode({ + String? name, + _i3.DiagnosticsTreeStyle? style, + }) => + (super.noSuchMethod( + Invocation.method( + #toDiagnosticsNode, + [], + { + #name: name, + #style: style, + }, + ), + returnValue: _FakeDiagnosticsNode_2( + this, + Invocation.method( + #toDiagnosticsNode, + [], + { + #name: name, + #style: style, + }, + ), + ), + ) as _i3.DiagnosticsNode); + + @override + List<_i3.DiagnosticsNode> debugDescribeChildren() => (super.noSuchMethod( + Invocation.method( + #debugDescribeChildren, + [], + ), + returnValue: <_i3.DiagnosticsNode>[], + ) as List<_i3.DiagnosticsNode>); +} diff --git a/test/chart/line_chart/line_chart_painter_test.dart b/test/chart/line_chart/line_chart_painter_test.dart index 9a28d6e2a..cfecb48c4 100644 --- a/test/chart/line_chart/line_chart_painter_test.dart +++ b/test/chart/line_chart/line_chart_painter_test.dart @@ -226,6 +226,111 @@ void main() { } expect(exception != null, true); }); + + test('test 3 minY == maxY', () { + const viewSize = Size(400, 400); + + final bar1 = LineChartBarData( + spots: const [ + FlSpot(0, 4), + FlSpot(1, 3), + FlSpot(2, 2), + FlSpot(3, 1), + FlSpot(4, 0), + ], + showingIndicators: [ + 0, + 2, + 3, + ], + ); + final bar2 = LineChartBarData( + spots: const [ + FlSpot(0, 5), + FlSpot(1, 3), + FlSpot(2, 2), + FlSpot(3, 5), + FlSpot(4, 0), + ], + ); + + final lineChartBarsData = [bar1, bar2]; + final (minX, maxX, minY, maxY) = LineChartHelper().calculateMaxAxisValues( + lineChartBarsData, + ); + + final data = LineChartData( + minX: minX, + maxX: maxX, + minY: minY, + maxY: minY, + lineBarsData: lineChartBarsData, + clipData: const FlClipData.all(), + extraLinesData: ExtraLinesData( + horizontalLines: [ + HorizontalLine(y: 1), + ], + verticalLines: [ + VerticalLine(x: 4), + ], + ), + betweenBarsData: [ + BetweenBarsData(fromIndex: 0, toIndex: 1), + ], + showingTooltipIndicators: [ + ShowingTooltipIndicators([ + LineBarSpot(bar1, 0, bar1.spots.first), + LineBarSpot(bar2, 1, bar2.spots.first), + ]), + ], + lineTouchData: LineTouchData( + getTouchedSpotIndicator: + (LineChartBarData barData, List spotIndexes) { + return spotIndexes.asMap().entries.map((entry) { + final i = entry.key; + if (i == 0) { + return null; + } + return const TouchedSpotIndicatorData( + FlLine(color: MockData.color0), + FlDotData(), + ); + }).toList(); + }, + ), + ); + + final lineChartPainter = LineChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + + final mockUtils = MockUtils(); + Utils.changeInstance(mockUtils); + when(mockUtils.getThemeAwareTextStyle(any, any)) + .thenAnswer((realInvocation) => textStyle1); + when(mockUtils.calculateRotationOffset(any, any)) + .thenAnswer((realInvocation) => Offset.zero); + when(mockUtils.convertRadiusToSigma(any)) + .thenAnswer((realInvocation) => 4.0); + when(mockUtils.getEfficientInterval(any, any)) + .thenAnswer((realInvocation) => 1.0); + when(mockUtils.getBestInitialIntervalValue(any, any, any)) + .thenAnswer((realInvocation) => 1.0); + + final mockBuildContext = MockBuildContext(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + lineChartPainter.paint( + mockBuildContext, + mockCanvasWrapper, + holder, + ); + + verify(mockCanvasWrapper.clipRect(any)).called(1); + verify(mockCanvasWrapper.drawDot(any, any, any)).called(12); + verify(mockCanvasWrapper.drawPath(any, any)).called(3); + }); }); group('clipToBorder()', () { @@ -2388,6 +2493,74 @@ void main() { expect(offset3, const Offset(20, -22)); expect(offset4, const Offset(80, 38)); }); + + test( + 'should restore canvas before drawing extra lines and clip after ' + 'when chart virtual rect is provided', () { + const viewSize = Size(100, 100); + final data = LineChartData( + minY: 0, + maxY: 10, + minX: 0, + maxX: 10, + titlesData: const FlTitlesData(show: false), + extraLinesData: ExtraLinesData( + verticalLines: [ + VerticalLine( + x: 0, + color: Colors.cyanAccent, + dashArray: [12, 22], + ), + VerticalLine( + x: 10, + color: Colors.cyanAccent, + dashArray: [12, 22], + ), + ], + ), + ); + + final lineChartPainter = LineChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + Offset.zero & viewSize, + ); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + final mockBuildContext = MockBuildContext(); + + lineChartPainter.drawExtraLines( + mockBuildContext, + mockCanvasWrapper, + holder, + ); + + final viewRect = Offset.zero & viewSize; + verifyInOrder([ + mockCanvasWrapper.restore(), + mockCanvasWrapper.drawDashedLine( + any, + any, + argThat( + const TypeMatcher().having( + (p0) => p0.color, + 'colors match', + isSameColorAs(Colors.cyanAccent), + ), + ), + holder.data.extraLinesData.verticalLines[0].dashArray, + ), + mockCanvasWrapper.saveLayer( + viewRect, + any, + ), + mockCanvasWrapper.clipRect(viewRect), + ]); + }); }); group('drawTouchTooltip()', () { @@ -2723,6 +2896,252 @@ void main() { expect((textPainter.text as TextSpan?)!.style, textStyle1); expect(drawOffset, const Offset(22, 52)); }); + + test('does not draw tooltip if it is outside of the chart virtual rect', + () { + const viewSize = Size(100, 100); + final chartVirtualRect = Offset.zero & const Size(200, 200); + + final barData = LineChartBarData( + spots: const [ + FlSpot(1, 1), + FlSpot(2, 2), + FlSpot(3, 3), + FlSpot(4, 4), + FlSpot.nullSpot, + FlSpot(5, 5), + ], + ); + + final tooltipData = LineTouchTooltipData( + getTooltipColor: (touchedSpot) => const Color(0x11111111), + tooltipRoundedRadius: 12, + rotateAngle: 43, + maxContentWidth: 100, + tooltipMargin: 12, + tooltipHorizontalAlignment: FLHorizontalAlignment.right, + tooltipPadding: const EdgeInsets.all(12), + fitInsideVertically: true, + getTooltipItems: (List touchedSpots) { + return touchedSpots + .map((e) => LineTooltipItem(e.barIndex.toString(), textStyle1)) + .toList(); + }, + tooltipBorder: const BorderSide(color: Color(0x11111111), width: 2), + ); + final data = LineChartData( + minY: 0, + maxY: 10, + minX: 0, + maxX: 10, + titlesData: const FlTitlesData(show: false), + lineTouchData: LineTouchData( + touchTooltipData: tooltipData, + ), + ); + + final lineChartPainter = LineChartPainter(); + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + chartVirtualRect, + ); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + final mockBuildContext = MockBuildContext(); + final mockUtils = MockUtils(); + Utils.changeInstance(mockUtils); + when(mockUtils.getThemeAwareTextStyle(any, any)) + .thenAnswer((realInvocation) => textStyle1); + when(mockUtils.calculateRotationOffset(any, any)) + .thenAnswer((realInvocation) => Offset.zero); + + lineChartPainter.drawTouchTooltip( + mockBuildContext, + mockCanvasWrapper, + tooltipData, + barData.spots.first, + ShowingTooltipIndicators([ + LineBarSpot( + barData, + 0, + barData.spots.first, + ), + ]), + holder, + ); + + verifyNever( + mockCanvasWrapper.drawRotated( + size: anyNamed('size'), + rotationOffset: anyNamed('rotationOffset'), + drawOffset: anyNamed('drawOffset'), + angle: anyNamed('angle'), + drawCallback: anyNamed('drawCallback'), + ), + ); + }); + + test( + 'takes dotHeight into account when deciding if tooltip should be drawn', + () { + const viewSize = Size(100, 100); + const dotRadius = 4.0; + const smallerDotRadius = 3.0; + const dotStrokeWidth = 1.0; + + final barData = LineChartBarData( + spots: const [ + FlSpot(1, 1), + FlSpot(2, 2), + FlSpot(3, 3), + FlSpot(4, 4), + FlSpot.nullSpot, + FlSpot(5, 5), + ], + ); + + final tooltipData = LineTouchTooltipData( + getTooltipColor: (touchedSpot) => const Color(0x11111111), + tooltipRoundedRadius: 12, + rotateAngle: 43, + maxContentWidth: 100, + tooltipMargin: 12, + tooltipHorizontalAlignment: FLHorizontalAlignment.right, + tooltipPadding: const EdgeInsets.all(12), + fitInsideVertically: true, + getTooltipItems: (List touchedSpots) { + return touchedSpots + .map((e) => LineTooltipItem(e.barIndex.toString(), textStyle1)) + .toList(); + }, + tooltipBorder: const BorderSide(color: Color(0x11111111), width: 2), + ); + final data = LineChartData( + minY: 0, + maxY: 10, + minX: 0, + maxX: 10, + titlesData: const FlTitlesData(show: false), + lineBarsData: [barData], + lineTouchData: LineTouchData( + touchTooltipData: tooltipData, + getTouchedSpotIndicator: (barData, spotIndexes) => [ + TouchedSpotIndicatorData( + const FlLine(color: Colors.red, strokeWidth: 1), + FlDotData( + getDotPainter: ( + FlSpot spot, + double xPercentage, + LineChartBarData bar, + int index, { + double? size, + }) => + FlDotCirclePainter( + color: Colors.red, + // smaller first dot ensures we're actually iterating over + // the painters to get the largest dot height + radius: index == 0 ? smallerDotRadius : dotRadius, + strokeWidth: dotStrokeWidth, + ), + ), + ), + ], + ), + ); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((realInvocation) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + final mockBuildContext = MockBuildContext(); + final mockUtils = MockUtils(); + Utils.changeInstance(mockUtils); + when(mockUtils.getThemeAwareTextStyle(any, any)) + .thenAnswer((realInvocation) => textStyle1); + when(mockUtils.calculateRotationOffset(any, any)) + .thenAnswer((realInvocation) => Offset.zero); + final lineChartPainter = LineChartPainter(); + + const dotHeight = (dotRadius + dotStrokeWidth) * 2; + const dotXOffset = 20.0; + const scaledSize = Size(200, 100); + + const dotVisibleXOffset = dotXOffset + (dotHeight / 2); + final chartVirtualRect = + const Offset(-dotVisibleXOffset, 0) & scaledSize; + + final indicators = ShowingTooltipIndicators([ + LineBarSpot( + barData, + 0, + barData.spots.first, + ), + LineBarSpot( + barData, + 0, + barData.spots[1], + ), + ]); + + final holder = PaintHolder( + data, + data, + TextScaler.noScaling, + chartVirtualRect, + ); + + lineChartPainter.drawTouchTooltip( + mockBuildContext, + mockCanvasWrapper, + tooltipData, + barData.spots.first, + indicators, + holder, + ); + + verify( + mockCanvasWrapper.drawRotated( + size: anyNamed('size'), + rotationOffset: anyNamed('rotationOffset'), + drawOffset: anyNamed('drawOffset'), + angle: anyNamed('angle'), + drawCallback: anyNamed('drawCallback'), + ), + ).called(3); + + const dotHiddenXOffset = dotXOffset + (dotHeight / 2) + 0.1; + final chartVirtualRect2 = + const Offset(-dotHiddenXOffset, 0) & scaledSize; + final holder2 = PaintHolder( + data, + data, + TextScaler.noScaling, + chartVirtualRect2, + ); + + lineChartPainter.drawTouchTooltip( + mockBuildContext, + mockCanvasWrapper, + tooltipData, + barData.spots.first, + indicators, + holder2, + ); + + verifyNever( + mockCanvasWrapper.drawRotated( + size: anyNamed('size'), + rotationOffset: anyNamed('rotationOffset'), + drawOffset: anyNamed('drawOffset'), + angle: anyNamed('angle'), + drawCallback: anyNamed('drawCallback'), + ), + ); + }, + ); }); group('getBarLineXLength()', () { diff --git a/test/chart/line_chart/line_chart_renderer_test.dart b/test/chart/line_chart/line_chart_renderer_test.dart index dde9635ea..d9fbd4306 100644 --- a/test/chart/line_chart/line_chart_renderer_test.dart +++ b/test/chart/line_chart/line_chart_renderer_test.dart @@ -48,6 +48,8 @@ void main() { data, targetData, textScaler, + null, + canBeScaled: false, ); final mockPainter = MockLineChartPainter(); @@ -123,5 +125,42 @@ void main() { expect(renderLineChart.targetData, data); expect(renderLineChart.textScaler, const TextScaler.linear(22)); }); + + test('passes chart virtual rect to paint holder', () { + final rect1 = Offset.zero & const Size(100, 100); + final renderLineChart = RenderLineChart( + mockBuildContext, + data, + targetData, + textScaler, + null, + canBeScaled: false, + ); + + expect(renderLineChart.chartVirtualRect, isNull); + expect(renderLineChart.paintHolder.chartVirtualRect, isNull); + + renderLineChart.chartVirtualRect = rect1; + + expect(renderLineChart.chartVirtualRect, rect1); + expect(renderLineChart.paintHolder.chartVirtualRect, rect1); + }); + + test('uses canBeScaled', () { + final renderLineChart = RenderLineChart( + mockBuildContext, + data, + targetData, + textScaler, + null, + canBeScaled: false, + ); + + expect(renderLineChart.canBeScaled, false); + + renderLineChart.canBeScaled = true; + + expect(renderLineChart.canBeScaled, true); + }); }); } diff --git a/test/chart/line_chart/line_chart_test.dart b/test/chart/line_chart/line_chart_test.dart new file mode 100644 index 000000000..47f31a705 --- /dev/null +++ b/test/chart/line_chart/line_chart_test.dart @@ -0,0 +1,826 @@ +import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart'; +import 'package:fl_chart/src/chart/line_chart/line_chart.dart'; +import 'package:fl_chart/src/chart/line_chart/line_chart_data.dart'; +import 'package:fl_chart/src/chart/line_chart/line_chart_renderer.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget createTestWidget({ + required LineChart chart, + }) { + return MaterialApp( + home: chart, + ); + } + + group('LineChart', () { + testWidgets('has correct default values', (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + ), + ), + ); + + final lineChart = tester.widget(find.byType(LineChart)); + expect(lineChart.transformationConfig, const FlTransformationConfig()); + }); + + testWidgets('passes interaction parameters to AxisChartScaffoldWidget', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + ), + ), + ); + + final axisChartScaffoldWidget = tester.widget( + find.byType(AxisChartScaffoldWidget), + ); + + expect( + axisChartScaffoldWidget.transformationConfig, + const FlTransformationConfig(), + ); + + await tester.pumpAndSettle(); + + final transformationConfig = FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + maxScale: 10, + minScale: 1.5, + transformationController: TransformationController(), + ); + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: transformationConfig, + ), + ), + ); + + final axisChartScaffoldWidget1 = tester.widget( + find.byType(AxisChartScaffoldWidget), + ); + + expect( + axisChartScaffoldWidget1.transformationConfig, + transformationConfig, + ); + }); + + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets('passes canBeScaled true for $scaleAxis', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + ), + ), + ), + ); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + expect(lineChartLeaf.canBeScaled, true); + }); + } + + testWidgets('passes canBeScaled false for FlScaleAxis.none', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + // ignore: avoid_redundant_argument_values + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + ), + ), + ), + ); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + expect(lineChartLeaf.canBeScaled, false); + }); + + group('touch gesture', () { + testWidgets('does not scale with FlScaleAxis.none', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + ), + ), + ); + + final lineChartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = lineChartCenterOffset; + final scaleStart2 = lineChartCenterOffset; + final scaleEnd1 = lineChartCenterOffset + const Offset(100, 100); + final scaleEnd2 = lineChartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + + expect(lineChartLeaf.chartVirtualRect, isNull); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final lineChartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = lineChartCenterOffset; + final scaleStart2 = lineChartCenterOffset; + final scaleEnd1 = lineChartCenterOffset + const Offset(100, 100); + final scaleEnd2 = lineChartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(LineChartLeaf), + ); + final chartVirtualRect = lineChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size, greaterThan(renderBox.size)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, isNegative); + }); + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(LineChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(LineChartLeaf), + ); + final chartVirtualRect = lineChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size.height, renderBox.size.height); + expect(chartVirtualRect.size.width, greaterThan(renderBox.size.width)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter(find.byType(LineChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(LineChartLeaf), + ); + final chartVirtualRect = lineChartLeaf.chartVirtualRect!; + + expect( + chartVirtualRect.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect.size.width, renderBox.size.width); + expect(chartVirtualRect.left, 0); + expect(chartVirtualRect.top, isNegative); + }); + + group('pans', () { + testWidgets('only horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeafBeforePan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectBeforePan = + lineChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final lineChartLeafAfterPan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectAfterPan = + lineChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectBeforePan.size, chartVirtualRectAfterPan.size); + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect(chartVirtualRectAfterPan.top, 0); + }); + + testWidgets('only vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeafBeforePan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectBeforePan = + lineChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.left, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final lineChartLeafAfterPan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectAfterPan = + lineChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectAfterPan.left, 0); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + + testWidgets('freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeafBeforePan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectBeforePan = + lineChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.left, isNegative); + expect(chartVirtualRectBeforePan.top, isNegative); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final lineChartLeafAfterPan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectAfterPan = + lineChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + }); + }); + + group('trackpad scroll', () { + group('pans', () { + testWidgets('only horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeafBeforePan = tester.widget( + find.byType(LineChartLeaf), + ); + + final chartVirtualRectBeforePan = + lineChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, 0); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final lineChartLeafAfterPan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectAfterPan = + lineChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect(chartVirtualRectAfterPan.top, 0); + }); + + testWidgets('vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeafBeforePan = tester.widget( + find.byType(LineChartLeaf), + ); + + final chartVirtualRectBeforePan = + lineChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.left, 0); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final lineChartLeafAfterPan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectAfterPan = + lineChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectAfterPan.left, 0); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + + testWidgets('freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final lineChartLeafBeforePan = tester.widget( + find.byType(LineChartLeaf), + ); + + final chartVirtualRectBeforePan = + lineChartLeafBeforePan.chartVirtualRect!; + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final lineChartLeafAfterPan = tester.widget( + find.byType(LineChartLeaf), + ); + final chartVirtualRectAfterPan = + lineChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + }); + + testWidgets( + 'does not scale with FlScaleAxis.none when ' + 'trackpadScrollCausesScale is true', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + expect(lineChartLeaf.chartVirtualRect, null); + }, + ); + + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets( + 'does not scale when trackpadScrollCausesScale is false ' + 'for $scaleAxis', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + // ignore: avoid_redundant_argument_values + trackpadScrollCausesScale: false, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter( + find.byType(LineChartLeaf), + ); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + expect(lineChartLeaf.chartVirtualRect, null); + }, + ); + } + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byType(LineChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(LineChartLeaf), + ); + final chartVirtualRect = lineChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size.height, renderBox.size.height); + expect(chartVirtualRect.size.width, greaterThan(renderBox.size.width)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byType(LineChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final lineChartLeaf = tester.widget( + find.byType(LineChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(LineChartLeaf), + ); + final chartVirtualRect = lineChartLeaf.chartVirtualRect!; + + expect( + chartVirtualRect.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect.size.width, renderBox.size.width); + expect(chartVirtualRect.left, 0); + expect(chartVirtualRect.top, isNegative); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: LineChart( + LineChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter(find.byType(LineChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final lineChartLeaf = + tester.widget(find.byType(LineChartLeaf)); + final renderBox = tester.renderObject( + find.byType(LineChartLeaf), + ); + final chartVirtualRect = lineChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size, greaterThan(renderBox.size)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, isNegative); + }); + }); + }); +} diff --git a/test/chart/scatter_chart/scatter_chart_renderer_test.dart b/test/chart/scatter_chart/scatter_chart_renderer_test.dart index 35f6e4452..47b1d5f60 100644 --- a/test/chart/scatter_chart/scatter_chart_renderer_test.dart +++ b/test/chart/scatter_chart/scatter_chart_renderer_test.dart @@ -28,6 +28,8 @@ void main() { data, targetData, textScaler, + null, + canBeScaled: false, ); final mockPainter = MockScatterChartPainter(); @@ -100,5 +102,42 @@ void main() { expect(renderScatterChart.targetData, data); expect(renderScatterChart.textScaler, const TextScaler.linear(22)); }); + + test('passes chart virtual rect to paint holder', () { + final rect1 = Offset.zero & const Size(100, 100); + final renderScatterChart = RenderScatterChart( + mockBuildContext, + data, + targetData, + textScaler, + null, + canBeScaled: false, + ); + + expect(renderScatterChart.chartVirtualRect, isNull); + expect(renderScatterChart.paintHolder.chartVirtualRect, isNull); + + renderScatterChart.chartVirtualRect = rect1; + + expect(renderScatterChart.chartVirtualRect, rect1); + expect(renderScatterChart.paintHolder.chartVirtualRect, rect1); + }); + + test('uses canBeScaled', () { + final renderScatterChart = RenderScatterChart( + mockBuildContext, + data, + targetData, + textScaler, + null, + canBeScaled: false, + ); + + expect(renderScatterChart.canBeScaled, false); + + renderScatterChart.canBeScaled = true; + + expect(renderScatterChart.canBeScaled, true); + }); }); } diff --git a/test/chart/scatter_chart/scatter_chart_test.dart b/test/chart/scatter_chart/scatter_chart_test.dart new file mode 100644 index 000000000..872572a43 --- /dev/null +++ b/test/chart/scatter_chart/scatter_chart_test.dart @@ -0,0 +1,827 @@ +import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart'; +import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart'; +import 'package:fl_chart/src/chart/scatter_chart/scatter_chart.dart'; +import 'package:fl_chart/src/chart/scatter_chart/scatter_chart_data.dart'; +import 'package:fl_chart/src/chart/scatter_chart/scatter_chart_renderer.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget createTestWidget({ + required ScatterChart chart, + }) { + return MaterialApp( + home: chart, + ); + } + + group('ScatterChart', () { + testWidgets('has correct default values', (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + ), + ), + ); + + final scatterChart = tester.widget( + find.byType(ScatterChart), + ); + expect( + scatterChart.transformationConfig, + const FlTransformationConfig(), + ); + }); + + testWidgets('passes interaction parameters to AxisChartScaffoldWidget', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + ), + ), + ); + + final axisChartScaffoldWidget = tester.widget( + find.byType(AxisChartScaffoldWidget), + ); + + expect( + axisChartScaffoldWidget.transformationConfig, + const FlTransformationConfig(), + ); + + await tester.pumpAndSettle(); + + final transformationConfig = FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + maxScale: 10, + minScale: 1.5, + transformationController: TransformationController(), + ); + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: transformationConfig, + ), + ), + ); + + final axisChartScaffoldWidget1 = tester.widget( + find.byType(AxisChartScaffoldWidget), + ); + + expect( + axisChartScaffoldWidget1.transformationConfig, + transformationConfig, + ); + }); + + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets('passes canBeScaled true for $scaleAxis', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + ), + ), + ), + ); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + expect(scatterChartLeaf.canBeScaled, true); + }); + } + + testWidgets('passes canBeScaled false for FlScaleAxis.none', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + // ignore: avoid_redundant_argument_values + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + ), + ), + ), + ); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + expect(scatterChartLeaf.canBeScaled, false); + }); + + group('touch gesture', () { + testWidgets('does not scale with FlScaleAxis.none', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + ), + ), + ); + + final scatterChartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = scatterChartCenterOffset; + final scaleStart2 = scatterChartCenterOffset; + final scaleEnd1 = scatterChartCenterOffset + const Offset(100, 100); + final scaleEnd2 = scatterChartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + + expect(scatterChartLeaf.chartVirtualRect, isNull); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final scatterChartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = scatterChartCenterOffset; + final scaleStart2 = scatterChartCenterOffset; + final scaleEnd1 = scatterChartCenterOffset + const Offset(100, 100); + final scaleEnd2 = scatterChartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRect = scatterChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size, greaterThan(renderBox.size)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, isNegative); + }); + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRect = scatterChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size.height, renderBox.size.height); + expect(chartVirtualRect.size.width, greaterThan(renderBox.size.width)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRect = scatterChartLeaf.chartVirtualRect!; + + expect( + chartVirtualRect.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect.size.width, renderBox.size.width); + expect(chartVirtualRect.left, 0); + expect(chartVirtualRect.top, isNegative); + }); + + group('pans', () { + testWidgets('only horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeafBeforePan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectBeforePan = + scatterChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final scatterChartLeafAfterPan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectAfterPan = + scatterChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectBeforePan.size, chartVirtualRectAfterPan.size); + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect(chartVirtualRectAfterPan.top, 0); + }); + + testWidgets('only vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeafBeforePan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectBeforePan = + scatterChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.left, 0); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final scatterChartLeafAfterPan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectAfterPan = + scatterChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectAfterPan.left, 0); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + + testWidgets('freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeafBeforePan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectBeforePan = + scatterChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, isNegative); + expect(chartVirtualRectBeforePan.left, isNegative); + + const panOffset = Offset(100, 100); + await tester.dragFrom(chartCenterOffset, panOffset); + await tester.pumpAndSettle(); + + final scatterChartLeafAfterPan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectAfterPan = + scatterChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + }); + }); + + group('trackpad scroll', () { + group('pans', () { + testWidgets('only horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeafBeforePan = tester.widget( + find.byType(ScatterChartLeaf), + ); + + final chartVirtualRectBeforePan = + scatterChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.top, 0); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final scatterChartLeafAfterPan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectAfterPan = + scatterChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect(chartVirtualRectAfterPan.top, 0); + }); + + testWidgets('vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeafBeforePan = tester.widget( + find.byType(ScatterChartLeaf), + ); + + final chartVirtualRectBeforePan = + scatterChartLeafBeforePan.chartVirtualRect!; + expect(chartVirtualRectBeforePan.left, 0); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final scatterChartLeafAfterPan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectAfterPan = + scatterChartLeafAfterPan.chartVirtualRect!; + + expect(chartVirtualRectAfterPan.left, 0); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + + testWidgets('freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + ), + ), + ), + ); + + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + final scaleStart1 = chartCenterOffset; + final scaleStart2 = chartCenterOffset; + final scaleEnd1 = chartCenterOffset + const Offset(100, 100); + final scaleEnd2 = chartCenterOffset - const Offset(100, 100); + + final gesture1 = await tester.startGesture(scaleStart1); + final gesture2 = await tester.startGesture(scaleStart2); + await tester.pump(); + await gesture1.moveTo(scaleEnd1); + await gesture2.moveTo(scaleEnd2); + await tester.pump(); + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + final scatterChartLeafBeforePan = tester.widget( + find.byType(ScatterChartLeaf), + ); + + final chartVirtualRectBeforePan = + scatterChartLeafBeforePan.chartVirtualRect!; + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + const leftAndUp = Offset(-100, -100); + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(leftAndUp)); + await tester.pump(); + + final scatterChartLeafAfterPan = tester.widget( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRectAfterPan = + scatterChartLeafAfterPan.chartVirtualRect!; + + expect( + chartVirtualRectAfterPan.left, + greaterThan(chartVirtualRectBeforePan.left), + ); + expect( + chartVirtualRectAfterPan.top, + greaterThan(chartVirtualRectBeforePan.top), + ); + }); + }); + + testWidgets( + 'does not scale with FlScaleAxis.none when ' + 'trackpadScrollCausesScale is true', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + // ignore: avoid_redundant_argument_values + scaleAxis: FlScaleAxis.none, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + expect(scatterChartLeaf.chartVirtualRect, null); + }, + ); + + for (final scaleAxis in FlScaleAxis.scalingEnabledAxis) { + testWidgets( + 'does not scale when trackpadScrollCausesScale is false ' + 'for $scaleAxis', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: FlTransformationConfig( + scaleAxis: scaleAxis, + // ignore: avoid_redundant_argument_values + trackpadScrollCausesScale: false, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = tester.getCenter( + find.byType(ScatterChartLeaf), + ); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + expect(scatterChartLeaf.chartVirtualRect, null); + }, + ); + } + + testWidgets('scales horizontally with FlScaleAxis.horizontal', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRect = scatterChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size.height, renderBox.size.height); + expect(chartVirtualRect.size.width, greaterThan(renderBox.size.width)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, 0); + }); + + testWidgets('scales vertically with FlScaleAxis.vertical', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.vertical, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final scatterChartLeaf = tester.widget( + find.byType(ScatterChartLeaf), + ); + final renderBox = tester.renderObject( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRect = scatterChartLeaf.chartVirtualRect!; + + expect( + chartVirtualRect.size.height, + greaterThan(renderBox.size.height), + ); + expect(chartVirtualRect.size.width, renderBox.size.width); + expect(chartVirtualRect.left, 0); + expect(chartVirtualRect.top, isNegative); + }); + + testWidgets('scales freely with FlScaleAxis.free', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget( + chart: ScatterChart( + ScatterChartData(), + transformationConfig: const FlTransformationConfig( + scaleAxis: FlScaleAxis.free, + trackpadScrollCausesScale: true, + ), + ), + ), + ); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final chartCenterOffset = + tester.getCenter(find.byType(ScatterChartLeaf)); + const scrollAmount = Offset(0, -100); + + await tester.sendEventToBinding(pointer.hover(chartCenterOffset)); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(scrollAmount)); + await tester.pump(); + + final scatterChartLeaf = + tester.widget(find.byType(ScatterChartLeaf)); + final renderBox = tester.renderObject( + find.byType(ScatterChartLeaf), + ); + final chartVirtualRect = scatterChartLeaf.chartVirtualRect!; + + expect(chartVirtualRect.size, greaterThan(renderBox.size)); + expect(chartVirtualRect.left, isNegative); + expect(chartVirtualRect.top, isNegative); + }); + }); + }); +}