├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── build.md │ ├── chore.md │ ├── ci.md │ ├── config.yml │ ├── documentation.md │ ├── feature_request.md │ ├── performance.md │ ├── refactor.md │ ├── revert.md │ ├── style.md │ └── test.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yaml └── workflows │ ├── main.yaml │ ├── tag-release.yaml │ └── version.yaml ├── .gitignore ├── .metadata ├── .vscode ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── build.yaml ├── coverage-total.svg ├── coverage.svg ├── coverage └── lcov.info ├── example ├── .gitignore ├── .metadata ├── README.md ├── analysis_options.yaml ├── lib │ └── main.dart └── pubspec.yaml ├── lib ├── scribble.dart └── src │ ├── domain │ ├── iterable_removed_x.dart │ └── model │ │ ├── point │ │ ├── point.dart │ │ ├── point.freezed.dart │ │ └── point.g.dart │ │ ├── sketch │ │ ├── sketch.dart │ │ ├── sketch.freezed.dart │ │ └── sketch.g.dart │ │ └── sketch_line │ │ ├── sketch_line.dart │ │ ├── sketch_line.freezed.dart │ │ └── sketch_line.g.dart │ └── view │ ├── notifier │ └── scribble_notifier.dart │ ├── painting │ ├── point_to_offset_x.dart │ ├── scribble_editing_painter.dart │ ├── scribble_painter.dart │ └── sketch_line_path_mixin.dart │ ├── pan_gesture_catcher.dart │ ├── scribble.dart │ ├── scribble_sketch.dart │ ├── simplification │ └── sketch_simplifier.dart │ └── state │ ├── scribble.state.dart │ ├── scribble.state.freezed.dart │ └── scribble.state.g.dart ├── melos.yaml ├── packages ├── simpli │ ├── .gitignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── analysis_options.yaml │ ├── coverage.svg │ ├── coverage │ │ └── lcov.info │ ├── lib │ │ ├── simpli.dart │ │ └── src │ │ │ ├── data │ │ │ ├── rdp_simplifier.dart │ │ │ ├── utils.dart │ │ │ └── visvalingam_simplifier.dart │ │ │ ├── domain │ │ │ └── simplifier.dart │ │ │ └── simpli.dart │ ├── pubspec.yaml │ └── test │ │ └── src │ │ └── data │ │ ├── rdp_simplifier_test.dart │ │ ├── utils_test.dart │ │ └── visvalingam_simplifier_test.dart └── value_notifier_tools │ ├── .gitignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── analysis_options.yaml │ ├── coverage.svg │ ├── coverage │ └── lcov.info │ ├── lib │ ├── src │ │ ├── history_value_notifier │ │ │ ├── history_value_notifier.dart │ │ │ └── history_value_notifier_mixin.dart │ │ ├── select_value_notifier │ │ │ └── select_value_notifier.dart │ │ └── where_value_notifier │ │ │ ├── where_value_notifier.dart │ │ │ ├── where_value_notifier_from_parent.dart │ │ │ └── where_value_notifier_mixin.dart │ └── value_notifier_tools.dart │ ├── pubspec.yaml │ └── test │ ├── src │ ├── history_value_notifier │ │ └── history_value_notifier_test.dart │ ├── select_value_notifier │ │ └── select_value_notifier_test.dart │ └── where_value_notifier │ │ ├── where_value_notifier_from_parent_test.dart │ │ └── where_value_notifier_test.dart │ └── util │ └── mock_listener.dart ├── pubspec.yaml ├── scribble_demo.gif └── test └── src ├── domain ├── iterable_removed_x_test.dart └── model │ └── sketch │ └── sketch_test.dart └── view ├── notifier └── scribble_notifier_test.dart ├── scribble_test.dart └── simplification └── sketch_simplifier_test.dart /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "fix: " 5 | labels: bug 6 | --- 7 | 8 | **Description** 9 | 10 | A clear and concise description of what the bug is. 11 | 12 | **Steps To Reproduce** 13 | 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected Behavior** 20 | 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Additional Context** 28 | 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/build.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build System 3 | about: Changes that affect the build system or external dependencies 4 | title: "build: " 5 | labels: build 6 | --- 7 | 8 | **Description** 9 | 10 | Describe what changes need to be done to the build system and why. 11 | 12 | **Requirements** 13 | 14 | - [ ] The build system is passing 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/chore.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Chore 3 | about: Other changes that don't modify src or test files 4 | title: "chore: " 5 | labels: chore 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what change is needed and why. If this changes code then please use another issue type. 11 | 12 | **Requirements** 13 | 14 | - [ ] No functional changes to the code 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ci.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Continuous Integration 3 | about: Changes to the CI configuration files and scripts 4 | title: "ci: " 5 | labels: ci 6 | --- 7 | 8 | **Description** 9 | 10 | Describe what changes need to be done to the ci/cd system and why. 11 | 12 | **Requirements** 13 | 14 | - [ ] The ci system is passing 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Improve the documentation so all collaborators have a common understanding 4 | title: "docs: " 5 | labels: documentation 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what documentation you are looking to add or improve. 11 | 12 | **Requirements** 13 | 14 | - [ ] Requirements go here 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: A new feature to be added to the project 4 | title: "feat: " 5 | labels: feature 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what you are looking to add. The more context the better. 11 | 12 | **Requirements** 13 | 14 | - [ ] Checklist of requirements to be fulfilled 15 | 16 | **Additional Context** 17 | 18 | Add any other context or screenshots about the feature request go here. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/performance.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Performance Update 3 | about: A code change that improves performance 4 | title: "perf: " 5 | labels: performance 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. 11 | 12 | **Requirements** 13 | 14 | - [ ] There is no drop in test coverage. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Refactor 3 | about: A code change that neither fixes a bug nor adds a feature 4 | title: "refactor: " 5 | labels: refactor 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. 11 | 12 | **Requirements** 13 | 14 | - [ ] There is no drop in test coverage. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/revert.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Revert Commit 3 | about: Reverts a previous commit 4 | title: "revert: " 5 | labels: revert 6 | --- 7 | 8 | **Description** 9 | 10 | Provide a link to a PR/Commit that you are looking to revert and why. 11 | 12 | **Requirements** 13 | 14 | - [ ] Change has been reverted 15 | - [ ] No change in test coverage has happened 16 | - [ ] A new ticket is created for any follow on work that needs to happen 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/style.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Style Changes 3 | about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) 4 | title: "style: " 5 | labels: style 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what you are looking to change and why. 11 | 12 | **Requirements** 13 | 14 | - [ ] There is no drop in test coverage. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | about: Adding missing tests or correcting existing tests 4 | title: "test: " 5 | labels: test 6 | --- 7 | 8 | **Description** 9 | 10 | List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. 11 | 12 | **Requirements** 13 | 14 | - [ ] There is no drop in test coverage. 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ## Description 10 | 11 | 12 | 13 | ## Checklist 14 | 15 | 16 | - [ ] My PR title is in the style of [conventional commits](https://www.conventionalcommits.org/) 17 | - [ ] All public facing APIs are documented with [dartdoc](https://dart.dev/guides/language/effective-dart/documentation) 18 | - [ ] I have added tests to cover my changes 19 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "pub" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - main 14 | 15 | jobs: 16 | semantic_pull_request: 17 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 18 | 19 | flutter-check: 20 | name: Build Check 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 10 23 | steps: 24 | - name: 📚 Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: 🐦 Setup Flutter 28 | uses: subosito/flutter-action@v2 29 | with: 30 | channel: 'stable' 31 | cache: true 32 | 33 | - name: Ⓜ️ Set up Melos 34 | uses: bluefireteam/melos-action@v3 35 | 36 | - name: 🧪 Run Analyze 37 | run: melos run analyze 38 | 39 | - name: 📝 Run Test 40 | run: melos run coverage 41 | 42 | - name: 📊 Generate Coverage 43 | id: coverage-report 44 | uses: whynotmake-it/dart-coverage-assistant@v1 45 | with: 46 | generate_badges: pr 47 | upper_threshold: 30 48 | 49 | check_generation: 50 | name: Check Code Generation 51 | timeout-minutes: 10 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: 📚 Checkout 55 | uses: actions/checkout@v4 56 | 57 | - name: 🐦 Setup Flutter 58 | uses: subosito/flutter-action@v2 59 | with: 60 | channel: 'stable' 61 | cache: true 62 | 63 | - name: Ⓜ️ Set up Melos 64 | uses: bluefireteam/melos-action@v3 65 | 66 | - name: 🔨 Generate 67 | run: melos run generate 68 | 69 | - name: 🔎 Check there are no uncommitted changes 70 | run: git add . && git diff --cached --exit-code 71 | -------------------------------------------------------------------------------- /.github/workflows/tag-release.yaml: -------------------------------------------------------------------------------- 1 | name: Tag release 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | jobs: 7 | publish-packages: 8 | name: Create tag for a release 9 | permissions: 10 | contents: write 11 | runs-on: [ ubuntu-latest ] 12 | if: contains(github.event.head_commit.message, 'chore(release)') 13 | steps: 14 | - name: 📚 Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: 🐦 Setup Flutter 20 | uses: subosito/flutter-action@v2 21 | 22 | - name: Ⓜ️ Set up Melos 23 | uses: bluefireteam/melos-action@v3 24 | with: 25 | tag: true 26 | -------------------------------------------------------------------------------- /.github/workflows/version.yaml: -------------------------------------------------------------------------------- 1 | name: Version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | prerelease: 7 | description: 'Version as prerelease' 8 | required: false 9 | default: false 10 | type: boolean 11 | graduate: 12 | description: 'Graduate prereleases' 13 | required: false 14 | default: false 15 | type: boolean 16 | 17 | jobs: 18 | prepare-release: 19 | name: Prepare release 20 | permissions: 21 | contents: write 22 | pull-requests: write 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: 📚 Checkout 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | 30 | - name: 🐦 Setup Flutter 31 | uses: subosito/flutter-action@v2 32 | 33 | - name: Ⓜ️ Set up Melos 34 | uses: bluefireteam/melos-action@v3 35 | with: 36 | run-versioning: ${{ inputs.prerelease == false }} 37 | run-versioning-prerelease: ${{ inputs.prerelease == true }} 38 | run-versioning-graduate: ${{ inputs.graduate == true }} 39 | publish-dry-run: true 40 | 41 | - name: 🎋 Create Pull Request 42 | uses: peter-evans/create-pull-request@v7 43 | with: 44 | title: "chore(release): Publish packages" 45 | body: "Prepared all packages to be released to pub.dev" 46 | branch: chore/release 47 | delete-branch: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't include platform code in example 2 | /example/android 3 | /example/ios 4 | /example/linux 5 | /example/macos 6 | /example/web 7 | /example/windows 8 | 9 | 10 | # Miscellaneous 11 | *.class 12 | *.log 13 | *.pyc 14 | *.swp 15 | .DS_Store 16 | .atom/ 17 | .buildlog/ 18 | .history 19 | .svn/ 20 | .mason/ 21 | migrate_working_dir/ 22 | 23 | # IntelliJ related 24 | *.iml 25 | *.ipr 26 | *.iws 27 | .idea/ 28 | 29 | # See https://www.dartlang.org/guides/libraries/private-files 30 | 31 | # Files and directories created by pub 32 | .dart_tool/ 33 | .packages 34 | build/ 35 | pubspec.lock 36 | pubspec_overrides.yaml -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 4cc385b4b84ac2f816d939a49ea1f328c4e0b48e 8 | channel: stable 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "conventionalCommits.scopes": [ 3 | "value_notifier_tools", 4 | "melos", 5 | "example", 6 | "simpli" 7 | ] 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build_runner build", 6 | "type": "shell", 7 | "command": "melos run build_runner", 8 | "group": "build", "presentation": { 9 | "reveal": "silent", 10 | "group": "build" 11 | } 12 | }, 13 | { 14 | "label": "melos bullshit", 15 | "type": "shell", 16 | "group": "none", 17 | "command": "melos bs", 18 | "presentation": { 19 | "reveal": "silent" 20 | }, 21 | "problemMatcher": [] 22 | }, 23 | { 24 | "label": "dart fix", 25 | "group": "none", 26 | "type": "shell", 27 | "command": "melos run fix", 28 | "presentation": { 29 | "reveal": "silent" 30 | }, 31 | "problemMatcher": [] 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.10.0+1 2 | 3 | - **DOCS**: fixed demo image. 4 | 5 | ## 0.10.0 6 | 7 | > Note: This release has breaking changes. 8 | 9 | - **REFACTOR**: cleaned up project structure. 10 | - **REFACTOR**: added CI and documentation. 11 | - **FIX**(value_notifier_tools): fixed type parameters in select extension. 12 | - **FIX**(value_notifier_tools): Added all classes to package exports. 13 | - **FIX**: fixed point.freezed.dart. 14 | - **FIX**(example): fixed dialog title. 15 | - **FEAT**(example): updated example to use `SelectValueNotifier`. 16 | - **FEAT**: use `HistoryValueNotifier` from new package. 17 | - **FEAT**(value_notifier_tools): added `HistoryValueNotifier`. 18 | - **FEAT**(value_notifier_tools): added where_value_notifier. 19 | - **FEAT**(value_notifier_tools): added select value notifier. 20 | - **FEAT**(example): show JSON. 21 | - **FEAT**(example): updated example visuals. 22 | - **FEAT**(example): updated example for new `ScribbleNotifier`. 23 | - **DOCS**: added back demo gif. 24 | - **DOCS**: updated README. 25 | - **DOCS**(value_notifier_tools): added README. 26 | - **DOCS**: documented all public members. 27 | - **BREAKING** **REFACTOR**: made `GestureCatcherRecognizer` private. 28 | - **BREAKING** **REFACTOR**: `ScribbleNotifier` is now a `ValueNotifier`. 29 | 30 | ## 0.9.1 31 | - removed erroneous `simulatePressure` 32 | ## 0.9.0 33 | - Use `perfect_freehand` for much smoother lines (thanks to @mattrussell-sonocent) 34 | - BREAKING: Removed line customization parameters from widgets 35 | 36 | ## 0.4.0 37 | - Upgraded dependencies (thanks to @wxxedu) 38 | - ``ScribbleNotifier.clear()`` clears the drawing without resetting anything else (color, width e.t.c) 39 | 40 | ## 0.3.0 41 | - Upgraded dependencies 42 | - Added ``ScribbleSketch`` widget for just displaying a sketch without input functionality, no notifier needed! 43 | ## 0.2.2 44 | - Upgraded dependencies 45 | 46 | ## 0.2.1 47 | - Updated README to include the newest features 48 | - Downgraded json_serializable due to a bug with freezed 49 | 50 | ## 0.2.0 51 | #### BREAKING: 52 | - Custom ScribbleNotifiers now need to provide a GlobalKey which is used in the renderImage() method to access Scribble's 53 | RepaintBoundary 54 | - Updated example to demonstrate image export. 55 | 56 | #### Image Export: 57 | - You can now export the Scribble to an Image ```ByteData``` using the ScribbleNotifiers ``renderImage()`` method! 58 | 59 | #### Other Changes: 60 | - The pressure on web is overridden so the cursor matches the selected pen width! 61 | - ``ScribbleNotifier`` now extends ``ScribbleNotifierBase`` instead of implementing it as an interface. 62 | - Updated dependencies 63 | 64 | ## 0.1.3 65 | #### Filter for Pointers: 66 | You can now switch between different ``ScribblePointerMode``s, even at runtime. 67 | 68 | This is very helpful for example, if Scribble lives inside a Scrollable and you want users to be able to navigate with their finger while drawing with their pen. 69 | 70 | Check the updated example to try it out! 71 | 72 | #### Other Changes: 73 | * ``ScribbleNotifier`` now has the option to set the sketch from outside after it has been constructed using the ``setSketch()`` method. You can even choose whether you want it to be committed to the undo history. 74 | * Added documentation to ``ScribbleState`` 75 | * Updated example 76 | * Updated dependencies 77 | 78 | ## 0.1.2 79 | 80 | * Removed the speed calculation using time due to precision issues 81 | 82 | ## 0.1.1 83 | * Added scaleFactor to support zoomable canvases. This allows you to for example wrap the Scribble Widget in an 84 | InteractiveViewer, so that users can draw finer details. 85 | 86 | ## 0.1.0 87 | 88 | * Points now remember their time to calculate speed more accurately 89 | * Multiple fixes for drawing with real pens or touch 90 | * ``ScribbleState`` can now be serialized to JSON 91 | 92 | ### Breaking: 93 | 94 | * speedFactor's value should now be higher for the same effect, the default value has changed to 0.4 95 | * ``color`` property in state is now an int to allow for easy JSON 96 | ## 0.0.13 97 | 98 | * Draw better line ends 99 | * Removed marker-like blend mode for now due to performance and buggy rendering in some cases 100 | 101 | ## 0.0.12 102 | 103 | * Eraser keeps pen width and the other way around 104 | 105 | ## 0.0.11 106 | 107 | * Eraser doesn't autoselect anymore 108 | * Undo doesn't undo color and stroke selection 109 | 110 | ## 0.0.10 111 | 112 | * Fixed stupid bug with pointer exit 113 | 114 | ## 0.0.9 115 | 116 | * Better behavior on pointer exit 117 | 118 | ## 0.0.8 119 | 120 | * Reduced Dependencies 121 | * Replaced kimchi package with the better suited [history_state_notifier](https://pub.dev/packages/history_state_notifier) 122 | * Fixed a bug with redo queue clearing 123 | 124 | ## 0.0.7 125 | 126 | * **BREAKING:** The ``drawPointer`` parameter is now called ``drawPen`` 127 | * You can now obtain the current sketch from the notifier. 128 | If you want to store it somewhere for example you can call ```toJson()``` on it. 129 | * You can now pass a sketch to a ``ScribbleNotifier`` constructor to initialize it with an existing 130 | drawing. 131 | * Added ``ScribbleNotifierBase`` interface so you can write your own notifier that works with the ``Scribble``widget 132 | * Added pressure curve support to the notifier 133 | * Allows more customization in the scribble widget for how the lines are rendered 134 | 135 | 136 | ## 0.0.6 137 | 138 | * Upped minimum flutter version to 2.5 139 | 140 | ## 0.0.5 141 | 142 | * Back to flutter 2.2.3 143 | 144 | ## 0.0.4 145 | 146 | * Upped minimum flutter version to 2.3 147 | 148 | ## 0.0.3 149 | 150 | * meta dependency to hopefully work with analysis 151 | 152 | ## 0.0.2 153 | 154 | * Added documentation 155 | * Fixed dependencies 156 | 157 | ## 0.0.1 158 | 159 | * Initial release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jesper Bellenbaum, Tim Lehmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scribble 2 | 3 | [![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) 4 | [![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg)](https://github.com/invertase/melos) 5 | ![coverage](./coverage.svg) 6 | 7 | Scribble is a lightweight library for freehand drawing in Flutter supporting pressure, variable line width and more! 8 | 9 | ![Scribble Demo](https://raw.githubusercontent.com/timcreatedit/scribble/main/scribble_demo.gif) 10 | 11 | ## Installation 💻 12 | 13 | **❗ In order to start using Scribble you must have the [Dart SDK][dart_install_link] installed on your machine.** 14 | 15 | Install via `dart pub add`: 16 | 17 | ```sh 18 | dart pub add scribble 19 | ``` 20 | 21 | --- 22 | 23 | ## Features 24 | 25 | * Variable line width 26 | * Image Export 27 | * Pen and touch pressure support 28 | * Line simplification for making sketch files smaller 29 | * Choose which pointers can draw (touch, pen, mouse, etc.) 30 | * Lines get slimmer when the pen is moved more quickly 31 | * Line eraser support 32 | * Full undo/redo support using [value_notifier_tools](https://pub.dev/packages/value_notifier_tools) 33 | * Sketches are fully serializable to JSON 34 | * Export Sketches to PNG 35 | 36 | ## Usage 37 | 38 | > You can find a full working example in the [example](./example) directory 39 | 40 | You can create a drawing surface by adding the `Scribble` widget to your widget tree and passing in 41 | a `ScribbleNotifier`. 42 | 43 | ```dart 44 | import 'package:flutter/material.dart'; 45 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 46 | 47 | class App extends StatelessWidget { 48 | @override 49 | Widget build(BuildContext context) { 50 | return Scaffold( 51 | body: Scribble( 52 | notifier: notifier, 53 | ), 54 | ); 55 | } 56 | } 57 | ``` 58 | 59 | Use the public methods on `ScribbleNotifier` to control the behavior (for example from a button in the UI): 60 | 61 | ```dart 62 | notifier = ScribbleNotifier(); 63 | 64 | 65 | // Set color 66 | notifier.setColor(Colors.black); 67 | 68 | // Clear 69 | notifier.clear(); 70 | 71 | // Undo 72 | notifier.undo(); 73 | 74 | // Export to Image 75 | notifier.renderImage(pixelRatio: 2.0); 76 | 77 | // Line details will be simplified to save space from now on 78 | notifier.setSimplificationFactor(2); 79 | 80 | // Simplify the entire existing sketch 81 | notifier.simplify(); 82 | 83 | // And more ... 84 | ``` 85 | 86 | ## Additional information 87 | 88 | As mentioned above, the package is still under development, but we already use it in the app we are currently 89 | developing. 90 | 91 | Feel free to contribute, or open issues in our [GitHub repo](https://github.com/timcreatedit/scribble). 92 | 93 | 94 | [dart_install_link]: https://dart.dev/get-dart 95 | [github_actions_link]: https://docs.github.com/en/actions/learn-github-actions 96 | [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg 97 | [license_link]: https://opensource.org/licenses/MIT 98 | [mason_link]: https://github.com/felangel/mason 99 | [very_good_ventures_link]: https://verygood.ventures 100 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lintervention/analysis_options.yaml 2 | -------------------------------------------------------------------------------- /build.yaml: -------------------------------------------------------------------------------- 1 | targets: 2 | $default: 3 | builders: 4 | json_serializable: 5 | options: 6 | explicit_to_json: true -------------------------------------------------------------------------------- /coverage-total.svg: -------------------------------------------------------------------------------- 1 | Test Coverage: 58.81%Test Coverage58.81% -------------------------------------------------------------------------------- /coverage.svg: -------------------------------------------------------------------------------- 1 | scribble: 44.21%scribble44.21% -------------------------------------------------------------------------------- /coverage/lcov.info: -------------------------------------------------------------------------------- 1 | SF:lib/src/domain/model/sketch/sketch.dart 2 | DA:19,2 3 | LF:1 4 | LH:1 5 | end_of_record 6 | SF:lib/src/domain/model/sketch/sketch.g.dart 7 | DA:9,2 8 | DA:10,1 9 | DA:11,3 10 | DA:12,1 11 | DA:15,0 12 | DA:16,0 13 | DA:17,0 14 | LF:7 15 | LH:4 16 | end_of_record 17 | SF:lib/src/view/notifier/scribble_notifier.dart 18 | DA:23,2 19 | DA:59,0 20 | DA:63,0 21 | DA:66,0 22 | DA:71,0 23 | DA:72,0 24 | DA:84,2 25 | DA:100,2 26 | DA:101,2 27 | DA:103,3 28 | DA:109,2 29 | DA:114,2 30 | DA:131,3 31 | DA:135,1 32 | DA:136,1 33 | DA:144,1 34 | DA:150,2 35 | DA:151,1 36 | DA:164,1 37 | DA:168,3 38 | DA:172,1 39 | DA:174,1 40 | DA:179,0 41 | DA:180,0 42 | DA:181,0 43 | DA:185,0 44 | DA:192,0 45 | DA:193,0 46 | DA:199,0 47 | DA:200,0 48 | DA:201,0 49 | DA:202,0 50 | DA:203,0 51 | DA:204,0 52 | DA:205,0 53 | DA:211,0 54 | DA:212,0 55 | DA:221,0 56 | DA:222,0 57 | DA:223,0 58 | DA:229,0 59 | DA:230,0 60 | DA:231,0 61 | DA:232,0 62 | DA:233,0 63 | DA:234,0 64 | DA:235,0 65 | DA:237,0 66 | DA:238,0 67 | DA:239,0 68 | DA:240,0 69 | DA:241,0 70 | DA:242,0 71 | DA:243,0 72 | DA:259,1 73 | DA:260,4 74 | DA:270,1 75 | DA:271,2 76 | DA:272,2 77 | DA:273,2 78 | DA:276,4 79 | DA:278,4 80 | DA:283,0 81 | DA:285,0 82 | DA:286,0 83 | DA:288,0 84 | DA:293,1 85 | DA:295,4 86 | DA:296,1 87 | DA:299,3 88 | DA:300,0 89 | DA:301,0 90 | DA:303,0 91 | DA:304,0 92 | DA:305,0 93 | DA:308,0 94 | DA:310,2 95 | DA:311,3 96 | DA:312,1 97 | DA:313,1 98 | DA:314,2 99 | DA:315,2 100 | DA:316,5 101 | DA:320,3 102 | DA:321,4 103 | DA:326,1 104 | DA:328,4 105 | DA:329,2 106 | DA:330,0 107 | DA:335,2 108 | DA:336,5 109 | DA:337,1 110 | DA:339,0 111 | DA:340,0 112 | DA:344,0 113 | DA:345,0 114 | DA:349,0 115 | DA:350,0 116 | DA:357,1 117 | DA:359,4 118 | DA:361,2 119 | DA:362,2 120 | DA:363,6 121 | DA:366,7 122 | DA:368,0 123 | DA:369,0 124 | DA:372,0 125 | DA:374,0 126 | DA:375,0 127 | DA:376,0 128 | DA:380,0 129 | DA:382,0 130 | DA:383,0 131 | DA:384,0 132 | DA:391,0 133 | DA:393,0 134 | DA:394,0 135 | DA:395,0 136 | DA:398,0 137 | DA:400,0 138 | DA:401,0 139 | DA:404,0 140 | DA:406,0 141 | DA:407,0 142 | DA:408,0 143 | DA:412,0 144 | DA:414,0 145 | DA:415,0 146 | DA:416,0 147 | DA:422,0 148 | DA:424,0 149 | DA:425,0 150 | DA:428,0 151 | DA:432,1 152 | DA:433,2 153 | DA:434,2 154 | DA:435,1 155 | DA:436,2 156 | DA:438,6 157 | DA:439,3 158 | DA:440,2 159 | DA:441,2 160 | DA:442,1 161 | DA:443,1 162 | DA:444,1 163 | DA:450,0 164 | DA:451,0 165 | DA:452,0 166 | DA:453,0 167 | DA:454,0 168 | DA:455,0 169 | DA:456,0 170 | DA:459,0 171 | DA:461,0 172 | DA:465,0 173 | DA:466,0 174 | DA:473,1 175 | DA:474,3 176 | DA:476,0 177 | DA:477,0 178 | DA:478,1 179 | DA:479,2 180 | DA:480,2 181 | DA:481,2 182 | DA:485,1 183 | DA:486,2 184 | DA:487,2 185 | DA:489,3 186 | DA:490,1 187 | DA:491,2 188 | DA:492,2 189 | DA:494,1 190 | LF:172 191 | LH:76 192 | end_of_record 193 | SF:lib/src/view/scribble.dart 194 | DA:19,1 195 | DA:49,1 196 | DA:51,1 197 | DA:52,1 198 | DA:53,1 199 | DA:55,2 200 | DA:56,1 201 | DA:57,1 202 | DA:58,1 203 | DA:60,1 204 | DA:61,1 205 | DA:62,1 206 | DA:64,1 207 | DA:65,2 208 | DA:66,1 209 | DA:67,1 210 | DA:68,1 211 | DA:69,1 212 | DA:70,1 213 | DA:76,1 214 | DA:78,1 215 | DA:79,1 216 | DA:80,1 217 | DA:82,1 218 | DA:83,1 219 | DA:86,2 220 | DA:87,1 221 | DA:88,2 222 | DA:89,2 223 | DA:90,2 224 | DA:91,2 225 | DA:92,2 226 | LF:32 227 | LH:32 228 | end_of_record 229 | SF:lib/src/view/scribble_sketch.dart 230 | DA:13,0 231 | DA:32,0 232 | DA:34,0 233 | DA:35,0 234 | DA:36,0 235 | DA:37,0 236 | DA:38,0 237 | LF:7 238 | LH:0 239 | end_of_record 240 | SF:lib/src/view/state/scribble.state.dart 241 | DA:108,0 242 | DA:109,0 243 | DA:110,2 244 | DA:114,8 245 | DA:118,0 246 | DA:119,0 247 | DA:120,0 248 | DA:121,0 249 | DA:122,0 250 | DA:127,2 251 | DA:128,2 252 | DA:129,2 253 | DA:130,2 254 | DA:131,0 255 | DA:133,0 256 | DA:138,0 257 | LF:16 258 | LH:6 259 | end_of_record 260 | SF:lib/src/view/state/scribble.state.g.dart 261 | DA:9,0 262 | DA:10,0 263 | DA:11,0 264 | DA:12,0 265 | DA:14,0 266 | DA:15,0 267 | DA:16,0 268 | DA:18,0 269 | DA:19,0 270 | DA:20,0 271 | DA:22,0 272 | DA:24,0 273 | DA:25,0 274 | DA:26,0 275 | DA:27,0 276 | DA:29,0 277 | DA:30,0 278 | DA:33,0 279 | DA:34,0 280 | DA:35,0 281 | DA:36,0 282 | DA:38,0 283 | DA:39,0 284 | DA:40,0 285 | DA:41,0 286 | DA:42,0 287 | DA:43,0 288 | DA:44,0 289 | DA:45,0 290 | DA:55,0 291 | DA:56,0 292 | DA:57,0 293 | DA:58,0 294 | DA:59,0 295 | DA:61,0 296 | DA:62,0 297 | DA:63,0 298 | DA:65,0 299 | DA:67,0 300 | DA:68,0 301 | DA:69,0 302 | DA:71,0 303 | DA:72,0 304 | DA:75,0 305 | DA:76,0 306 | DA:77,0 307 | DA:79,0 308 | DA:80,0 309 | DA:81,0 310 | DA:82,0 311 | DA:83,0 312 | DA:84,0 313 | DA:85,0 314 | LF:53 315 | LH:0 316 | end_of_record 317 | SF:lib/src/domain/iterable_removed_x.dart 318 | DA:8,2 319 | DA:9,8 320 | DA:11,6 321 | DA:16,2 322 | DA:17,2 323 | DA:19,2 324 | DA:20,8 325 | DA:21,2 326 | DA:22,2 327 | DA:24,6 328 | DA:25,2 329 | DA:31,1 330 | DA:32,3 331 | DA:33,2 332 | DA:34,1 333 | LF:15 334 | LH:15 335 | end_of_record 336 | SF:lib/src/domain/model/point/point.dart 337 | DA:18,4 338 | DA:21,2 339 | LF:2 340 | LH:2 341 | end_of_record 342 | SF:lib/src/domain/model/point/point.g.dart 343 | DA:9,2 344 | DA:10,2 345 | DA:11,2 346 | DA:12,2 347 | DA:15,0 348 | DA:16,0 349 | DA:17,0 350 | DA:18,0 351 | DA:19,0 352 | LF:9 353 | LH:4 354 | end_of_record 355 | SF:lib/src/domain/model/sketch_line/sketch_line.dart 356 | DA:25,1 357 | DA:26,1 358 | LF:2 359 | LH:2 360 | end_of_record 361 | SF:lib/src/domain/model/sketch_line/sketch_line.g.dart 362 | DA:9,1 363 | DA:10,1 364 | DA:11,1 365 | DA:12,3 366 | DA:13,1 367 | DA:14,2 368 | DA:15,2 369 | DA:18,0 370 | DA:19,0 371 | DA:20,0 372 | DA:21,0 373 | DA:22,0 374 | LF:12 375 | LH:7 376 | end_of_record 377 | SF:lib/src/view/painting/point_to_offset_x.dart 378 | DA:8,4 379 | LF:1 380 | LH:1 381 | end_of_record 382 | SF:lib/src/view/simplification/sketch_simplifier.dart 383 | DA:11,6 384 | DA:17,1 385 | DA:18,1 386 | DA:19,2 387 | DA:20,1 388 | DA:21,1 389 | DA:22,1 390 | DA:34,6 391 | DA:36,1 392 | DA:38,1 393 | DA:40,7 394 | DA:42,1 395 | DA:46,2 396 | DA:47,2 397 | DA:48,4 398 | LF:15 399 | LH:15 400 | end_of_record 401 | SF:lib/src/view/painting/sketch_line_path_mixin.dart 402 | DA:20,0 403 | DA:24,0 404 | DA:25,0 405 | DA:26,0 406 | DA:27,0 407 | DA:28,0 408 | DA:29,0 409 | DA:30,0 410 | DA:32,0 411 | DA:33,0 412 | DA:37,0 413 | DA:39,0 414 | DA:40,0 415 | DA:41,0 416 | DA:42,0 417 | DA:43,0 418 | DA:48,0 419 | DA:50,0 420 | DA:51,0 421 | DA:52,0 422 | DA:53,0 423 | DA:54,0 424 | DA:55,0 425 | DA:56,0 426 | DA:57,0 427 | LF:25 428 | LH:0 429 | end_of_record 430 | SF:lib/src/view/painting/scribble_editing_painter.dart 431 | DA:12,1 432 | DA:36,1 433 | DA:38,2 434 | DA:40,2 435 | DA:41,2 436 | DA:42,0 437 | DA:45,0 438 | DA:47,0 439 | DA:50,0 440 | DA:51,0 441 | DA:55,2 442 | DA:56,0 443 | DA:58,0 444 | DA:59,0 445 | DA:60,0 446 | DA:62,0 447 | DA:63,0 448 | DA:64,0 449 | DA:66,0 450 | DA:67,0 451 | DA:68,0 452 | DA:69,0 453 | DA:75,0 454 | DA:77,0 455 | DA:78,0 456 | LF:25 457 | LH:6 458 | end_of_record 459 | SF:lib/src/view/painting/scribble_painter.dart 460 | DA:8,1 461 | DA:23,1 462 | DA:25,2 463 | DA:27,4 464 | DA:28,0 465 | DA:29,0 466 | DA:30,0 467 | DA:35,0 468 | DA:36,0 469 | DA:40,0 470 | DA:42,0 471 | DA:43,0 472 | DA:44,0 473 | LF:13 474 | LH:4 475 | end_of_record 476 | SF:lib/src/view/pan_gesture_catcher.dart 477 | DA:12,1 478 | DA:24,1 479 | DA:26,1 480 | DA:27,2 481 | DA:28,1 482 | DA:30,1 483 | DA:31,2 484 | DA:33,1 485 | DA:35,1 486 | DA:38,1 487 | DA:45,1 488 | DA:48,1 489 | DA:50,0 490 | DA:53,0 491 | DA:56,0 492 | DA:58,0 493 | LF:16 494 | LH:12 495 | end_of_record 496 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Symbolication related 35 | app.*.symbols 36 | 37 | # Obfuscation related 38 | app.*.map.json 39 | 40 | # Android Studio will place build artifacts here 41 | /android/app/debug 42 | /android/app/profile 43 | /android/app/release 44 | -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "68bfaea224880b488c617afe30ab12091ea8fa4e" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 68bfaea224880b488c617afe30ab12091ea8fa4e 17 | base_revision: 68bfaea224880b488c617afe30ab12091ea8fa4e 18 | - platform: macos 19 | create_revision: 68bfaea224880b488c617afe30ab12091ea8fa4e 20 | base_revision: 68bfaea224880b488c617afe30ab12091ea8fa4e 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | A new Flutter project. 4 | -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:scribble/scribble.dart'; 5 | import 'package:value_notifier_tools/value_notifier_tools.dart'; 6 | 7 | void main() { 8 | runApp(const MyApp()); 9 | } 10 | 11 | class MyApp extends StatelessWidget { 12 | const MyApp({super.key}); 13 | 14 | // This widget is the root of your application. 15 | @override 16 | Widget build(BuildContext context) { 17 | return MaterialApp( 18 | title: 'Scribble', 19 | theme: ThemeData.from( 20 | colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple)), 21 | home: const HomePage(title: 'Scribble'), 22 | ); 23 | } 24 | } 25 | 26 | class HomePage extends StatefulWidget { 27 | const HomePage({super.key, required this.title}); 28 | 29 | final String title; 30 | 31 | @override 32 | State createState() => _HomePageState(); 33 | } 34 | 35 | class _HomePageState extends State { 36 | late ScribbleNotifier notifier; 37 | 38 | bool _simulatePressure = true; 39 | 40 | @override 41 | void initState() { 42 | notifier = ScribbleNotifier(); 43 | super.initState(); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return Scaffold( 49 | backgroundColor: Theme.of(context).colorScheme.surface, 50 | appBar: AppBar( 51 | title: Text(widget.title), 52 | actions: _buildActions(context), 53 | ), 54 | body: Padding( 55 | padding: const EdgeInsets.symmetric(horizontal: 64), 56 | child: Column( 57 | children: [ 58 | Expanded( 59 | child: Card( 60 | clipBehavior: Clip.hardEdge, 61 | margin: EdgeInsets.zero, 62 | color: Colors.white, 63 | surfaceTintColor: Colors.white, 64 | child: Scribble( 65 | notifier: notifier, 66 | drawPen: true, 67 | simulatePressure: _simulatePressure, 68 | ), 69 | ), 70 | ), 71 | Padding( 72 | padding: const EdgeInsets.all(16), 73 | child: Column( 74 | children: [ 75 | SizedBox( 76 | width: double.infinity, 77 | child: Wrap( 78 | crossAxisAlignment: WrapCrossAlignment.center, 79 | alignment: WrapAlignment.spaceBetween, 80 | spacing: 16, 81 | runSpacing: 16, 82 | children: [ 83 | Row( 84 | mainAxisSize: MainAxisSize.min, 85 | children: [ 86 | _buildColorToolbar(context), 87 | const VerticalDivider(width: 32), 88 | _buildStrokeToolbar(context), 89 | ], 90 | ), 91 | Row( 92 | mainAxisSize: MainAxisSize.min, 93 | children: [ 94 | Switch.adaptive( 95 | value: _simulatePressure, 96 | onChanged: (v) => 97 | setState(() => _simulatePressure = v), 98 | ), 99 | const SizedBox(width: 8), 100 | const Text("Simulate Pressure") 101 | ], 102 | ), 103 | _buildPointerModeSwitcher(context), 104 | ], 105 | ), 106 | ), 107 | const Divider( 108 | height: 32, 109 | ), 110 | Row( 111 | children: [ 112 | ValueListenableBuilder( 113 | valueListenable: notifier.select((value) => value.lines 114 | .expand((element) => element.points) 115 | .length), 116 | builder: (context, value, child) => 117 | Text("Simplification:\n($value points)"), 118 | ), 119 | Expanded( 120 | child: ValueListenableBuilder( 121 | valueListenable: notifier 122 | .select((value) => value.simplificationTolerance), 123 | builder: (context, value, child) => Slider( 124 | value: value, 125 | max: 10, 126 | onChanged: notifier.setSimplificationTolerance, 127 | label: "${value.toStringAsFixed(2)} px", 128 | divisions: 100, 129 | ), 130 | ), 131 | ), 132 | TextButton( 133 | onPressed: notifier.simplify, 134 | child: const Text("Simplify"), 135 | ), 136 | ], 137 | ), 138 | ], 139 | ), 140 | ) 141 | ], 142 | ), 143 | ), 144 | ); 145 | } 146 | 147 | List _buildActions(context) { 148 | return [ 149 | ValueListenableBuilder( 150 | valueListenable: notifier, 151 | builder: (context, value, child) => IconButton( 152 | icon: child as Icon, 153 | tooltip: "Undo", 154 | onPressed: notifier.canUndo ? notifier.undo : null, 155 | ), 156 | child: const Icon(Icons.undo), 157 | ), 158 | ValueListenableBuilder( 159 | valueListenable: notifier, 160 | builder: (context, value, child) => IconButton( 161 | icon: child as Icon, 162 | tooltip: "Redo", 163 | onPressed: notifier.canRedo ? notifier.redo : null, 164 | ), 165 | child: const Icon(Icons.redo), 166 | ), 167 | IconButton( 168 | icon: const Icon(Icons.clear), 169 | tooltip: "Clear", 170 | onPressed: notifier.clear, 171 | ), 172 | IconButton( 173 | icon: const Icon(Icons.image), 174 | tooltip: "Show PNG Image", 175 | onPressed: () => _showImage(context), 176 | ), 177 | IconButton( 178 | icon: const Icon(Icons.data_object), 179 | tooltip: "Show JSON", 180 | onPressed: () => _showJson(context), 181 | ), 182 | ]; 183 | } 184 | 185 | void _showImage(BuildContext context) async { 186 | final image = notifier.renderImage(); 187 | showDialog( 188 | context: context, 189 | builder: (context) => AlertDialog( 190 | title: const Text("Generated Image"), 191 | content: SizedBox.expand( 192 | child: FutureBuilder( 193 | future: image, 194 | builder: (context, snapshot) => snapshot.hasData 195 | ? Image.memory(snapshot.data!.buffer.asUint8List()) 196 | : const Center(child: CircularProgressIndicator()), 197 | ), 198 | ), 199 | actions: [ 200 | TextButton( 201 | onPressed: Navigator.of(context).pop, 202 | child: const Text("Close"), 203 | ) 204 | ], 205 | ), 206 | ); 207 | } 208 | 209 | void _showJson(BuildContext context) { 210 | showDialog( 211 | context: context, 212 | builder: (context) => AlertDialog( 213 | title: const Text("Sketch as JSON"), 214 | content: SizedBox.expand( 215 | child: SelectableText( 216 | jsonEncode(notifier.currentSketch.toJson()), 217 | autofocus: true, 218 | ), 219 | ), 220 | actions: [ 221 | TextButton( 222 | onPressed: Navigator.of(context).pop, 223 | child: const Text("Close"), 224 | ) 225 | ], 226 | ), 227 | ); 228 | } 229 | 230 | Widget _buildStrokeToolbar(BuildContext context) { 231 | return ValueListenableBuilder( 232 | valueListenable: notifier, 233 | builder: (context, state, _) => Row( 234 | crossAxisAlignment: CrossAxisAlignment.center, 235 | mainAxisAlignment: MainAxisAlignment.start, 236 | children: [ 237 | for (final w in notifier.widths) 238 | _buildStrokeButton( 239 | context, 240 | strokeWidth: w, 241 | state: state, 242 | ), 243 | ], 244 | ), 245 | ); 246 | } 247 | 248 | Widget _buildStrokeButton( 249 | BuildContext context, { 250 | required double strokeWidth, 251 | required ScribbleState state, 252 | }) { 253 | final selected = state.selectedWidth == strokeWidth; 254 | return Padding( 255 | padding: const EdgeInsets.all(4), 256 | child: Material( 257 | elevation: selected ? 4 : 0, 258 | shape: const CircleBorder(), 259 | child: InkWell( 260 | onTap: () => notifier.setStrokeWidth(strokeWidth), 261 | customBorder: const CircleBorder(), 262 | child: AnimatedContainer( 263 | duration: kThemeAnimationDuration, 264 | width: strokeWidth * 2, 265 | height: strokeWidth * 2, 266 | decoration: BoxDecoration( 267 | color: state.map( 268 | drawing: (s) => Color(s.selectedColor), 269 | erasing: (_) => Colors.transparent, 270 | ), 271 | border: state.map( 272 | drawing: (_) => null, 273 | erasing: (_) => Border.all(width: 1), 274 | ), 275 | borderRadius: BorderRadius.circular(50.0)), 276 | ), 277 | ), 278 | ), 279 | ); 280 | } 281 | 282 | Widget _buildColorToolbar(BuildContext context) { 283 | return Row( 284 | crossAxisAlignment: CrossAxisAlignment.center, 285 | mainAxisAlignment: MainAxisAlignment.start, 286 | children: [ 287 | _buildColorButton(context, color: Colors.black), 288 | _buildColorButton(context, color: Colors.red), 289 | _buildColorButton(context, color: Colors.green), 290 | _buildColorButton(context, color: Colors.blue), 291 | _buildColorButton(context, color: Colors.yellow), 292 | _buildEraserButton(context), 293 | ], 294 | ); 295 | } 296 | 297 | Widget _buildPointerModeSwitcher(BuildContext context) { 298 | return ValueListenableBuilder( 299 | valueListenable: notifier.select( 300 | (value) => value.allowedPointersMode, 301 | ), 302 | builder: (context, value, child) { 303 | return SegmentedButton( 304 | multiSelectionEnabled: false, 305 | emptySelectionAllowed: false, 306 | onSelectionChanged: (v) => notifier.setAllowedPointersMode(v.first), 307 | segments: const [ 308 | ButtonSegment( 309 | value: ScribblePointerMode.all, 310 | icon: Icon(Icons.touch_app), 311 | label: Text("All pointers"), 312 | ), 313 | ButtonSegment( 314 | value: ScribblePointerMode.penOnly, 315 | icon: Icon(Icons.draw), 316 | label: Text("Pen only"), 317 | ), 318 | ], 319 | selected: {value}, 320 | ); 321 | }); 322 | } 323 | 324 | Widget _buildEraserButton(BuildContext context) { 325 | return ValueListenableBuilder( 326 | valueListenable: notifier.select((value) => value is Erasing), 327 | builder: (context, value, child) => ColorButton( 328 | color: Colors.transparent, 329 | outlineColor: Colors.black, 330 | isActive: value, 331 | onPressed: () => notifier.setEraser(), 332 | child: const Icon(Icons.cleaning_services), 333 | ), 334 | ); 335 | } 336 | 337 | Widget _buildColorButton( 338 | BuildContext context, { 339 | required Color color, 340 | }) { 341 | return ValueListenableBuilder( 342 | valueListenable: notifier.select((value) => 343 | value is Drawing && value.selectedColor == color.toARGB32()), 344 | builder: (context, value, child) => Padding( 345 | padding: const EdgeInsets.symmetric(horizontal: 4), 346 | child: ColorButton( 347 | color: color, 348 | isActive: value, 349 | onPressed: () => notifier.setColor(color), 350 | ), 351 | ), 352 | ); 353 | } 354 | } 355 | 356 | class ColorButton extends StatelessWidget { 357 | const ColorButton({ 358 | required this.color, 359 | required this.isActive, 360 | required this.onPressed, 361 | this.outlineColor, 362 | this.child, 363 | super.key, 364 | }); 365 | 366 | final Color color; 367 | 368 | final Color? outlineColor; 369 | 370 | final bool isActive; 371 | 372 | final VoidCallback onPressed; 373 | 374 | final Icon? child; 375 | 376 | @override 377 | Widget build(BuildContext context) { 378 | return AnimatedContainer( 379 | duration: kThemeAnimationDuration, 380 | decoration: ShapeDecoration( 381 | shape: CircleBorder( 382 | side: BorderSide( 383 | color: switch (isActive) { 384 | true => outlineColor ?? color, 385 | false => Colors.transparent, 386 | }, 387 | width: 2, 388 | ), 389 | ), 390 | ), 391 | child: IconButton( 392 | style: FilledButton.styleFrom( 393 | backgroundColor: color, 394 | shape: const CircleBorder(), 395 | side: isActive 396 | ? const BorderSide(color: Colors.white, width: 2) 397 | : const BorderSide(color: Colors.transparent), 398 | ), 399 | onPressed: onPressed, 400 | icon: child ?? const SizedBox(), 401 | ), 402 | ); 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | description: "An example project for scribble." 3 | publish_to: 'none' 4 | version: 0.1.0 5 | 6 | environment: 7 | sdk: '>=3.3.2 <4.0.0' 8 | 9 | dependencies: 10 | flutter: 11 | sdk: flutter 12 | scribble: 13 | path: .. 14 | value_notifier_tools: ^0.1.2 15 | 16 | dev_dependencies: 17 | flutter_test: 18 | sdk: flutter 19 | flutter_lints: ^3.0.0 20 | 21 | flutter: 22 | uses-material-design: true 23 | -------------------------------------------------------------------------------- /lib/scribble.dart: -------------------------------------------------------------------------------- 1 | /// Scribble is a lightweight library for freehand drawing in Flutter 2 | library scribble; 3 | 4 | export 'package:scribble/src/domain/model/sketch/sketch.dart'; 5 | export 'package:scribble/src/view/notifier/scribble_notifier.dart'; 6 | export 'package:scribble/src/view/scribble.dart'; 7 | export 'package:scribble/src/view/scribble_sketch.dart'; 8 | export 'package:scribble/src/view/state/scribble.state.dart'; 9 | -------------------------------------------------------------------------------- /lib/src/domain/iterable_removed_x.dart: -------------------------------------------------------------------------------- 1 | import 'dart:collection'; 2 | 3 | /// An extension on [Iterable] that provides a method to get the indices of the 4 | /// elements that were removed from this iterable in order. 5 | extension IterableRemovedX on Iterable { 6 | /// Returns the indices of the elements that were removed from this iterable 7 | /// in order. 8 | Iterable removedIndices(Iterable other) sync* { 9 | assert(other.length <= length, "The 'after' iterable can't be longer"); 10 | assert( 11 | other.every(contains), 12 | "The 'after iterable can't contain elements " 13 | "that are not in this iterable", 14 | ); 15 | 16 | final before = Queue.from(this); 17 | final after = Queue.from(other); 18 | 19 | while (before.isNotEmpty) { 20 | if (after.isNotEmpty && before.first == after.first) { 21 | before.removeFirst(); 22 | after.removeFirst(); 23 | } else { 24 | yield length - before.length; 25 | before.removeFirst(); 26 | } 27 | } 28 | } 29 | 30 | /// Returns an iterable without the elements at the given [indices]. 31 | Iterable withRemovedIndices(Set indices) sync* { 32 | for (var i = 0; i < length; i++) { 33 | if (indices.contains(i) == false) { 34 | yield elementAt(i); 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/domain/model/point/point.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'point.freezed.dart'; 4 | part 'point.g.dart'; 5 | 6 | /// {@template point} 7 | /// Represents a point in a sketch with an x and y coordinate and an optional 8 | /// pressure value. 9 | /// {@endtemplate} 10 | @Freezed() 11 | class Point with _$Point { 12 | /// {@macro point} 13 | const factory Point( 14 | double x, 15 | double y, { 16 | @Default(0.5) double pressure, 17 | }) = _Point; 18 | const Point._(); 19 | 20 | /// Constructs a point from a JSON object. 21 | factory Point.fromJson(Map json) => _$PointFromJson(json); 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/domain/model/point/point.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 | 6 | part of 'point.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); 16 | 17 | Point _$PointFromJson(Map json) { 18 | return _Point.fromJson(json); 19 | } 20 | 21 | /// @nodoc 22 | mixin _$Point { 23 | double get x => throw _privateConstructorUsedError; 24 | double get y => throw _privateConstructorUsedError; 25 | double get pressure => throw _privateConstructorUsedError; 26 | 27 | /// Serializes this Point to a JSON map. 28 | Map toJson() => throw _privateConstructorUsedError; 29 | 30 | /// Create a copy of Point 31 | /// with the given fields replaced by the non-null parameter values. 32 | @JsonKey(includeFromJson: false, includeToJson: false) 33 | $PointCopyWith get copyWith => throw _privateConstructorUsedError; 34 | } 35 | 36 | /// @nodoc 37 | abstract class $PointCopyWith<$Res> { 38 | factory $PointCopyWith(Point value, $Res Function(Point) then) = 39 | _$PointCopyWithImpl<$Res, Point>; 40 | @useResult 41 | $Res call({double x, double y, double pressure}); 42 | } 43 | 44 | /// @nodoc 45 | class _$PointCopyWithImpl<$Res, $Val extends Point> 46 | implements $PointCopyWith<$Res> { 47 | _$PointCopyWithImpl(this._value, this._then); 48 | 49 | // ignore: unused_field 50 | final $Val _value; 51 | // ignore: unused_field 52 | final $Res Function($Val) _then; 53 | 54 | /// Create a copy of Point 55 | /// with the given fields replaced by the non-null parameter values. 56 | @pragma('vm:prefer-inline') 57 | @override 58 | $Res call({ 59 | Object? x = null, 60 | Object? y = null, 61 | Object? pressure = null, 62 | }) { 63 | return _then(_value.copyWith( 64 | x: null == x 65 | ? _value.x 66 | : x // ignore: cast_nullable_to_non_nullable 67 | as double, 68 | y: null == y 69 | ? _value.y 70 | : y // ignore: cast_nullable_to_non_nullable 71 | as double, 72 | pressure: null == pressure 73 | ? _value.pressure 74 | : pressure // ignore: cast_nullable_to_non_nullable 75 | as double, 76 | ) as $Val); 77 | } 78 | } 79 | 80 | /// @nodoc 81 | abstract class _$$PointImplCopyWith<$Res> implements $PointCopyWith<$Res> { 82 | factory _$$PointImplCopyWith( 83 | _$PointImpl value, $Res Function(_$PointImpl) then) = 84 | __$$PointImplCopyWithImpl<$Res>; 85 | @override 86 | @useResult 87 | $Res call({double x, double y, double pressure}); 88 | } 89 | 90 | /// @nodoc 91 | class __$$PointImplCopyWithImpl<$Res> 92 | extends _$PointCopyWithImpl<$Res, _$PointImpl> 93 | implements _$$PointImplCopyWith<$Res> { 94 | __$$PointImplCopyWithImpl( 95 | _$PointImpl _value, $Res Function(_$PointImpl) _then) 96 | : super(_value, _then); 97 | 98 | /// Create a copy of Point 99 | /// with the given fields replaced by the non-null parameter values. 100 | @pragma('vm:prefer-inline') 101 | @override 102 | $Res call({ 103 | Object? x = null, 104 | Object? y = null, 105 | Object? pressure = null, 106 | }) { 107 | return _then(_$PointImpl( 108 | null == x 109 | ? _value.x 110 | : x // ignore: cast_nullable_to_non_nullable 111 | as double, 112 | null == y 113 | ? _value.y 114 | : y // ignore: cast_nullable_to_non_nullable 115 | as double, 116 | pressure: null == pressure 117 | ? _value.pressure 118 | : pressure // ignore: cast_nullable_to_non_nullable 119 | as double, 120 | )); 121 | } 122 | } 123 | 124 | /// @nodoc 125 | @JsonSerializable() 126 | class _$PointImpl extends _Point { 127 | const _$PointImpl(this.x, this.y, {this.pressure = 0.5}) : super._(); 128 | 129 | factory _$PointImpl.fromJson(Map json) => 130 | _$$PointImplFromJson(json); 131 | 132 | @override 133 | final double x; 134 | @override 135 | final double y; 136 | @override 137 | @JsonKey() 138 | final double pressure; 139 | 140 | @override 141 | String toString() { 142 | return 'Point(x: $x, y: $y, pressure: $pressure)'; 143 | } 144 | 145 | @override 146 | bool operator ==(Object other) { 147 | return identical(this, other) || 148 | (other.runtimeType == runtimeType && 149 | other is _$PointImpl && 150 | (identical(other.x, x) || other.x == x) && 151 | (identical(other.y, y) || other.y == y) && 152 | (identical(other.pressure, pressure) || 153 | other.pressure == pressure)); 154 | } 155 | 156 | @JsonKey(includeFromJson: false, includeToJson: false) 157 | @override 158 | int get hashCode => Object.hash(runtimeType, x, y, pressure); 159 | 160 | /// Create a copy of Point 161 | /// with the given fields replaced by the non-null parameter values. 162 | @JsonKey(includeFromJson: false, includeToJson: false) 163 | @override 164 | @pragma('vm:prefer-inline') 165 | _$$PointImplCopyWith<_$PointImpl> get copyWith => 166 | __$$PointImplCopyWithImpl<_$PointImpl>(this, _$identity); 167 | 168 | @override 169 | Map toJson() { 170 | return _$$PointImplToJson( 171 | this, 172 | ); 173 | } 174 | } 175 | 176 | abstract class _Point extends Point { 177 | const factory _Point(final double x, final double y, 178 | {final double pressure}) = _$PointImpl; 179 | const _Point._() : super._(); 180 | 181 | factory _Point.fromJson(Map json) = _$PointImpl.fromJson; 182 | 183 | @override 184 | double get x; 185 | @override 186 | double get y; 187 | @override 188 | double get pressure; 189 | 190 | /// Create a copy of Point 191 | /// with the given fields replaced by the non-null parameter values. 192 | @override 193 | @JsonKey(includeFromJson: false, includeToJson: false) 194 | _$$PointImplCopyWith<_$PointImpl> get copyWith => 195 | throw _privateConstructorUsedError; 196 | } 197 | -------------------------------------------------------------------------------- /lib/src/domain/model/point/point.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'point.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$PointImpl _$$PointImplFromJson(Map json) => _$PointImpl( 10 | (json['x'] as num).toDouble(), 11 | (json['y'] as num).toDouble(), 12 | pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5, 13 | ); 14 | 15 | Map _$$PointImplToJson(_$PointImpl instance) => 16 | { 17 | 'x': instance.x, 18 | 'y': instance.y, 19 | 'pressure': instance.pressure, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/src/domain/model/sketch/sketch.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | import 'package:scribble/src/domain/model/sketch_line/sketch_line.dart'; 3 | 4 | export '../point/point.dart'; 5 | export '../sketch_line/sketch_line.dart'; 6 | 7 | part 'sketch.freezed.dart'; 8 | part 'sketch.g.dart'; 9 | 10 | /// Represents a sketch with a list of [SketchLine]s. 11 | @freezed 12 | class Sketch with _$Sketch { 13 | /// Represents a sketch with a list of [SketchLine]s. 14 | const factory Sketch({ 15 | required List lines, 16 | }) = _Sketch; 17 | 18 | /// Constructs a sketch from a JSON object. 19 | factory Sketch.fromJson(Map json) => _$SketchFromJson(json); 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/domain/model/sketch/sketch.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 | 6 | part of 'sketch.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); 16 | 17 | Sketch _$SketchFromJson(Map json) { 18 | return _Sketch.fromJson(json); 19 | } 20 | 21 | /// @nodoc 22 | mixin _$Sketch { 23 | List get lines => throw _privateConstructorUsedError; 24 | 25 | /// Serializes this Sketch to a JSON map. 26 | Map toJson() => throw _privateConstructorUsedError; 27 | 28 | /// Create a copy of Sketch 29 | /// with the given fields replaced by the non-null parameter values. 30 | @JsonKey(includeFromJson: false, includeToJson: false) 31 | $SketchCopyWith get copyWith => throw _privateConstructorUsedError; 32 | } 33 | 34 | /// @nodoc 35 | abstract class $SketchCopyWith<$Res> { 36 | factory $SketchCopyWith(Sketch value, $Res Function(Sketch) then) = 37 | _$SketchCopyWithImpl<$Res, Sketch>; 38 | @useResult 39 | $Res call({List lines}); 40 | } 41 | 42 | /// @nodoc 43 | class _$SketchCopyWithImpl<$Res, $Val extends Sketch> 44 | implements $SketchCopyWith<$Res> { 45 | _$SketchCopyWithImpl(this._value, this._then); 46 | 47 | // ignore: unused_field 48 | final $Val _value; 49 | // ignore: unused_field 50 | final $Res Function($Val) _then; 51 | 52 | /// Create a copy of Sketch 53 | /// with the given fields replaced by the non-null parameter values. 54 | @pragma('vm:prefer-inline') 55 | @override 56 | $Res call({ 57 | Object? lines = null, 58 | }) { 59 | return _then(_value.copyWith( 60 | lines: null == lines 61 | ? _value.lines 62 | : lines // ignore: cast_nullable_to_non_nullable 63 | as List, 64 | ) as $Val); 65 | } 66 | } 67 | 68 | /// @nodoc 69 | abstract class _$$SketchImplCopyWith<$Res> implements $SketchCopyWith<$Res> { 70 | factory _$$SketchImplCopyWith( 71 | _$SketchImpl value, $Res Function(_$SketchImpl) then) = 72 | __$$SketchImplCopyWithImpl<$Res>; 73 | @override 74 | @useResult 75 | $Res call({List lines}); 76 | } 77 | 78 | /// @nodoc 79 | class __$$SketchImplCopyWithImpl<$Res> 80 | extends _$SketchCopyWithImpl<$Res, _$SketchImpl> 81 | implements _$$SketchImplCopyWith<$Res> { 82 | __$$SketchImplCopyWithImpl( 83 | _$SketchImpl _value, $Res Function(_$SketchImpl) _then) 84 | : super(_value, _then); 85 | 86 | /// Create a copy of Sketch 87 | /// with the given fields replaced by the non-null parameter values. 88 | @pragma('vm:prefer-inline') 89 | @override 90 | $Res call({ 91 | Object? lines = null, 92 | }) { 93 | return _then(_$SketchImpl( 94 | lines: null == lines 95 | ? _value._lines 96 | : lines // ignore: cast_nullable_to_non_nullable 97 | as List, 98 | )); 99 | } 100 | } 101 | 102 | /// @nodoc 103 | @JsonSerializable() 104 | class _$SketchImpl implements _Sketch { 105 | const _$SketchImpl({required final List lines}) : _lines = lines; 106 | 107 | factory _$SketchImpl.fromJson(Map json) => 108 | _$$SketchImplFromJson(json); 109 | 110 | final List _lines; 111 | @override 112 | List get lines { 113 | if (_lines is EqualUnmodifiableListView) return _lines; 114 | // ignore: implicit_dynamic_type 115 | return EqualUnmodifiableListView(_lines); 116 | } 117 | 118 | @override 119 | String toString() { 120 | return 'Sketch(lines: $lines)'; 121 | } 122 | 123 | @override 124 | bool operator ==(Object other) { 125 | return identical(this, other) || 126 | (other.runtimeType == runtimeType && 127 | other is _$SketchImpl && 128 | const DeepCollectionEquality().equals(other._lines, _lines)); 129 | } 130 | 131 | @JsonKey(includeFromJson: false, includeToJson: false) 132 | @override 133 | int get hashCode => 134 | Object.hash(runtimeType, const DeepCollectionEquality().hash(_lines)); 135 | 136 | /// Create a copy of Sketch 137 | /// with the given fields replaced by the non-null parameter values. 138 | @JsonKey(includeFromJson: false, includeToJson: false) 139 | @override 140 | @pragma('vm:prefer-inline') 141 | _$$SketchImplCopyWith<_$SketchImpl> get copyWith => 142 | __$$SketchImplCopyWithImpl<_$SketchImpl>(this, _$identity); 143 | 144 | @override 145 | Map toJson() { 146 | return _$$SketchImplToJson( 147 | this, 148 | ); 149 | } 150 | } 151 | 152 | abstract class _Sketch implements Sketch { 153 | const factory _Sketch({required final List lines}) = _$SketchImpl; 154 | 155 | factory _Sketch.fromJson(Map json) = _$SketchImpl.fromJson; 156 | 157 | @override 158 | List get lines; 159 | 160 | /// Create a copy of Sketch 161 | /// with the given fields replaced by the non-null parameter values. 162 | @override 163 | @JsonKey(includeFromJson: false, includeToJson: false) 164 | _$$SketchImplCopyWith<_$SketchImpl> get copyWith => 165 | throw _privateConstructorUsedError; 166 | } 167 | -------------------------------------------------------------------------------- /lib/src/domain/model/sketch/sketch.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'sketch.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$SketchImpl _$$SketchImplFromJson(Map json) => _$SketchImpl( 10 | lines: (json['lines'] as List) 11 | .map((e) => SketchLine.fromJson(e as Map)) 12 | .toList(), 13 | ); 14 | 15 | Map _$$SketchImplToJson(_$SketchImpl instance) => 16 | { 17 | 'lines': instance.lines.map((e) => e.toJson()).toList(), 18 | }; 19 | -------------------------------------------------------------------------------- /lib/src/domain/model/sketch_line/sketch_line.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | import 'package:scribble/src/domain/model/point/point.dart'; 3 | 4 | part 'sketch_line.freezed.dart'; 5 | part 'sketch_line.g.dart'; 6 | 7 | /// {@template sketch_line} 8 | /// Represents a line in a sketch. 9 | /// {@endtemplate} 10 | @freezed 11 | class SketchLine with _$SketchLine { 12 | /// {@macro sketch_line} 13 | const factory SketchLine({ 14 | /// The points that make up the line 15 | required List points, 16 | 17 | /// The color of the line in hexadecimal format (ARGB) 18 | required int color, 19 | 20 | /// The width of the line 21 | required double width, 22 | }) = _SketchLine; 23 | 24 | /// Constructs a sketch line from a JSON object. 25 | factory SketchLine.fromJson(Map json) => 26 | _$SketchLineFromJson(json); 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/domain/model/sketch_line/sketch_line.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 | 6 | part of 'sketch_line.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); 16 | 17 | SketchLine _$SketchLineFromJson(Map json) { 18 | return _SketchLine.fromJson(json); 19 | } 20 | 21 | /// @nodoc 22 | mixin _$SketchLine { 23 | /// The points that make up the line 24 | List get points => throw _privateConstructorUsedError; 25 | 26 | /// The color of the line in hexadecimal format (ARGB) 27 | int get color => throw _privateConstructorUsedError; 28 | 29 | /// The width of the line 30 | double get width => throw _privateConstructorUsedError; 31 | 32 | /// Serializes this SketchLine to a JSON map. 33 | Map toJson() => throw _privateConstructorUsedError; 34 | 35 | /// Create a copy of SketchLine 36 | /// with the given fields replaced by the non-null parameter values. 37 | @JsonKey(includeFromJson: false, includeToJson: false) 38 | $SketchLineCopyWith get copyWith => 39 | throw _privateConstructorUsedError; 40 | } 41 | 42 | /// @nodoc 43 | abstract class $SketchLineCopyWith<$Res> { 44 | factory $SketchLineCopyWith( 45 | SketchLine value, $Res Function(SketchLine) then) = 46 | _$SketchLineCopyWithImpl<$Res, SketchLine>; 47 | @useResult 48 | $Res call({List points, int color, double width}); 49 | } 50 | 51 | /// @nodoc 52 | class _$SketchLineCopyWithImpl<$Res, $Val extends SketchLine> 53 | implements $SketchLineCopyWith<$Res> { 54 | _$SketchLineCopyWithImpl(this._value, this._then); 55 | 56 | // ignore: unused_field 57 | final $Val _value; 58 | // ignore: unused_field 59 | final $Res Function($Val) _then; 60 | 61 | /// Create a copy of SketchLine 62 | /// with the given fields replaced by the non-null parameter values. 63 | @pragma('vm:prefer-inline') 64 | @override 65 | $Res call({ 66 | Object? points = null, 67 | Object? color = null, 68 | Object? width = null, 69 | }) { 70 | return _then(_value.copyWith( 71 | points: null == points 72 | ? _value.points 73 | : points // ignore: cast_nullable_to_non_nullable 74 | as List, 75 | color: null == color 76 | ? _value.color 77 | : color // ignore: cast_nullable_to_non_nullable 78 | as int, 79 | width: null == width 80 | ? _value.width 81 | : width // ignore: cast_nullable_to_non_nullable 82 | as double, 83 | ) as $Val); 84 | } 85 | } 86 | 87 | /// @nodoc 88 | abstract class _$$SketchLineImplCopyWith<$Res> 89 | implements $SketchLineCopyWith<$Res> { 90 | factory _$$SketchLineImplCopyWith( 91 | _$SketchLineImpl value, $Res Function(_$SketchLineImpl) then) = 92 | __$$SketchLineImplCopyWithImpl<$Res>; 93 | @override 94 | @useResult 95 | $Res call({List points, int color, double width}); 96 | } 97 | 98 | /// @nodoc 99 | class __$$SketchLineImplCopyWithImpl<$Res> 100 | extends _$SketchLineCopyWithImpl<$Res, _$SketchLineImpl> 101 | implements _$$SketchLineImplCopyWith<$Res> { 102 | __$$SketchLineImplCopyWithImpl( 103 | _$SketchLineImpl _value, $Res Function(_$SketchLineImpl) _then) 104 | : super(_value, _then); 105 | 106 | /// Create a copy of SketchLine 107 | /// with the given fields replaced by the non-null parameter values. 108 | @pragma('vm:prefer-inline') 109 | @override 110 | $Res call({ 111 | Object? points = null, 112 | Object? color = null, 113 | Object? width = null, 114 | }) { 115 | return _then(_$SketchLineImpl( 116 | points: null == points 117 | ? _value._points 118 | : points // ignore: cast_nullable_to_non_nullable 119 | as List, 120 | color: null == color 121 | ? _value.color 122 | : color // ignore: cast_nullable_to_non_nullable 123 | as int, 124 | width: null == width 125 | ? _value.width 126 | : width // ignore: cast_nullable_to_non_nullable 127 | as double, 128 | )); 129 | } 130 | } 131 | 132 | /// @nodoc 133 | @JsonSerializable() 134 | class _$SketchLineImpl implements _SketchLine { 135 | const _$SketchLineImpl( 136 | {required final List points, 137 | required this.color, 138 | required this.width}) 139 | : _points = points; 140 | 141 | factory _$SketchLineImpl.fromJson(Map json) => 142 | _$$SketchLineImplFromJson(json); 143 | 144 | /// The points that make up the line 145 | final List _points; 146 | 147 | /// The points that make up the line 148 | @override 149 | List get points { 150 | if (_points is EqualUnmodifiableListView) return _points; 151 | // ignore: implicit_dynamic_type 152 | return EqualUnmodifiableListView(_points); 153 | } 154 | 155 | /// The color of the line in hexadecimal format (ARGB) 156 | @override 157 | final int color; 158 | 159 | /// The width of the line 160 | @override 161 | final double width; 162 | 163 | @override 164 | String toString() { 165 | return 'SketchLine(points: $points, color: $color, width: $width)'; 166 | } 167 | 168 | @override 169 | bool operator ==(Object other) { 170 | return identical(this, other) || 171 | (other.runtimeType == runtimeType && 172 | other is _$SketchLineImpl && 173 | const DeepCollectionEquality().equals(other._points, _points) && 174 | (identical(other.color, color) || other.color == color) && 175 | (identical(other.width, width) || other.width == width)); 176 | } 177 | 178 | @JsonKey(includeFromJson: false, includeToJson: false) 179 | @override 180 | int get hashCode => Object.hash( 181 | runtimeType, const DeepCollectionEquality().hash(_points), color, width); 182 | 183 | /// Create a copy of SketchLine 184 | /// with the given fields replaced by the non-null parameter values. 185 | @JsonKey(includeFromJson: false, includeToJson: false) 186 | @override 187 | @pragma('vm:prefer-inline') 188 | _$$SketchLineImplCopyWith<_$SketchLineImpl> get copyWith => 189 | __$$SketchLineImplCopyWithImpl<_$SketchLineImpl>(this, _$identity); 190 | 191 | @override 192 | Map toJson() { 193 | return _$$SketchLineImplToJson( 194 | this, 195 | ); 196 | } 197 | } 198 | 199 | abstract class _SketchLine implements SketchLine { 200 | const factory _SketchLine( 201 | {required final List points, 202 | required final int color, 203 | required final double width}) = _$SketchLineImpl; 204 | 205 | factory _SketchLine.fromJson(Map json) = 206 | _$SketchLineImpl.fromJson; 207 | 208 | /// The points that make up the line 209 | @override 210 | List get points; 211 | 212 | /// The color of the line in hexadecimal format (ARGB) 213 | @override 214 | int get color; 215 | 216 | /// The width of the line 217 | @override 218 | double get width; 219 | 220 | /// Create a copy of SketchLine 221 | /// with the given fields replaced by the non-null parameter values. 222 | @override 223 | @JsonKey(includeFromJson: false, includeToJson: false) 224 | _$$SketchLineImplCopyWith<_$SketchLineImpl> get copyWith => 225 | throw _privateConstructorUsedError; 226 | } 227 | -------------------------------------------------------------------------------- /lib/src/domain/model/sketch_line/sketch_line.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'sketch_line.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$SketchLineImpl _$$SketchLineImplFromJson(Map json) => 10 | _$SketchLineImpl( 11 | points: (json['points'] as List) 12 | .map((e) => Point.fromJson(e as Map)) 13 | .toList(), 14 | color: (json['color'] as num).toInt(), 15 | width: (json['width'] as num).toDouble(), 16 | ); 17 | 18 | Map _$$SketchLineImplToJson(_$SketchLineImpl instance) => 19 | { 20 | 'points': instance.points.map((e) => e.toJson()).toList(), 21 | 'color': instance.color, 22 | 'width': instance.width, 23 | }; 24 | -------------------------------------------------------------------------------- /lib/src/view/notifier/scribble_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui' as ui; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/gestures.dart'; 5 | import 'package:flutter/rendering.dart'; 6 | import 'package:flutter/widgets.dart'; 7 | import 'package:scribble/scribble.dart'; 8 | import 'package:scribble/src/view/painting/point_to_offset_x.dart'; 9 | import 'package:scribble/src/view/simplification/sketch_simplifier.dart'; 10 | import 'package:value_notifier_tools/value_notifier_tools.dart'; 11 | 12 | /// {@template scribble_notifier_base} 13 | /// The base class for a notifier that controls the state of a [Scribble] 14 | /// widget. 15 | /// 16 | /// This class is meant to be extended by a concrete implementation that 17 | /// provides the actual behavior. 18 | /// 19 | /// See [ScribbleNotifier] for the default implementation. 20 | /// {@endtemplate} 21 | abstract class ScribbleNotifierBase extends ValueNotifier { 22 | /// {@macro scribble_notifier_base} 23 | ScribbleNotifierBase(super.state); 24 | 25 | /// You need to provide a key that the [RepaintBoundary] can use so you can 26 | /// access it from the [renderImage] method. 27 | GlobalKey get repaintBoundaryKey; 28 | 29 | /// Should be called when the pointer hovers over the canvas with the 30 | /// corresponding [event]. 31 | void onPointerHover(PointerHoverEvent event); 32 | 33 | /// Should be called when the pointer is pressed down on the canvas with the 34 | /// corresponding [event]. 35 | void onPointerDown(PointerDownEvent event); 36 | 37 | /// Should be called when the pointer is moved on the canvas with the 38 | /// corresponding [event]. 39 | void onPointerUpdate(PointerMoveEvent event); 40 | 41 | /// Should be called when the pointer is lifted from the canvas with the 42 | /// corresponding [event]. 43 | void onPointerUp(PointerUpEvent event); 44 | 45 | /// Should be called when the pointer is canceled with the corresponding 46 | /// [event]. 47 | void onPointerCancel(PointerCancelEvent event); 48 | 49 | /// Should be called when the pointer exits the canvas with the corresponding 50 | /// [event]. 51 | void onPointerExit(PointerExitEvent event); 52 | 53 | /// Used to render the image to ByteData which can then be stored or reused 54 | /// for example in an [Image.memory] widget. 55 | /// 56 | /// Use [pixelRatio] to increase the resolution of the resulting image. 57 | /// You can specify a different [format], by default this method 58 | /// generates pngs. 59 | Future renderImage({ 60 | double pixelRatio = 1.0, 61 | ui.ImageByteFormat format = ui.ImageByteFormat.png, 62 | }) async { 63 | final renderObject = repaintBoundaryKey.currentContext?.findRenderObject() 64 | as RenderRepaintBoundary?; 65 | if (renderObject == null) { 66 | throw StateError( 67 | "Tried to convert Scribble to Image, but no valid RenderObject was " 68 | "found!", 69 | ); 70 | } 71 | final img = await renderObject.toImage(pixelRatio: pixelRatio); 72 | return (await img.toByteData(format: format))!; 73 | } 74 | } 75 | 76 | /// {@template scribble_notifier} 77 | /// The default implementation of a [ScribbleNotifierBase]. 78 | /// 79 | /// This class controls the state and behavior for a [Scribble] widget. 80 | /// {@endtemplate} 81 | class ScribbleNotifier extends ScribbleNotifierBase 82 | with HistoryValueNotifierMixin { 83 | /// {@macro scribble_notifier} 84 | ScribbleNotifier({ 85 | /// If you pass a sketch here, the notifier will use that sketch as a 86 | /// starting point. 87 | Sketch? sketch, 88 | 89 | /// Which pointers can be drawn with and are captured. 90 | ScribblePointerMode allowedPointersMode = ScribblePointerMode.all, 91 | 92 | /// How many states you want stored in the undo history, 30 by default. 93 | int maxHistoryLength = 30, 94 | this.widths = const [5, 10, 15], 95 | this.pressureCurve = Curves.linear, 96 | this.simplifier = const VisvalingamSimplifier(), 97 | 98 | /// {@macro view.state.scribble_state.simplification_tolerance} 99 | double simplificationTolerance = 0, 100 | }) : super( 101 | ScribbleState.drawing( 102 | sketch: switch (sketch) { 103 | Sketch() => simplifier.simplifySketch( 104 | sketch, 105 | pixelTolerance: simplificationTolerance, 106 | ), 107 | null => const Sketch(lines: []), 108 | }, 109 | selectedWidth: widths[0], 110 | allowedPointersMode: allowedPointersMode, 111 | simplificationTolerance: simplificationTolerance, 112 | ), 113 | ) { 114 | this.maxHistoryLength = maxHistoryLength; 115 | } 116 | 117 | /// The supported widths, mainly useful for rendering UI, you can still set 118 | /// the width to any arbitrary value from code. 119 | /// 120 | /// The first entry in this list will be the starting width. 121 | final List widths; 122 | 123 | /// The curve that's used to map pen pressure to the pressure value when 124 | /// recording, by default it's linear. 125 | final Curve pressureCurve; 126 | 127 | /// The state of the sketch at this moment. 128 | /// 129 | /// If you want to store it somewhere you can call ``.toJson()`` on it to 130 | /// receive a map. 131 | Sketch get currentSketch => value.sketch; 132 | 133 | final GlobalKey _repaintBoundaryKey = GlobalKey(); 134 | 135 | @override 136 | GlobalKey get repaintBoundaryKey => _repaintBoundaryKey; 137 | 138 | /// The [SketchSimplifier] that is used to simplify the lines of the sketch. 139 | /// 140 | /// Defaults to [VisvalingamSimplifier], but you can implement your own. 141 | final SketchSimplifier simplifier; 142 | 143 | /// Only apply the sketch from the undo history, otherwise keep current state 144 | @override 145 | @protected 146 | ScribbleState transformHistoryValue( 147 | ScribbleState historyValue, 148 | ScribbleState currentState, 149 | ) { 150 | return currentState.copyWith( 151 | sketch: historyValue.sketch, 152 | ); 153 | } 154 | 155 | /// Can be used to update the state of the Sketch externally (e.g. when 156 | /// fetching from a server) to what is passed in as [sketch]; 157 | /// 158 | /// By default, this state of the sketch gets added to the undo history. If 159 | /// this is not desired, set [addToUndoHistory] to `false`. 160 | /// 161 | /// The sketch will be simplified using the currently set simplification 162 | /// tolerance. If you don't want simplification, call 163 | /// [setSimplificationTolerance] to set it to 0. 164 | void setSketch({ 165 | required Sketch sketch, 166 | bool addToUndoHistory = true, 167 | }) { 168 | final newState = value.copyWith( 169 | sketch: sketch, 170 | ); 171 | if (addToUndoHistory) { 172 | value = newState; 173 | } else { 174 | temporaryValue = newState; 175 | } 176 | } 177 | 178 | /// Clear the entire drawing. 179 | void clear() { 180 | value = switch (value) { 181 | final Drawing d => d.copyWith( 182 | sketch: const Sketch(lines: []), 183 | activeLine: null, 184 | ), 185 | final Erasing e => e.copyWith( 186 | sketch: const Sketch(lines: []), 187 | ), 188 | }; 189 | } 190 | 191 | /// Sets the width of the next line 192 | void setStrokeWidth(double strokeWidth) { 193 | temporaryValue = value.copyWith( 194 | selectedWidth: strokeWidth, 195 | ); 196 | } 197 | 198 | /// Switches to eraser mode 199 | void setEraser() { 200 | temporaryValue = ScribbleState.erasing( 201 | sketch: value.sketch, 202 | selectedWidth: value.selectedWidth, 203 | scaleFactor: value.scaleFactor, 204 | allowedPointersMode: value.allowedPointersMode, 205 | activePointerIds: value.activePointerIds, 206 | ); 207 | } 208 | 209 | /// Sets the current mode of allowed pointers to the given 210 | /// [ScribblePointerMode] 211 | void setAllowedPointersMode(ScribblePointerMode allowedPointersMode) { 212 | temporaryValue = value.copyWith( 213 | allowedPointersMode: allowedPointersMode, 214 | ); 215 | } 216 | 217 | /// Sets the zoom factor to allow for adjusting line width. 218 | /// 219 | /// If the factor is 2 for example, lines will be drawn half as thick as 220 | /// actually selected to allow for drawing details. Has to be greater than 0. 221 | void setScaleFactor(double factor) { 222 | assert(factor > 0, "The scale factor must be greater than 0."); 223 | temporaryValue = value.copyWith( 224 | scaleFactor: factor, 225 | ); 226 | } 227 | 228 | /// Sets the color of the pen to the given color. 229 | void setColor(Color color) { 230 | temporaryValue = value.map( 231 | drawing: (s) => ScribbleState.drawing( 232 | sketch: s.sketch, 233 | selectedColor: color.toARGB32(), 234 | selectedWidth: s.selectedWidth, 235 | allowedPointersMode: s.allowedPointersMode, 236 | ), 237 | erasing: (s) => ScribbleState.drawing( 238 | sketch: s.sketch, 239 | selectedColor: color.toARGB32(), 240 | selectedWidth: s.selectedWidth, 241 | allowedPointersMode: s.allowedPointersMode, 242 | scaleFactor: value.scaleFactor, 243 | activePointerIds: value.activePointerIds, 244 | ), 245 | ); 246 | } 247 | 248 | /// Sets the simplification degree for the sketch in logical pixels. 249 | /// 250 | /// 0 means no simplification, 1px is a good starting point for most sketches. 251 | /// The higher the degree, the more the details will be eroded. 252 | /// 253 | /// **Info:** Simplification quickly breaks simulated pressure, since it 254 | /// removes points that are close together first, so pressure simulation 255 | /// assumes a more even speed of the pen. 256 | /// 257 | /// Changing this value by itself will only affect future lines. If you want 258 | /// to simplify existing lines, see [simplify]. 259 | void setSimplificationTolerance(double degree) { 260 | temporaryValue = value.copyWith( 261 | simplificationTolerance: degree, 262 | ); 263 | } 264 | 265 | /// Simplifies the current sketch to the current simplification degree using 266 | /// [simplifier]. 267 | /// 268 | /// This will simplify all lines. If [addToUndoHistory] is true, this step 269 | /// will be added to the undo history 270 | void simplify({bool addToUndoHistory = true}) { 271 | final newSketch = simplifier.simplifySketch( 272 | value.sketch, 273 | pixelTolerance: value.simplificationTolerance, 274 | ); 275 | if (addToUndoHistory) { 276 | value = value.copyWith(sketch: newSketch); 277 | } else { 278 | temporaryValue = value.copyWith(sketch: newSketch); 279 | } 280 | } 281 | 282 | /// Used by the Listener callback to display the pen if desired 283 | @override 284 | void onPointerHover(PointerHoverEvent event) { 285 | if (!value.supportedPointerKinds.contains(event.kind)) return; 286 | temporaryValue = value.copyWith( 287 | pointerPosition: 288 | event.distance > 10000 ? null : _getPointFromEvent(event), 289 | ); 290 | } 291 | 292 | /// Used by the Listener callback to start drawing 293 | @override 294 | void onPointerDown(PointerDownEvent event) { 295 | if (!value.supportedPointerKinds.contains(event.kind)) return; 296 | var s = value; 297 | 298 | // Are there already pointers on the screen? 299 | if (value.activePointerIds.isNotEmpty) { 300 | s = value.map( 301 | drawing: (s) => 302 | // If the current line already contains something 303 | (s.activeLine != null && s.activeLine!.points.length > 2) 304 | ? _finishLineForState(s) 305 | : s.copyWith( 306 | activeLine: null, 307 | ), 308 | erasing: (s) => s, 309 | ); 310 | } else if (value is Drawing) { 311 | s = (value as Drawing).copyWith( 312 | pointerPosition: _getPointFromEvent(event), 313 | activeLine: SketchLine( 314 | points: [_getPointFromEvent(event)], 315 | color: (value as Drawing).selectedColor, 316 | width: value.selectedWidth / value.scaleFactor, 317 | ), 318 | ); 319 | } 320 | temporaryValue = s.copyWith( 321 | activePointerIds: [...value.activePointerIds, event.pointer], 322 | ); 323 | } 324 | 325 | /// Used by the Listener callback to update the drawing 326 | @override 327 | void onPointerUpdate(PointerMoveEvent event) { 328 | if (!value.supportedPointerKinds.contains(event.kind)) return; 329 | if (!value.active) { 330 | temporaryValue = value.copyWith( 331 | pointerPosition: null, 332 | ); 333 | return; 334 | } 335 | if (value is Drawing) { 336 | temporaryValue = _addPoint(event, value).copyWith( 337 | pointerPosition: _getPointFromEvent(event), 338 | ); 339 | } else if (value is Erasing) { 340 | final erasedState = _erasePoint(event); 341 | // Check if content was actually erased 342 | if (erasedState != null) { 343 | // Content was actually erased, add to undo stack 344 | value = erasedState.copyWith( 345 | pointerPosition: _getPointFromEvent(event), 346 | ); 347 | } else { 348 | // No content erased, only update pointer position 349 | temporaryValue = value.copyWith( 350 | pointerPosition: _getPointFromEvent(event), 351 | ); 352 | } 353 | } 354 | } 355 | 356 | /// Used by the Listener callback to finish a line 357 | @override 358 | void onPointerUp(PointerUpEvent event) { 359 | if (!value.supportedPointerKinds.contains(event.kind)) return; 360 | final pos = 361 | event.kind == PointerDeviceKind.mouse ? value.pointerPosition : null; 362 | if (value is Drawing) { 363 | value = _finishLineForState(_addPoint(event, value)).copyWith( 364 | pointerPosition: pos, 365 | activePointerIds: 366 | value.activePointerIds.where((id) => id != event.pointer).toList(), 367 | ); 368 | } else if (value is Erasing) { 369 | final erasedState = _erasePoint(event); 370 | // Only update value when content was actually erased (affects undo stack) 371 | if (erasedState != null) { 372 | value = erasedState.copyWith( 373 | pointerPosition: pos, 374 | activePointerIds: value.activePointerIds 375 | .where((id) => id != event.pointer) 376 | .toList(), 377 | ); 378 | } else { 379 | // No content erased, only update pointer position 380 | temporaryValue = value.copyWith( 381 | pointerPosition: pos, 382 | activePointerIds: value.activePointerIds 383 | .where((id) => id != event.pointer) 384 | .toList(), 385 | ); 386 | } 387 | } 388 | } 389 | 390 | /// Used by the Listener callback to stop displaying the cursor 391 | @override 392 | void onPointerCancel(PointerCancelEvent event) { 393 | if (!value.supportedPointerKinds.contains(event.kind)) return; 394 | if (value is Drawing) { 395 | value = _finishLineForState(_addPoint(event, value)).copyWith( 396 | pointerPosition: null, 397 | activePointerIds: 398 | value.activePointerIds.where((id) => id != event.pointer).toList(), 399 | ); 400 | } else if (value is Erasing) { 401 | final erasedState = _erasePoint(event); 402 | // Only update value when content was actually erased (affects undo stack) 403 | if (erasedState != null) { 404 | value = erasedState.copyWith( 405 | pointerPosition: null, 406 | activePointerIds: value.activePointerIds 407 | .where((id) => id != event.pointer) 408 | .toList(), 409 | ); 410 | } else { 411 | // No content erased, only update pointer position 412 | temporaryValue = value.copyWith( 413 | pointerPosition: null, 414 | activePointerIds: value.activePointerIds 415 | .where((id) => id != event.pointer) 416 | .toList(), 417 | ); 418 | } 419 | } 420 | } 421 | 422 | @override 423 | void onPointerExit(PointerExitEvent event) { 424 | if (!value.supportedPointerKinds.contains(event.kind)) return; 425 | temporaryValue = _finishLineForState(value).copyWith( 426 | pointerPosition: null, 427 | activePointerIds: 428 | value.activePointerIds.where((id) => id != event.pointer).toList(), 429 | ); 430 | } 431 | 432 | ScribbleState _addPoint(PointerEvent event, ScribbleState s) { 433 | if (s is Erasing || !s.active) return s; 434 | if (s is Drawing && s.activeLine == null) return s; 435 | final currentLine = (s as Drawing).activeLine!; 436 | final distanceToLast = currentLine.points.isEmpty 437 | ? double.infinity 438 | : (currentLine.points.last.asOffset - event.localPosition).distance; 439 | if (distanceToLast <= kPrecisePointerPanSlop / s.scaleFactor) return s; 440 | return s.copyWith( 441 | activeLine: currentLine.copyWith( 442 | points: [ 443 | ...currentLine.points, 444 | _getPointFromEvent(event), 445 | ], 446 | ), 447 | ); 448 | } 449 | 450 | ScribbleState? _erasePoint(PointerEvent event) { 451 | final filteredLines = value.sketch.lines 452 | .where( 453 | (l) => l.points.every( 454 | (p) => 455 | (event.localPosition - p.asOffset).distance > 456 | l.width + value.selectedWidth, 457 | ), 458 | ) 459 | .toList(); 460 | // If no lines were erased, return null to avoid unnecessary state updates 461 | if (filteredLines.length == value.sketch.lines.length) { 462 | return null; 463 | } 464 | 465 | return value.copyWith( 466 | sketch: value.sketch.copyWith( 467 | lines: filteredLines, 468 | ), 469 | ); 470 | } 471 | 472 | /// Converts a pointer event to the [Point] on the canvas. 473 | Point _getPointFromEvent(PointerEvent event) { 474 | final p = event.pressureMin == event.pressureMax 475 | ? 0.5 476 | : (event.pressure - event.pressureMin) / 477 | (event.pressureMax - event.pressureMin); 478 | return Point( 479 | event.localPosition.dx, 480 | event.localPosition.dy, 481 | pressure: pressureCurve.transform(p), 482 | ); 483 | } 484 | 485 | ScribbleState _finishLineForState(ScribbleState s) { 486 | if (s case Drawing(activeLine: final activeLine?)) { 487 | return s.copyWith( 488 | activeLine: null, 489 | sketch: s.sketch.copyWith( 490 | lines: [ 491 | ...s.sketch.lines, 492 | simplifier.simplify( 493 | activeLine, 494 | pixelTolerance: s.simplificationTolerance, 495 | ), 496 | ], 497 | ), 498 | ); 499 | } 500 | return s; 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /lib/src/view/painting/point_to_offset_x.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:scribble/src/domain/model/sketch/sketch.dart'; 4 | 5 | /// Extension on [Point] to convert it to an [Offset]. 6 | extension PointToOffsetX on Point { 7 | /// Converts a [Point] to a [Offset]. 8 | Offset get asOffset => Offset(x, y); 9 | } 10 | -------------------------------------------------------------------------------- /lib/src/view/painting/scribble_editing_painter.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/rendering.dart'; 2 | import 'package:scribble/scribble.dart'; 3 | import 'package:scribble/src/view/painting/point_to_offset_x.dart'; 4 | import 'package:scribble/src/view/painting/sketch_line_path_mixin.dart'; 5 | 6 | /// {@template scribble_editing_painter} 7 | /// A painter for drawing the currently active line of a scribble sketch, as 8 | /// well as the pointer when in drawing or erasing mode, if desired. 9 | /// {@endtemplate} 10 | class ScribbleEditingPainter extends CustomPainter with SketchLinePathMixin { 11 | /// {@macro scribble_editing_painter} 12 | ScribbleEditingPainter({ 13 | required this.state, 14 | required this.drawPointer, 15 | required this.drawEraser, 16 | required this.simulatePressure, 17 | }); 18 | 19 | /// The current state of the scribble sketch 20 | final ScribbleState state; 21 | 22 | /// Whether to draw the pointer when in drawing mode. 23 | /// 24 | /// The pointer will be drawn as a filled circle with the currently selected 25 | /// color. 26 | final bool drawPointer; 27 | 28 | /// Whether to draw the pointer when in erasing mode 29 | /// 30 | /// The pointer will be drawn as a transparent circle with a black border. 31 | final bool drawEraser; 32 | 33 | @override 34 | final bool simulatePressure; 35 | 36 | @override 37 | void paint(Canvas canvas, Size size) { 38 | final paint = Paint()..style = PaintingStyle.fill; 39 | 40 | final activeLine = state.map( 41 | drawing: (s) => s.activeLine, 42 | erasing: (_) => null, 43 | ); 44 | if (activeLine != null) { 45 | final path = getPathForLine( 46 | activeLine, 47 | scaleFactor: state.scaleFactor, 48 | ); 49 | if (path != null) { 50 | paint.color = Color(activeLine.color); 51 | canvas.drawPath(path, paint); 52 | } 53 | } 54 | 55 | if (state.pointerPosition != null && 56 | (state is Drawing && drawPointer || state is Erasing && drawEraser)) { 57 | paint 58 | ..style = state.map( 59 | drawing: (_) => PaintingStyle.fill, 60 | erasing: (_) => PaintingStyle.stroke, 61 | ) 62 | ..color = state.map( 63 | drawing: (s) => Color(s.selectedColor), 64 | erasing: (s) => const Color(0xFF000000), 65 | ) 66 | ..strokeWidth = 1; 67 | canvas.drawCircle( 68 | state.pointerPosition!.asOffset, 69 | state.selectedWidth / state.scaleFactor, 70 | paint, 71 | ); 72 | } 73 | } 74 | 75 | @override 76 | bool shouldRepaint(ScribbleEditingPainter oldDelegate) { 77 | return oldDelegate.state != state || 78 | oldDelegate.simulatePressure != simulatePressure; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/src/view/painting/scribble_painter.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/rendering.dart'; 2 | import 'package:scribble/scribble.dart'; 3 | import 'package:scribble/src/view/painting/sketch_line_path_mixin.dart'; 4 | 5 | /// A painter for drawing a scribble sketch. 6 | class ScribblePainter extends CustomPainter with SketchLinePathMixin { 7 | /// Creates a new [ScribblePainter] instance. 8 | ScribblePainter({ 9 | required this.sketch, 10 | required this.scaleFactor, 11 | required this.simulatePressure, 12 | }); 13 | 14 | /// The [Sketch] to draw. 15 | final Sketch sketch; 16 | 17 | /// {@macro view.state.scribble_state.scale_factor} 18 | final double scaleFactor; 19 | 20 | @override 21 | final bool simulatePressure; 22 | 23 | @override 24 | void paint(Canvas canvas, Size size) { 25 | final paint = Paint()..style = PaintingStyle.fill; 26 | 27 | for (var i = 0; i < sketch.lines.length; ++i) { 28 | final path = getPathForLine( 29 | sketch.lines[i], 30 | scaleFactor: scaleFactor, 31 | ); 32 | if (path == null) { 33 | continue; 34 | } 35 | paint.color = Color(sketch.lines[i].color); 36 | canvas.drawPath(path, paint); 37 | } 38 | } 39 | 40 | @override 41 | bool shouldRepaint(ScribblePainter oldDelegate) { 42 | return oldDelegate.sketch != sketch || 43 | oldDelegate.simulatePressure != simulatePressure || 44 | oldDelegate.scaleFactor != scaleFactor; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/view/painting/sketch_line_path_mixin.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:perfect_freehand/perfect_freehand.dart' as pf; 4 | import 'package:scribble/src/domain/model/sketch/sketch.dart'; 5 | 6 | /// A mixin for generating a [Path] from a [SketchLine]. 7 | /// 8 | /// Provides the method [getPathForLine] which generates a smooth [Path] from a 9 | /// [SketchLine]. 10 | mixin SketchLinePathMixin { 11 | /// {@macro scribble.simulate_pressure} 12 | bool get simulatePressure; 13 | 14 | /// Generates a [Path] from a [SketchLine]. 15 | /// 16 | /// The [scaleFactor] is used to scale the line width. 17 | /// 18 | /// If [simulatePressure] is true, the line will be drawn as if it had 19 | /// pressure information, if all its points have the same pressure. 20 | Path? getPathForLine( 21 | SketchLine line, { 22 | double scaleFactor = 1.0, 23 | }) { 24 | final needSimulate = simulatePressure && 25 | line.points.length > 1 && 26 | line.points.every((p) => p.pressure == line.points.first.pressure); 27 | final points = line.points 28 | .map((point) => pf.PointVector(point.x, point.y, point.pressure)) 29 | .toList(); 30 | final outlinePoints = pf.getStroke( 31 | points, 32 | options: pf.StrokeOptions( 33 | size: line.width * 2 * scaleFactor, 34 | simulatePressure: needSimulate, 35 | ), 36 | ); 37 | if (outlinePoints.isEmpty) { 38 | return null; 39 | } else if (outlinePoints.length < 2) { 40 | return Path() 41 | ..addOval( 42 | Rect.fromCircle( 43 | center: Offset(outlinePoints[0].dx, outlinePoints[0].dy), 44 | radius: 1, 45 | ), 46 | ); 47 | } else { 48 | final path = Path()..moveTo(outlinePoints[0].dx, outlinePoints[0].dy); 49 | 50 | for (var i = 1; i < outlinePoints.length - 1; i++) { 51 | final p0 = outlinePoints[i]; 52 | final p1 = outlinePoints[i + 1]; 53 | path.quadraticBezierTo( 54 | p0.dx, 55 | p0.dy, 56 | (p0.dx + p1.dx) / 2, 57 | (p0.dy + p1.dy) / 2, 58 | ); 59 | } 60 | return path; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/src/view/pan_gesture_catcher.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/gestures.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | 4 | /// {@template gesture_catcher} 5 | /// A widget that catches gestures for a given set of pointer kinds. 6 | /// 7 | /// Any gestures of pointers contained in [pointerKindsToCatch] will be caught 8 | /// by this widget and not be passed to any other widgets in the widget tree. 9 | /// {@endtemplate} 10 | class GestureCatcher extends StatelessWidget { 11 | /// {@macro gesture_catcher} 12 | const GestureCatcher({ 13 | required this.pointerKindsToCatch, 14 | required this.child, 15 | super.key, 16 | }); 17 | 18 | /// The pointer kinds to catch. 19 | final Set pointerKindsToCatch; 20 | 21 | /// The child widget. 22 | final Widget child; 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return RawGestureDetector( 27 | key: ValueKey(pointerKindsToCatch), 28 | gestures: { 29 | _GestureCatcherRecognizer: 30 | GestureRecognizerFactoryWithHandlers<_GestureCatcherRecognizer>( 31 | () => _GestureCatcherRecognizer( 32 | debugOwner: this, 33 | pointerKindsToCatch: pointerKindsToCatch, 34 | ), 35 | (_GestureCatcherRecognizer instance) {}, 36 | ), 37 | }, 38 | child: child, 39 | ); 40 | } 41 | } 42 | 43 | class _GestureCatcherRecognizer extends OneSequenceGestureRecognizer { 44 | /// Create a gesture recognizer for tracking movement on a plane. 45 | _GestureCatcherRecognizer({ 46 | required Set pointerKindsToCatch, 47 | super.debugOwner, 48 | }) : super(supportedDevices: pointerKindsToCatch); 49 | 50 | @override 51 | String get debugDescription => 'pan catcher'; 52 | 53 | @override 54 | void didStopTrackingLastPointer(int pointer) {} 55 | 56 | @override 57 | void handleEvent(PointerEvent event) { 58 | resolve(GestureDisposition.accepted); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/view/scribble.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/gestures.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:scribble/src/view/notifier/scribble_notifier.dart'; 6 | import 'package:scribble/src/view/painting/scribble_editing_painter.dart'; 7 | import 'package:scribble/src/view/painting/scribble_painter.dart'; 8 | import 'package:scribble/src/view/pan_gesture_catcher.dart'; 9 | import 'package:scribble/src/view/state/scribble.state.dart'; 10 | 11 | /// {@template scribble} 12 | /// This Widget represents a canvas on which users can draw with any pointer. 13 | /// 14 | /// You can control its behavior from code using the [notifier] instance you 15 | /// pass in. 16 | /// {@endtemplate} 17 | class Scribble extends StatelessWidget { 18 | /// {@macro scribble} 19 | const Scribble({ 20 | /// The notifier that controls this canvas. 21 | required this.notifier, 22 | 23 | /// Whether to draw the pointer when in drawing mode 24 | this.drawPen = true, 25 | 26 | /// Whether to draw the pointer when in erasing mode 27 | this.drawEraser = true, 28 | this.simulatePressure = true, 29 | super.key, 30 | }); 31 | 32 | /// The notifier that controls this canvas. 33 | final ScribbleNotifierBase notifier; 34 | 35 | /// Whether to draw the pointer when in drawing mode 36 | final bool drawPen; 37 | 38 | /// Whether to draw the pointer when in erasing mode 39 | final bool drawEraser; 40 | 41 | /// {@template scribble.simulate_pressure} 42 | /// Whether to simulate pressure when drawing lines that don't have pressure 43 | /// information (all points have the same pressure). 44 | /// 45 | /// Defaults to `true`. 46 | /// {@endtemplate} 47 | final bool simulatePressure; 48 | 49 | @override 50 | Widget build(BuildContext context) { 51 | return ValueListenableBuilder( 52 | valueListenable: notifier, 53 | builder: (context, state, _) { 54 | final drawCurrentTool = 55 | drawPen && state is Drawing || drawEraser && state is Erasing; 56 | final child = SizedBox.expand( 57 | child: CustomPaint( 58 | foregroundPainter: ScribbleEditingPainter( 59 | state: state, 60 | drawPointer: drawPen, 61 | drawEraser: drawEraser, 62 | simulatePressure: simulatePressure, 63 | ), 64 | child: RepaintBoundary( 65 | key: notifier.repaintBoundaryKey, 66 | child: CustomPaint( 67 | painter: ScribblePainter( 68 | sketch: state.sketch, 69 | scaleFactor: state.scaleFactor, 70 | simulatePressure: simulatePressure, 71 | ), 72 | ), 73 | ), 74 | ), 75 | ); 76 | return !state.active 77 | ? child 78 | : GestureCatcher( 79 | pointerKindsToCatch: state.supportedPointerKinds, 80 | child: MouseRegion( 81 | cursor: drawCurrentTool && 82 | state.supportedPointerKinds 83 | .contains(PointerDeviceKind.mouse) 84 | ? SystemMouseCursors.none 85 | : MouseCursor.defer, 86 | onExit: notifier.onPointerExit, 87 | child: Listener( 88 | onPointerDown: notifier.onPointerDown, 89 | onPointerMove: notifier.onPointerUpdate, 90 | onPointerUp: notifier.onPointerUp, 91 | onPointerHover: notifier.onPointerHover, 92 | onPointerCancel: notifier.onPointerCancel, 93 | child: child, 94 | ), 95 | ), 96 | ); 97 | }, 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/src/view/scribble_sketch.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:scribble/src/domain/model/sketch/sketch.dart'; 3 | import 'package:scribble/src/view/painting/scribble_painter.dart'; 4 | 5 | /// {@template scribble_sketch} 6 | /// A widget for displaying a scribble sketch without any input functionalities. 7 | /// 8 | /// The sketch is expected to not have any active line, i.e. all lines are 9 | /// considered finished, the sketch is complete. 10 | /// {@endtemplate} 11 | class ScribbleSketch extends StatelessWidget { 12 | /// {@macro scribble_sketch} 13 | const ScribbleSketch({ 14 | required this.sketch, 15 | this.scaleFactor = 1, 16 | this.simulatePressure = true, 17 | super.key, 18 | }); 19 | 20 | /// The sketch to display 21 | final Sketch sketch; 22 | 23 | /// How much the widget is scaled at the moment. 24 | /// 25 | /// Can be used if zoom functionality is needed 26 | /// (e.g. through InteractiveViewer) so that the pen width remains the same. 27 | final double scaleFactor; 28 | 29 | /// {@macro scribble.simulate_pressure} 30 | final bool simulatePressure; 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | return CustomPaint( 35 | painter: ScribblePainter( 36 | sketch: sketch, 37 | scaleFactor: scaleFactor, 38 | simulatePressure: simulatePressure, 39 | ), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/view/simplification/sketch_simplifier.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | import 'package:scribble/scribble.dart'; 3 | import 'package:scribble/src/domain/iterable_removed_x.dart'; 4 | import 'package:simpli/simpli.dart'; 5 | 6 | /// {@template sketch_simplifier} 7 | /// An interface for simplifying a sketch and it's lines. 8 | /// {@endtemplate} 9 | abstract class SketchSimplifier { 10 | /// {@macro sketch_simplifier} 11 | const SketchSimplifier(); 12 | 13 | /// Simplifies the given list of points. 14 | SketchLine simplify(SketchLine line, {required double pixelTolerance}); 15 | 16 | /// Simplifies an entire sketch by simplifying each line in the sketch. 17 | Sketch simplifySketch(Sketch sketch, {required double pixelTolerance}) { 18 | if (pixelTolerance == 0) return sketch; 19 | return sketch.copyWith( 20 | lines: [ 21 | for (final l in sketch.lines) 22 | simplify(l, pixelTolerance: pixelTolerance), 23 | ], 24 | ); 25 | } 26 | } 27 | 28 | /// {@template visvalingam_simplifier} 29 | /// A [SketchSimplifier], that uses the Visvalingam algorithm to simplify a list 30 | /// of points. 31 | /// {@endtemplate} 32 | class VisvalingamSimplifier extends SketchSimplifier { 33 | /// {@macro visvalingam_simplifier} 34 | const VisvalingamSimplifier(); 35 | 36 | @override 37 | SketchLine simplify(SketchLine line, {required double pixelTolerance}) { 38 | if (pixelTolerance == 0) return line; 39 | 40 | final mathPoints = line.points.map((e) => math.Point(e.x, e.y)).toList(); 41 | 42 | final simplified = Simpli.visvalingam( 43 | mathPoints, 44 | pixelTolerance: pixelTolerance, 45 | ); 46 | final removedIndices = mathPoints.removedIndices(simplified.cast()); 47 | return line.copyWith( 48 | points: line.points.withRemovedIndices(removedIndices.toSet()).toList(), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/view/state/scribble.state.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | import 'package:scribble/src/domain/model/sketch/sketch.dart'; 5 | 6 | part 'scribble.state.freezed.dart'; 7 | 8 | part 'scribble.state.g.dart'; 9 | 10 | /// Which pointers are allowed for drawing and will be captured by the scribble 11 | /// widget. 12 | enum ScribblePointerMode { 13 | /// Allow drawing with all pointers. 14 | all, 15 | 16 | /// Allow drawing with mouse only. 17 | mouseOnly, 18 | 19 | /// Allow drawing with pen only. 20 | /// 21 | /// This is useful if you want to place the scribble widget in an 22 | /// `InteractiveViewer` for example, so that it can be zoomed in and out 23 | /// without drawing on it. 24 | penOnly, 25 | 26 | /// Allow drawing with both mouse and pen. 27 | mouseAndPen, 28 | } 29 | 30 | /// Represents the state of the scribble widget. 31 | @freezed 32 | sealed class ScribbleState with _$ScribbleState { 33 | /// The state of the scribble widget when it is being drawn on. 34 | const factory ScribbleState.drawing({ 35 | /// The current state of the sketch 36 | required Sketch sketch, 37 | 38 | /// The line that is currently being drawn 39 | SketchLine? activeLine, 40 | 41 | /// Which pointers are allowed for drawing and will be captured by the 42 | /// scribble widget. 43 | @Default(ScribblePointerMode.all) ScribblePointerMode allowedPointersMode, 44 | 45 | /// The ids of all supported pointers that are currently interacting with 46 | /// the widget. 47 | @Default([]) List activePointerIds, 48 | 49 | /// The current position of the pointer 50 | Point? pointerPosition, 51 | 52 | /// The color that is currently being drawn with 53 | @Default(0xFF000000) int selectedColor, 54 | 55 | /// The current width of the pen 56 | @Default(5) double selectedWidth, 57 | 58 | /// {@template view.state.scribble_state.scale_factor} 59 | /// How much the widget is scaled at the moment. 60 | /// 61 | /// Can be used if zoom functionality is needed 62 | /// (e.g. through InteractiveViewer) so that the pen width remains the same. 63 | /// {@endtemplate} 64 | @Default(1) double scaleFactor, 65 | 66 | /// {@template view.state.scribble_state.simplification_tolerance} 67 | /// The current tolerance of simplification, in pixels. 68 | /// 69 | /// Lines will be simplified when they are finished. A value of 0 (default) 70 | /// will mean no simplification. 71 | /// {@endtemplate} 72 | @Default(0) double simplificationTolerance, 73 | }) = Drawing; 74 | 75 | /// The state of the scribble widget when the user is currently erasing. 76 | const factory ScribbleState.erasing({ 77 | /// The current state of the sketch 78 | required Sketch sketch, 79 | 80 | /// Which pointers are allowed for drawing and will be captured by the 81 | /// scribble widget. 82 | @Default(ScribblePointerMode.all) ScribblePointerMode allowedPointersMode, 83 | 84 | /// The ids of all supported pointers that are currently interacting with 85 | /// the widget. 86 | @Default([]) List activePointerIds, 87 | 88 | /// The current position of the pointer 89 | Point? pointerPosition, 90 | 91 | /// The current width of the pen 92 | @Default(5) double selectedWidth, 93 | 94 | /// How much the widget is scaled at the moment. 95 | /// 96 | /// Can be used if zoom functionality is needed 97 | /// (e.g. through InteractiveViewer) so that the pen width remains the same. 98 | @Default(1) double scaleFactor, 99 | 100 | /// The current tolerance of simplification, in pixels. 101 | /// 102 | /// Lines will be simplified when they are finished. A value of 0 (default) 103 | /// will mean no simplification. 104 | @Default(0) double simplificationTolerance, 105 | }) = Erasing; 106 | 107 | /// Constructs a [ScribbleState] from a JSON object. 108 | factory ScribbleState.fromJson(Map json) => 109 | _$ScribbleStateFromJson(json); 110 | const ScribbleState._(); 111 | 112 | /// Returns whether the widget is currently active, meaning that only one 113 | /// pointer is interacting with the widget. 114 | bool get active => activePointerIds.length <= 1; 115 | 116 | /// Returns the list of lines that should be drawn on the canvas by 117 | /// combining the sketches lines with the current active line if it exists. 118 | List get lines => map( 119 | drawing: (d) => d.activeLine == null 120 | ? sketch.lines 121 | : [...sketch.lines, d.activeLine!], 122 | erasing: (d) => d.sketch.lines, 123 | ); 124 | 125 | /// Returns a set of [PointerDeviceKind] that represents the currently 126 | /// supported devices, depending on [ScribbleState.allowedPointersMode]. 127 | Set get supportedPointerKinds { 128 | switch (allowedPointersMode) { 129 | case ScribblePointerMode.all: 130 | return Set.from(PointerDeviceKind.values); 131 | case ScribblePointerMode.mouseOnly: 132 | return const {PointerDeviceKind.mouse}; 133 | case ScribblePointerMode.penOnly: 134 | return const { 135 | PointerDeviceKind.stylus, 136 | PointerDeviceKind.invertedStylus, 137 | }; 138 | case ScribblePointerMode.mouseAndPen: 139 | return const { 140 | PointerDeviceKind.mouse, 141 | PointerDeviceKind.stylus, 142 | PointerDeviceKind.invertedStylus, 143 | }; 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /lib/src/view/state/scribble.state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'scribble.state.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$DrawingImpl _$$DrawingImplFromJson(Map json) => 10 | _$DrawingImpl( 11 | sketch: Sketch.fromJson(json['sketch'] as Map), 12 | activeLine: json['activeLine'] == null 13 | ? null 14 | : SketchLine.fromJson(json['activeLine'] as Map), 15 | allowedPointersMode: $enumDecodeNullable( 16 | _$ScribblePointerModeEnumMap, json['allowedPointersMode']) ?? 17 | ScribblePointerMode.all, 18 | activePointerIds: (json['activePointerIds'] as List?) 19 | ?.map((e) => (e as num).toInt()) 20 | .toList() ?? 21 | const [], 22 | pointerPosition: json['pointerPosition'] == null 23 | ? null 24 | : Point.fromJson(json['pointerPosition'] as Map), 25 | selectedColor: (json['selectedColor'] as num?)?.toInt() ?? 0xFF000000, 26 | selectedWidth: (json['selectedWidth'] as num?)?.toDouble() ?? 5, 27 | scaleFactor: (json['scaleFactor'] as num?)?.toDouble() ?? 1, 28 | simplificationTolerance: 29 | (json['simplificationTolerance'] as num?)?.toDouble() ?? 0, 30 | $type: json['runtimeType'] as String?, 31 | ); 32 | 33 | Map _$$DrawingImplToJson(_$DrawingImpl instance) => 34 | { 35 | 'sketch': instance.sketch.toJson(), 36 | 'activeLine': instance.activeLine?.toJson(), 37 | 'allowedPointersMode': 38 | _$ScribblePointerModeEnumMap[instance.allowedPointersMode]!, 39 | 'activePointerIds': instance.activePointerIds, 40 | 'pointerPosition': instance.pointerPosition?.toJson(), 41 | 'selectedColor': instance.selectedColor, 42 | 'selectedWidth': instance.selectedWidth, 43 | 'scaleFactor': instance.scaleFactor, 44 | 'simplificationTolerance': instance.simplificationTolerance, 45 | 'runtimeType': instance.$type, 46 | }; 47 | 48 | const _$ScribblePointerModeEnumMap = { 49 | ScribblePointerMode.all: 'all', 50 | ScribblePointerMode.mouseOnly: 'mouseOnly', 51 | ScribblePointerMode.penOnly: 'penOnly', 52 | ScribblePointerMode.mouseAndPen: 'mouseAndPen', 53 | }; 54 | 55 | _$ErasingImpl _$$ErasingImplFromJson(Map json) => 56 | _$ErasingImpl( 57 | sketch: Sketch.fromJson(json['sketch'] as Map), 58 | allowedPointersMode: $enumDecodeNullable( 59 | _$ScribblePointerModeEnumMap, json['allowedPointersMode']) ?? 60 | ScribblePointerMode.all, 61 | activePointerIds: (json['activePointerIds'] as List?) 62 | ?.map((e) => (e as num).toInt()) 63 | .toList() ?? 64 | const [], 65 | pointerPosition: json['pointerPosition'] == null 66 | ? null 67 | : Point.fromJson(json['pointerPosition'] as Map), 68 | selectedWidth: (json['selectedWidth'] as num?)?.toDouble() ?? 5, 69 | scaleFactor: (json['scaleFactor'] as num?)?.toDouble() ?? 1, 70 | simplificationTolerance: 71 | (json['simplificationTolerance'] as num?)?.toDouble() ?? 0, 72 | $type: json['runtimeType'] as String?, 73 | ); 74 | 75 | Map _$$ErasingImplToJson(_$ErasingImpl instance) => 76 | { 77 | 'sketch': instance.sketch.toJson(), 78 | 'allowedPointersMode': 79 | _$ScribblePointerModeEnumMap[instance.allowedPointersMode]!, 80 | 'activePointerIds': instance.activePointerIds, 81 | 'pointerPosition': instance.pointerPosition?.toJson(), 82 | 'selectedWidth': instance.selectedWidth, 83 | 'scaleFactor': instance.scaleFactor, 84 | 'simplificationTolerance': instance.simplificationTolerance, 85 | 'runtimeType': instance.$type, 86 | }; 87 | -------------------------------------------------------------------------------- /melos.yaml: -------------------------------------------------------------------------------- 1 | name: history_value_notifier_workspace 2 | 3 | packages: 4 | - . 5 | - packages/* 6 | - example 7 | 8 | command: 9 | version: 10 | updateGitTagRefs: true 11 | workspaceChangelog: false 12 | 13 | scripts: 14 | analyze: 15 | run: | 16 | dart analyze . --fatal-infos 17 | exec: 18 | # We are setting the concurrency to 1 because a higher concurrency can crash 19 | # the analysis server on low performance machines (like GitHub Actions). 20 | concurrency: 1 21 | description: | 22 | Run `dart analyze` in all packages. 23 | - Note: you can also rely on your IDEs Dart Analysis / Issues window. 24 | 25 | test:select: 26 | run: flutter test 27 | exec: 28 | failFast: true 29 | concurrency: 6 30 | packageFilters: 31 | dirExists: test 32 | description: Run `flutter test test` for selected packages. 33 | 34 | test: 35 | run: melos run test:select --no-select 36 | description: Run all tests in this project. 37 | 38 | coverage:select: 39 | run: | 40 | flutter test --coverage 41 | exec: 42 | failFast: true 43 | concurrency: 6 44 | packageFilters: 45 | dirExists: test 46 | description: Generate coverage for the selected package. 47 | 48 | coverage: 49 | run: melos run coverage:select --no-select 50 | description: Generate coverage for all packages. 51 | 52 | generate:select: 53 | description: Run code generation for selected packages. 54 | run: dart run build_runner build --delete-conflicting-outputs 55 | exec: 56 | concurrency: 1 57 | failFast: true 58 | packageFilters: 59 | dependsOn: 60 | - build_runner 61 | 62 | generate: 63 | description: Run code generation for all packages. 64 | run: melos run generate:select --no-select -------------------------------------------------------------------------------- /packages/simpli/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | .mason/ 12 | migrate_working_dir/ 13 | 14 | # IntelliJ related 15 | *.iml 16 | *.ipr 17 | *.iws 18 | .idea/ 19 | 20 | # See https://www.dartlang.org/guides/libraries/private-files 21 | 22 | # Files and directories created by pub 23 | .dart_tool/ 24 | .packages 25 | build/ 26 | pubspec.lock 27 | pubspec_overrides.yaml -------------------------------------------------------------------------------- /packages/simpli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.1+2 2 | 3 | - **FIX**: remove emoji from pubspec. 4 | - **FIX**: updated meta dependency constraints. 5 | 6 | ## 0.1.1+1 7 | 8 | - **FIX**: updated meta dependency constraints. 9 | 10 | ## 0.1.1 11 | 12 | - **FEAT**: added simpli package (#50). 13 | 14 | ## 0.1.0+1 15 | 16 | - feat: initial commit 🎉 17 | -------------------------------------------------------------------------------- /packages/simpli/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tim Lehmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/simpli/README.md: -------------------------------------------------------------------------------- 1 | # Simpli 2 | 3 | [![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) 4 | [![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg)](https://github.com/invertase/melos) 5 | ![coverage](./coverage.svg) 6 | 7 | 8 | Polyline simplification algorithms, made simple! Sporting exciting algorithms such as ✨Ramer-Douglas-Peucker✨ and ✨Visvalingam✨. 9 | 10 | ## Installation 💻 11 | 12 | **❗ In order to start using Simpli you must have the [Dart SDK][dart_install_link] installed on your machine.** 13 | 14 | Install via `dart pub add`: 15 | 16 | ```sh 17 | dart pub add simpli 18 | ``` 19 | 20 | 21 | ## Usage 🚀 22 | 23 | ```dart 24 | import 'package:simpli/simpli.dart'; 25 | 26 | void main() { 27 | final points = [ 28 | Point(0, 0), 29 | Point(1, 1), 30 | Point(2, 0), 31 | Point(3, 3), 32 | Point(4, 0), 33 | ]; 34 | 35 | final rdpSimplified = Simpli.ramerDouglasPeucker(points, pixelTolerance: 50.0); 36 | final visvalingamSimplified = Simpli.visvalingam(points, pixelTolerance: 50.0); 37 | 38 | print(simplifiedPoints); 39 | print(visvalingamSimplified); 40 | } 41 | ``` 42 | 43 | --- 44 | 45 | 46 | [dart_install_link]: https://dart.dev/get-dart 47 | [github_actions_link]: https://docs.github.com/en/actions/learn-github-actions 48 | [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg 49 | [license_link]: https://opensource.org/licenses/MIT 50 | [mason_link]: https://github.com/felangel/mason 51 | [very_good_ventures_link]: https://verygood.ventures 52 | -------------------------------------------------------------------------------- /packages/simpli/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lintervention/analysis_options.yaml 2 | -------------------------------------------------------------------------------- /packages/simpli/coverage.svg: -------------------------------------------------------------------------------- 1 | simpli: 100.00%simpli100.00% -------------------------------------------------------------------------------- /packages/simpli/coverage/lcov.info: -------------------------------------------------------------------------------- 1 | SF:lib/src/data/visvalingam_simplifier.dart 2 | DA:18,1 3 | DA:20,1 4 | DA:25,3 5 | DA:28,1 6 | DA:29,1 7 | DA:31,2 8 | DA:35,1 9 | DA:36,1 10 | DA:37,5 11 | DA:38,2 12 | DA:42,2 13 | DA:44,1 14 | DA:48,2 15 | DA:53,2 16 | DA:57,1 17 | DA:58,1 18 | DA:59,1 19 | DA:62,1 20 | DA:64,1 21 | DA:67,1 22 | DA:70,1 23 | DA:71,1 24 | DA:77,1 25 | DA:79,4 26 | DA:80,1 27 | DA:82,1 28 | DA:83,2 29 | DA:84,1 30 | DA:85,2 31 | DA:87,1 32 | DA:95,1 33 | DA:102,1 34 | DA:103,1 35 | DA:104,3 36 | DA:109,1 37 | DA:112,1 38 | DA:114,1 39 | DA:115,1 40 | DA:116,1 41 | DA:123,1 42 | DA:124,1 43 | DA:125,1 44 | LF:42 45 | LH:42 46 | end_of_record 47 | SF:lib/src/data/rdp_simplifier.dart 48 | DA:12,1 49 | DA:14,1 50 | DA:19,2 51 | DA:24,4 52 | DA:25,1 53 | DA:26,1 54 | DA:27,1 55 | DA:28,1 56 | DA:30,1 57 | DA:36,4 58 | DA:38,1 59 | DA:39,2 60 | DA:42,1 61 | DA:43,1 62 | DA:47,1 63 | DA:48,3 64 | DA:49,1 65 | DA:50,1 66 | LF:18 67 | LH:18 68 | end_of_record 69 | SF:lib/src/data/utils.dart 70 | DA:10,2 71 | DA:15,16 72 | DA:16,14 73 | DA:17,2 74 | DA:19,20 75 | DA:20,2 76 | DA:24,1 77 | DA:29,17 78 | DA:30,1 79 | DA:31,1 80 | LF:10 81 | LH:10 82 | end_of_record 83 | -------------------------------------------------------------------------------- /packages/simpli/lib/simpli.dart: -------------------------------------------------------------------------------- 1 | /// Polyline simplification algorithms, made simple! 2 | /// Sporting Ramer-Douglas-Peucker and Visvalingam 3 | library simpli; 4 | 5 | export 'src/domain/simplifier.dart'; 6 | export 'src/simpli.dart'; 7 | -------------------------------------------------------------------------------- /packages/simpli/lib/src/data/rdp_simplifier.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:simpli/src/data/utils.dart'; 4 | import 'package:simpli/src/domain/simplifier.dart'; 5 | 6 | /// {@template rdp_simplifier} 7 | /// A simplifier that uses the Ramer-Douglas-Peucker algorithm to simplify a 8 | /// list of points. 9 | /// {@endtemplate} 10 | class RdpSimplifier implements Simplifier { 11 | /// {@macro rdp_simplifier} 12 | const RdpSimplifier(); 13 | 14 | @override 15 | List> simplify( 16 | List> points, { 17 | double pixelTolerance = 50.0, 18 | }) { 19 | if (points.length < 3) { 20 | return points; 21 | } 22 | var maxDistance = 0.0; 23 | var index = 0; 24 | for (var i = 1; i < points.length - 1; i++) { 25 | final distance = Utils.perpendicularDistance( 26 | point: points[i], 27 | lineStart: points.first, 28 | lineEnd: points.last, 29 | ); 30 | if (distance > maxDistance) { 31 | maxDistance = distance; 32 | index = i; 33 | } 34 | } 35 | 36 | if (maxDistance <= pixelTolerance) return [points.first, points.last]; 37 | 38 | final firstPart = simplify( 39 | points.sublist(0, index + 1), 40 | pixelTolerance: pixelTolerance, 41 | ); 42 | final secondPart = simplify( 43 | points.sublist(index), 44 | pixelTolerance: pixelTolerance, 45 | ); 46 | 47 | return [ 48 | ...firstPart.sublist(0, firstPart.length - 1), 49 | points[index], 50 | ...secondPart.sublist(1), 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/simpli/lib/src/data/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | /// A collection of utility functions. 4 | abstract class Utils { 5 | const Utils._(); 6 | 7 | /// Calculates the perpendicular distance from a point to a line segment. 8 | /// 9 | /// From https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line 10 | static double perpendicularDistance({ 11 | required Point point, 12 | required Point lineStart, 13 | required Point lineEnd, 14 | }) { 15 | final numerator = ((lineEnd.x - lineStart.x) * (lineStart.y - point.y) - 16 | (lineStart.x - point.x) * (lineEnd.y - lineStart.y)) 17 | .abs(); 18 | final denominator = 19 | sqrt(pow(lineEnd.x - lineStart.x, 2) + pow(lineEnd.y - lineStart.y, 2)); 20 | return numerator / denominator; 21 | } 22 | 23 | /// Calculates the are of a triangle given its three points. 24 | static double triangleArea({ 25 | required Point a, 26 | required Point b, 27 | required Point c, 28 | }) { 29 | return (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) 30 | .toDouble() 31 | .abs(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/simpli/lib/src/data/visvalingam_simplifier.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:collection/collection.dart'; 4 | import 'package:meta/meta.dart'; 5 | import 'package:simpli/src/data/utils.dart'; 6 | import 'package:simpli/src/domain/simplifier.dart'; 7 | 8 | /// A point with an optional area 9 | @visibleForTesting 10 | typedef PointWithArea = (Point, double?); 11 | 12 | /// {@template visvalingam_simplifier} 13 | /// A simplifier that uses the Visvalingam algorithm to simplify a list of 14 | /// points. 15 | /// {@endtemplate} 16 | class VisvalingamSimplifier implements Simplifier { 17 | /// {@macro visvalingam_simplifier} 18 | const VisvalingamSimplifier(); 19 | 20 | @override 21 | List> simplify( 22 | List> points, { 23 | double pixelTolerance = 50, 24 | }) { 25 | if (points.length < 3 || pixelTolerance <= 0) { 26 | return points; 27 | } 28 | final simplified = [...points]; 29 | final heap = HeapPriorityQueue( 30 | // Points with lower area should be the first to be removed. 31 | (p0, p1) => p0.$2!.compareTo(p1.$2!), 32 | ); 33 | 34 | // Contains all points besides the first and last, together with their area 35 | final pointsWithArea = [ 36 | (points.first, null), 37 | for (var i = 1; i < points.length - 1; i++) pointWithArea(points, i), 38 | (points.last, null), 39 | ]; 40 | 41 | // Add all points that have an area to the heap 42 | for (final p in pointsWithArea) { 43 | if (p.$2 != null) { 44 | heap.add(p); 45 | } 46 | } 47 | 48 | for (var point = heap.pop(); point != null; point = heap.pop()) { 49 | final area = point.$2; 50 | if (area == null) continue; 51 | 52 | // If the points area is larger than the tolerance squared, we can stop. 53 | if (area >= pixelTolerance * pixelTolerance) { 54 | break; 55 | } 56 | 57 | final index = pointsWithArea.indexOf(point); 58 | if (index < 0) continue; 59 | tryRecompute( 60 | pointsWithArea: pointsWithArea, 61 | heap: heap, 62 | index: index - 1, 63 | ); 64 | tryRecompute( 65 | pointsWithArea: pointsWithArea, 66 | heap: heap, 67 | index: index + 1, 68 | ); 69 | 70 | simplified.removeAt(index); 71 | pointsWithArea.removeAt(index); 72 | } 73 | return simplified; 74 | } 75 | 76 | /// Calculates the area of a point in a list of points. 77 | @visibleForTesting 78 | PointWithArea pointWithArea(List> points, int index) { 79 | if (index == 0 || index >= points.length - 1) { 80 | throw Exception('Index out of bounds for pointWithArea.'); 81 | } 82 | final area = Utils.triangleArea( 83 | a: points[index - 1], 84 | b: points[index], 85 | c: points[index + 1], 86 | ); 87 | return (points[index], area); 88 | } 89 | 90 | /// Recomputes the area of a point in a list of [pointsWithArea] and updates 91 | /// it in the [heap]. 92 | /// 93 | /// This is not a pure function, it will mutate the [pointsWithArea] and 94 | /// [heap]. 95 | @visibleForTesting 96 | void tryRecompute({ 97 | required List pointsWithArea, 98 | required HeapPriorityQueue heap, 99 | required int index, 100 | }) { 101 | try { 102 | final oldPoint = pointsWithArea[index]; 103 | final newPointWithArea = pointWithArea( 104 | pointsWithArea.map((p) => p.$1).toList(), 105 | index, 106 | ); 107 | final newPoint = ( 108 | newPointWithArea.$1, 109 | max(oldPoint.$2!, newPointWithArea.$2!), 110 | ); 111 | 112 | pointsWithArea[index] = newPoint; 113 | heap 114 | ..remove(oldPoint) 115 | ..add(newPoint); 116 | } on Exception { 117 | // Do nothing 118 | } 119 | } 120 | } 121 | 122 | extension on PriorityQueue { 123 | T? pop() { 124 | if (isEmpty) return null; 125 | return removeFirst(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /packages/simpli/lib/src/domain/simplifier.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:simpli/src/data/rdp_simplifier.dart'; 4 | import 'package:simpli/src/data/visvalingam_simplifier.dart'; 5 | 6 | /// Represents a line simplificatoin algorithm. 7 | abstract interface class Simplifier { 8 | const factory Simplifier.ramerDouglasPeucker() = RdpSimplifier; 9 | const factory Simplifier.visvalingam() = VisvalingamSimplifier; 10 | 11 | /// Simplifies a list of points using a sensible default simplification. 12 | /// 13 | /// The [pixelTolerance] parameter is used to determine the tolerance for 14 | /// simplification. A value of 0.0 will result in no simplification. 15 | /// Defaults to 50px. 16 | /// The exact effect of the value will depend on the implementation. 17 | List simplify( 18 | List points, { 19 | double pixelTolerance = 50.0, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /packages/simpli/lib/src/simpli.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:simpli/src/data/rdp_simplifier.dart'; 4 | import 'package:simpli/src/data/visvalingam_simplifier.dart'; 5 | 6 | /// {@template simpli} 7 | /// Line simplification algorithms, made simple! Sporting Ramer-Douglas-Peucker 8 | /// and Visvalingam 9 | /// {@endtemplate} 10 | abstract class Simpli { 11 | /// {@macro simpli} 12 | const Simpli._(); 13 | 14 | /// Simplifies a list of points using the Ramer-Douglas-Peucker algorithm 15 | /// using the given [pixelTolerance]. 16 | static List ramerDouglasPeucker( 17 | List points, { 18 | double pixelTolerance = 50.0, 19 | }) { 20 | return const RdpSimplifier() 21 | .simplify(points, pixelTolerance: pixelTolerance); 22 | } 23 | 24 | /// Simplifies a list of points using the Visvalingam algorithm 25 | /// using the given [pixelTolerance]. 26 | static List> visvalingam( 27 | List> points, { 28 | double pixelTolerance = 50.0, 29 | }) { 30 | return const VisvalingamSimplifier() 31 | .simplify(points, pixelTolerance: pixelTolerance); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/simpli/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: simpli 2 | description: Polyline simplification algorithms, made simple! Sporting exciting algorithms such as Ramer-Douglas-Peucker and Visvalingam. 3 | version: 0.1.1+2 4 | repository: https://github.com/timcreatedit/scribble/tree/main/packages/simpli 5 | homepage: https://whynotmake.it 6 | 7 | 8 | environment: 9 | sdk: ">=3.0.0 <4.0.0" 10 | 11 | dependencies: 12 | collection: ">=1.0.0 <2.0.0" 13 | meta: ">= 1.0.0 <2.0.0" 14 | 15 | dev_dependencies: 16 | lintervention: ^0.1.1 17 | mocktail: ^1.0.3 18 | test: ^1.25.2 19 | 20 | -------------------------------------------------------------------------------- /packages/simpli/test/src/data/rdp_simplifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:simpli/src/data/rdp_simplifier.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('RdpSimplifier', () { 8 | late RdpSimplifier sut; 9 | setUp(() { 10 | sut = const RdpSimplifier(); 11 | }); 12 | 13 | const path = [ 14 | Point(0, 0), 15 | Point(1, 1), 16 | Point(2, 0), 17 | Point(3, 2), 18 | Point(4, 0), 19 | Point(5, 1), 20 | Point(6, 0), 21 | Point(7, 2), 22 | Point(8, 0), 23 | Point(9, 1), 24 | Point(10, 0), 25 | ]; 26 | 27 | group('simplify', () { 28 | test('does not simplify when tolerance is zero', () async { 29 | final simplified = sut.simplify(path, pixelTolerance: 0); 30 | expect(simplified, path); 31 | }); 32 | 33 | test('does not simplify when path has less than 3 points', () async { 34 | const shortLine = [Point(0, 0), Point(1, 1)]; 35 | final simplified = sut.simplify(shortLine, pixelTolerance: 1); 36 | expect(simplified, shortLine); 37 | }); 38 | 39 | test('strips all points if tolerance is infinite', () async { 40 | final simplified = sut.simplify(path, pixelTolerance: double.infinity); 41 | expect(simplified, [path.first, path.last]); 42 | }); 43 | 44 | test('simplifies path', () async { 45 | final simplified = sut.simplify(path, pixelTolerance: 1); 46 | final pathOnlyOutliers = 47 | path.where((p) => p.y == 2 || p.y == 0).toList(); 48 | expect(simplified, pathOnlyOutliers); 49 | }); 50 | }); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /packages/simpli/test/src/data/utils_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:simpli/src/data/utils.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | setUp(() {}); 8 | 9 | group('Utils', () { 10 | group('perpendicularDistance', () { 11 | test('0 if point equals start', () async { 12 | final distance = Utils.perpendicularDistance( 13 | point: const Point(0, 0), 14 | lineStart: const Point(0, 0), 15 | lineEnd: const Point(2, 0), 16 | ); 17 | expect(distance, 0); 18 | }); 19 | test('0 if point equals end', () async { 20 | final distance = Utils.perpendicularDistance( 21 | point: const Point(0, 0), 22 | lineStart: const Point(0, 0), 23 | lineEnd: const Point(2, 0), 24 | ); 25 | expect(distance, 0); 26 | }); 27 | test('0 if point is on line', () async { 28 | final distance = Utils.perpendicularDistance( 29 | point: const Point(1, 0), 30 | lineStart: const Point(0, 0), 31 | lineEnd: const Point(2, 0), 32 | ); 33 | expect(distance, 0); 34 | }); 35 | 36 | test('correct distance if point is above line', () async { 37 | final distance = Utils.perpendicularDistance( 38 | point: const Point(1, 1), 39 | lineStart: const Point(0, 0), 40 | lineEnd: const Point(2, 0), 41 | ); 42 | expect(distance, 1); 43 | }); 44 | 45 | test('correct distance if point is next to line', () async { 46 | final distance = Utils.perpendicularDistance( 47 | point: const Point(4, 0), 48 | lineStart: const Point(0, 0), 49 | lineEnd: const Point(2, 0), 50 | ); 51 | expect(distance, 0); 52 | }); 53 | 54 | test('correct distance if point is below line', () async { 55 | final distance = Utils.perpendicularDistance( 56 | point: const Point(1, -1), 57 | lineStart: const Point(0, 0), 58 | lineEnd: const Point(2, 0), 59 | ); 60 | expect(distance, 1); 61 | }); 62 | 63 | test('works for angled lines', () async { 64 | final distance = Utils.perpendicularDistance( 65 | point: const Point(1, 1), 66 | lineStart: const Point(0, 0), 67 | lineEnd: const Point(2, 2), 68 | ); 69 | expect(distance, 0); 70 | }); 71 | }); 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /packages/simpli/test/src/data/visvalingam_simplifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:simpli/src/data/visvalingam_simplifier.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('VisvalingamSimplifier', () { 8 | late VisvalingamSimplifier sut; 9 | setUp(() { 10 | sut = const VisvalingamSimplifier(); 11 | }); 12 | 13 | const path = [ 14 | Point(0, 0), 15 | Point(1, 1), 16 | Point(2, 0), 17 | Point(3, 2), 18 | Point(4, 0), 19 | Point(5, 1), 20 | Point(6, 0), 21 | Point(7, 2), 22 | Point(8, 0), 23 | Point(9, 1), 24 | Point(10, 2), 25 | ]; 26 | 27 | group('simplify', () { 28 | test('does not simplify when tolerance == 0', () async { 29 | final simplified = sut.simplify(path, pixelTolerance: 0); 30 | expect(simplified, path); 31 | }); 32 | 33 | test('does not simplify when path has less than 3 points', () async { 34 | const shortLine = [Point(0, 0), Point(1, 1)]; 35 | final simplified = sut.simplify(shortLine, pixelTolerance: 1); 36 | expect(simplified, shortLine); 37 | }); 38 | 39 | test('strips all points if tolerance is infinity', () async { 40 | final simplified = sut.simplify(path, pixelTolerance: double.infinity); 41 | expect(simplified, [path.first, path.last]); 42 | }); 43 | 44 | test('simplifies path', () async { 45 | final simplified = sut.simplify(path, pixelTolerance: 1.5); 46 | final pathOnlyOutliers = 47 | path.where((p) => p.y == 2 || p.y == 0).toList(); 48 | expect(simplified, pathOnlyOutliers); 49 | }); 50 | }); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | .mason/ 12 | migrate_working_dir/ 13 | 14 | # IntelliJ related 15 | *.iml 16 | *.ipr 17 | *.iws 18 | .idea/ 19 | 20 | # See https://www.dartlang.org/guides/libraries/private-files 21 | 22 | # Files and directories created by pub 23 | .dart_tool/ 24 | .packages 25 | build/ 26 | pubspec.lock 27 | pubspec_overrides.yaml -------------------------------------------------------------------------------- /packages/value_notifier_tools/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.2 2 | 3 | - **FIX**(value_notifier_tools): fixed type parameters in select extension. 4 | - **FEAT**(example): updated example to use `SelectValueNotifier`. 5 | 6 | ## 0.1.1 7 | 8 | - **FIX**(value_notifier_tools): Added all classes to package exports. 9 | - **FEAT**: use `HistoryValueNotifier` from new package. 10 | - **FEAT**(value_notifier_tools): added `HistoryValueNotifier`. 11 | - **FEAT**(value_notifier_tools): added where_value_notifier. 12 | - **FEAT**(value_notifier_tools): added select value notifier. 13 | - **DOCS**(value_notifier_tools): added README. 14 | 15 | ## 0.1.0+1 16 | 17 | - feat: initial commit 🎉 18 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tim Lehmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/README.md: -------------------------------------------------------------------------------- 1 | # Value Notifier Tools 2 | 3 | [![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) 4 | [![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) 5 | ![coverage](./coverage.svg) 6 | 7 | Helpful lightweight tools for working with `ValueNotifier`s 8 | 9 | ## Installation 💻 10 | 11 | **❗ In order to start using History Value Notifier you must have the [Dart SDK][dart_install_link] installed on your machine.** 12 | 13 | Install via `dart pub add`: 14 | 15 | ```sh 16 | dart pub add value_notifier_tools 17 | ``` 18 | 19 | ## Features 20 | This package adds helpful tools for working with `ValueNotifier`s. Currently, it offers the following: 21 | 22 | * 🕐 [HistoryValueNotifier](#historyvaluenotifier) allows you to undo and redo changes to the state of the notifier. This is useful for implementing undo/redo functionality in your app. 23 | * 🎯 [SelectValueNotifier](#selectvaluenotifier) allows you to select a subset of the state of a `ValueNotifier`. This is useful for when you only want to listen to a specific part of the state of and rebuild a widget when that changes. 24 | * 🔎 [WhereValueNotifier](#wherevaluenotifier) allows you to provide a custom predicate to determine whether a state change should be propagated to listeners. This is useful for when only certain state transitions should cause a rebuild. 25 | * 🪶 No dependencies on any other packages and super lightweight. 26 | * 🧩 Easy to use and integrate into your existing projects. 27 | * 🧪 100% test coverage 28 | 29 | 30 | ## HistoryValueNotifier 31 | 32 | * ↩️ Add `undo()` and `redo()` to `ValueNotifier` 33 | * 🕐 Limit the size of your history 34 | * 💕 Offers both a mixin that can be added to your existing `ValueNotifier`s and a class that you can extend 35 | * 🔎 Choose which states get stored to the history 36 | * 🔄 Transform states before applying them from the history 37 | 38 | ### Usage 39 | Getting started is easy! There are three main ways in which you can add `HistoryValueNotifier` to your project: 40 | 41 | #### Use it as-is 42 | If you don't need any extra functionality, you can use `HistoryValueNotifier` as-is. 43 | 44 | ```dart 45 | import 'package:history_value_notifier/history_value_notifier.dart'; 46 | 47 | final notifier = HistoryValueNotifier(0); 48 | notifier.value = 1; 49 | notifier.undo(); // 0 50 | notifier.redo(); // 1 51 | ``` 52 | 53 | #### Upgrade an existing `ValueNotifier` 54 | 55 | ```dart 56 | class CounterNotifier extends ValueNotifier 57 | with HistoryValueNotifierMixin { 58 | CounterNotifier() : super(0) { 59 | // This is how you limit the size of your history. 60 | // Set it to null to keep all state (default) 61 | maxHistoryLength = 30; 62 | } 63 | 64 | void increment() => ++state; 65 | 66 | void decrement() => --state; 67 | 68 | // By using temporaryState setter, the change won't be stored in history 69 | void reset() => temporaryState = 0; 70 | 71 | // You can override this function to apply a transformation to a state 72 | // from the history before it gets applied. 73 | @override 74 | int transformHistoryState(int newState, int currentState) { 75 | return newState; 76 | } 77 | } 78 | ``` 79 | 80 | #### Create a `HistoryValueNotifier` 81 | 82 | If you prefer to create a `HistoryValueNotifier` directly, you can do this instead: 83 | 84 | ```dart 85 | class CounterNotifier extends HistoryValueNotifier { 86 | // ... Same as above 87 | } 88 | ``` 89 | 90 | #### Use It! 91 | 92 | You can now use the full functionality of the `HistoryValueNotifier`! 93 | 94 | ```dart 95 | // Obtain a reference however you wish (Provider, GetIt, etc.) 96 | final CounterNotifier notifier = context.read(counterNotifier); 97 | 98 | notifier.increment(); // 1 99 | notifier.undo(); // 0 100 | notifier.redo(); // 1 101 | 102 | notifier.decrement(); // 0 103 | notifier.undo(); // 1 104 | notifier.canRedo // true 105 | notifier.increment // 2 106 | notifier.canRedo // false 107 | 108 | // ... 109 | ``` 110 | 111 | ## SelectValueNotifier 112 | 113 | * 🎯 Select a subset of the state of a `ValueNotifier` 114 | * 🧩 Only listen to the parts of the state that you care about 115 | * 🏃 Micromanage rebuilds for maximum performance 116 | 117 | ### Usage 118 | 119 | Using `SelectValueNotifier` is super easy: 120 | 121 | #### Use the convenient `select` extension method 122 | This allows you to select a subset of the state of a `ValueNotifier` by providing a selector function anywhere you need. 123 | 124 | ```dart 125 | final notifier = ValueNotifier({'a': 1, 'b': 2}); 126 | 127 | return ValueListenableBuilder( 128 | valueListenable: notifier.select((value) => value['a']), 129 | builder: (context, value, child) { 130 | return Text(value.toString()); 131 | }, 132 | ); 133 | ``` 134 | Your `selectNotifier` will now only notify listeners when the value of `'a'` changes. 135 | 136 | ## WhereValueNotifier 137 | 138 | * 🔎 Provide a custom predicate to determine whether a state change should be propagated to listeners 139 | * 🧩 Only listen to the state changes that you care about 140 | 141 | ### Usage 142 | There are two main ways to use `WhereValueNotifier`: 143 | 144 | 145 | #### Use the `where` extension method 146 | This allows you to dynamically filter the state changes that you care about from another notifier. 147 | 148 | ```dart 149 | final notifier = ValueNotifier(0); 150 | 151 | return ValueListenableBuilder( 152 | valueListenable: notifier.where((oldState, newState) => newState > oldState), 153 | builder: (context, value, child) { 154 | return Text(value.toString()); 155 | }, 156 | ); 157 | ``` 158 | 159 | Now, your widget will only rebuild when the new state is greater than the old state. 160 | 161 | #### Extend it for your own custom classes 162 | You can also extend it and provide your own `updateShouldNotify` function. 163 | 164 | ```dart 165 | class IncreasingValueNotifier extends WhereValueNotifier { 166 | IncreasingValueNotifier(super.value); 167 | 168 | @override 169 | bool updateShouldNotify(T oldState, T newState) { 170 | return oldState < newState; 171 | } 172 | } 173 | ``` 174 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lintervention/analysis_options.yaml 2 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/coverage.svg: -------------------------------------------------------------------------------- 1 | value notifier tools: 100.00%value notifier tools100.00% -------------------------------------------------------------------------------- /packages/value_notifier_tools/coverage/lcov.info: -------------------------------------------------------------------------------- 1 | SF:lib/src/where_value_notifier/where_value_notifier_mixin.dart 2 | DA:8,4 3 | DA:10,2 4 | DA:11,2 5 | DA:13,2 6 | DA:15,2 7 | DA:16,2 8 | DA:17,4 9 | LF:7 10 | LH:7 11 | end_of_record 12 | SF:lib/src/where_value_notifier/where_value_notifier.dart 13 | DA:28,2 14 | LF:1 15 | LH:1 16 | end_of_record 17 | SF:lib/src/history_value_notifier/history_value_notifier.dart 18 | DA:17,1 19 | LF:1 20 | LH:1 21 | end_of_record 22 | SF:lib/src/history_value_notifier/history_value_notifier_mixin.dart 23 | DA:13,2 24 | DA:14,1 25 | DA:16,2 26 | DA:19,1 27 | DA:21,3 28 | DA:22,3 29 | DA:23,4 30 | DA:27,4 31 | DA:29,2 32 | DA:30,3 33 | DA:37,1 34 | DA:39,1 35 | DA:40,2 36 | DA:41,3 37 | DA:42,2 38 | DA:43,1 39 | DA:44,4 40 | DA:45,4 41 | DA:49,1 42 | DA:57,1 43 | DA:59,1 44 | DA:63,6 45 | DA:66,3 46 | DA:70,1 47 | DA:77,1 48 | DA:85,1 49 | DA:97,1 50 | DA:99,1 51 | DA:103,1 52 | DA:104,2 53 | DA:105,7 54 | DA:110,1 55 | DA:111,2 56 | DA:112,7 57 | DA:117,1 58 | DA:118,2 59 | DA:119,1 60 | DA:120,2 61 | DA:127,1 62 | DA:128,1 63 | DA:129,2 64 | DA:132,1 65 | DA:133,1 66 | DA:134,6 67 | DA:135,1 68 | LF:45 69 | LH:45 70 | end_of_record 71 | SF:lib/src/select_value_notifier/select_value_notifier.dart 72 | DA:15,1 73 | DA:18,3 74 | DA:19,3 75 | DA:30,1 76 | DA:33,1 77 | DA:36,1 78 | DA:37,5 79 | DA:40,1 80 | DA:42,3 81 | DA:43,1 82 | DA:55,1 83 | DA:58,1 84 | LF:12 85 | LH:12 86 | end_of_record 87 | SF:lib/src/where_value_notifier/where_value_notifier_from_parent.dart 88 | DA:13,1 89 | DA:17,2 90 | DA:18,3 91 | DA:27,1 92 | DA:29,2 93 | DA:32,1 94 | DA:33,3 95 | DA:36,1 96 | DA:38,2 97 | DA:41,1 98 | DA:43,3 99 | DA:44,1 100 | DA:52,1 101 | DA:53,1 102 | LF:14 103 | LH:14 104 | end_of_record 105 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/lib/src/history_value_notifier/history_value_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:value_notifier_tools/src/history_value_notifier/history_value_notifier_mixin.dart'; 3 | 4 | /// {@template history_value_notifier} 5 | /// Works like a [ValueNotifier] with the added benefit of maintaining an 6 | /// internal undo history that can be navigated through. 7 | /// 8 | /// All states that get set using the [value] setter will automatically be 9 | /// remembered for up to [maxHistoryLength] entries. If you want to update 10 | /// the state without adding it to the history, use the [temporaryValue] setter 11 | /// instead. 12 | /// > Note: The initial undo history will start with the initial [value]. 13 | /// {@endtemplate} 14 | class HistoryValueNotifier extends ValueNotifier 15 | with HistoryValueNotifierMixin { 16 | /// {@macro history_value_notifier} 17 | HistoryValueNotifier(super._value); 18 | } 19 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/lib/src/history_value_notifier/history_value_notifier_mixin.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:value_notifier_tools/src/history_value_notifier/history_value_notifier.dart'; 3 | 4 | /// Use this mixin on any [ValueNotifier] to add a history functionality to it. 5 | /// 6 | /// {@macro history_value_notifier} 7 | mixin HistoryValueNotifierMixin on ValueNotifier { 8 | int? _maxHistoryLength; 9 | 10 | /// How many values to keep track of. 11 | /// 12 | /// If null, all values will be stored. 13 | int? get maxHistoryLength => _maxHistoryLength; 14 | set maxHistoryLength(int? value) { 15 | assert( 16 | value == null || value >= 0, 17 | "The maxHistoryLength can't be negative!", 18 | ); 19 | _maxHistoryLength = value; 20 | if (value == null) return; 21 | if (_undoHistory.length > value) { 22 | _undoHistory = _undoHistory.sublist(0, value); 23 | _undoIndex = _undoIndex >= value ? value - 1 : _undoIndex; 24 | } 25 | } 26 | 27 | bool get _keepAny => _maxHistoryLength == null || _maxHistoryLength! > 0; 28 | 29 | late List _undoHistory = [ 30 | if (includeInitialValueInHistory && _keepAny) value, 31 | ]; 32 | 33 | int _undoIndex = 0; 34 | 35 | /// Sets the value of this [HistoryValueNotifier], notifies listeners, 36 | /// and adds the new value to the undo history. 37 | @override 38 | set value(T value) { 39 | _internalClearRedoQueue(); 40 | if (_undoHistory.isEmpty || 41 | shouldInsertValueIntoQueue(value, _undoHistory[0])) { 42 | _undoHistory.insert(0, value); 43 | if (_maxHistoryLength != null && 44 | _undoHistory.length > _maxHistoryLength!) { 45 | _undoHistory = _undoHistory.sublist(0, _maxHistoryLength); 46 | } 47 | } 48 | 49 | super.value = value; 50 | } 51 | 52 | /// Sets the current "value" of this [HistoryValueNotifier] **without** adding 53 | /// the new value to the undo history. 54 | /// 55 | /// This is helpful for loading values or in general any other value that the 56 | /// user should not be able to undo to. 57 | @protected 58 | set temporaryValue(T value) { 59 | super.value = value; 60 | } 61 | 62 | /// Whether currently an undo operation is possible. 63 | bool get canUndo => _undoIndex + 1 < _undoHistory.length; 64 | 65 | /// Whether a redo operation is currently possible. 66 | bool get canRedo => _undoIndex > 0; 67 | 68 | /// You can override this to prevent undo/redo operations in certain cases 69 | /// (e.g. when in a loading value) 70 | @protected 71 | bool get allowOperations => true; 72 | 73 | /// Whether to include the initial [value] in the undo history. 74 | /// 75 | /// `true` by default, override and set to false if the initial value should 76 | /// be treated like [temporaryValue]. 77 | @protected 78 | bool get includeInitialValueInHistory => true; 79 | 80 | /// You can override this function if you want to transform values from the 81 | /// history before they get applied. 82 | /// 83 | /// This can be useful if your value contains values that aren't supposed 84 | /// to be changed upon undoing for example. 85 | @protected 86 | T transformHistoryValue(T newValue, T currentValue) { 87 | return newValue; 88 | } 89 | 90 | /// You can override this function if you want to filter certain values before 91 | /// adding them to the history. 92 | /// 93 | /// By default, this uses value equality, but you could for example always 94 | /// return true in case your value doesn't support value equality. 95 | /// [newValue] holds the value that's supposed to be added, [lastInQueue] is 96 | /// the value that's currently last in the undo queue. 97 | @protected 98 | bool shouldInsertValueIntoQueue(T newValue, T lastInQueue) { 99 | return newValue != lastInQueue; 100 | } 101 | 102 | /// Returns to the previous value in the history. 103 | void undo() { 104 | if (canUndo && allowOperations) { 105 | temporaryValue = transformHistoryValue(_undoHistory[++_undoIndex], value); 106 | } 107 | } 108 | 109 | /// Proceeds to the next value in the history. 110 | void redo() { 111 | if (canRedo && allowOperations) { 112 | temporaryValue = transformHistoryValue(_undoHistory[--_undoIndex], value); 113 | } 114 | } 115 | 116 | /// Removes all history items from the queue. 117 | void clearQueue() { 118 | _undoHistory = []; 119 | _undoIndex = 0; 120 | temporaryValue = value; 121 | } 122 | 123 | /// Removes all history items that happened after the current undo position. 124 | /// 125 | /// Internally this is used whenever a change occurs, but you might want to 126 | /// use it for something else. 127 | void clearRedoQueue() { 128 | _internalClearRedoQueue(); 129 | temporaryValue = value; 130 | } 131 | 132 | void _internalClearRedoQueue() { 133 | if (canRedo) { 134 | _undoHistory = _undoHistory.sublist(_undoIndex, _undoHistory.length); 135 | _undoIndex = 0; 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/lib/src/select_value_notifier/select_value_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | 3 | /// A function that maps a value of type [FromT] to a value of type [ToT]. 4 | typedef Selector = ToT Function(FromT value); 5 | 6 | /// {@template selected_value_notifier} 7 | /// A [ValueNotifier] that wraps another [ValueNotifier] and selects updates 8 | /// based on a provided function. 9 | /// 10 | /// Updates will only be sent to listeners when the new value is different from 11 | /// the previous value. 12 | /// {@endtemplate} 13 | class SelectValueNotifier extends ValueNotifier { 14 | /// {@macro selected_value_notifier} 15 | SelectValueNotifier({ 16 | required this.parentNotifier, 17 | required this.selector, 18 | }) : super(selector(parentNotifier.value)) { 19 | parentNotifier.addListener(_updateFromParent); 20 | } 21 | 22 | /// The [ValueNotifier] that this [SelectValueNotifier] listens to and 23 | /// filters updates from. 24 | final ValueNotifier parentNotifier; 25 | 26 | /// A function that maps the value of the parent notifier to the value of this 27 | /// notifier. 28 | final Selector selector; 29 | 30 | @override 31 | @protected 32 | set value(ToT newValue) { 33 | throw UnsupportedError('Cannot set value on SelectValueNotifier'); 34 | } 35 | 36 | void _updateFromParent() { 37 | super.value = selector(parentNotifier.value); 38 | } 39 | 40 | @override 41 | void dispose() { 42 | parentNotifier.removeListener(_updateFromParent); 43 | super.dispose(); 44 | } 45 | } 46 | 47 | /// An extension on [ValueNotifier] that provides a `select` method to create a 48 | /// [SelectValueNotifier] from the notifier. 49 | extension SelectValueNotifierX on ValueNotifier { 50 | /// Selects updates from this notifier using the provided [selector] 51 | /// function. 52 | /// 53 | /// The [selector] function is called whenever the parent notifier updates and 54 | /// the result is used as the new value for the [SelectValueNotifier]. 55 | SelectValueNotifier select( 56 | ToT Function(FromT value) selector, 57 | ) { 58 | return SelectValueNotifier( 59 | parentNotifier: this, 60 | selector: selector, 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/lib/src/where_value_notifier/where_value_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:value_notifier_tools/src/where_value_notifier/where_value_notifier_mixin.dart'; 3 | 4 | /// {@template where_value_notifier} 5 | /// A [ValueNotifier] that provides a custom [updateShouldNotify] function to 6 | /// determine whether the listener should be notified. 7 | /// 8 | /// Extend this to create a custom [ValueNotifier] that notifies listeners based 9 | /// on the provided [updateShouldNotify] function. 10 | /// 11 | /// **Example:** 12 | /// In this example, the MyValueNotifier notifies listeners when the new value 13 | /// is less than the previous value. 14 | /// ```dart 15 | /// class MyValueNotifier extends WhereValueNotifier { 16 | /// MyValueNotifier(int value) : super(value); 17 | /// 18 | /// @override 19 | /// bool updateShouldNotify(int previous, int next) { 20 | /// return previous > next; 21 | /// } 22 | /// } 23 | /// ``` 24 | /// {@endtemplate} 25 | abstract class WhereValueNotifier extends ValueNotifier 26 | with WhereValueNotifierMixin { 27 | /// {@macro where_value_notifier} 28 | WhereValueNotifier(super.value); 29 | } 30 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/lib/src/where_value_notifier/where_value_notifier_from_parent.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:value_notifier_tools/src/where_value_notifier/where_value_notifier.dart'; 3 | 4 | /// A function that compares the previous value with the new value and returns 5 | /// whether the listener should be notified. 6 | typedef WhereFilter = bool Function(T previous, T next); 7 | 8 | /// 9 | /// A [WhereValueNotifier] that listens to a parent [ValueNotifier] and notifies 10 | /// listeners based on the provided [updateShouldNotify] function. 11 | class WhereValueNotifierFromParent extends WhereValueNotifier { 12 | /// {@macro selected_value_notifier} 13 | WhereValueNotifierFromParent({ 14 | required this.parentNotifier, 15 | required WhereFilter updateShouldNotify, 16 | }) : filter = updateShouldNotify, 17 | super(parentNotifier.value) { 18 | parentNotifier.addListener(_parentListener); 19 | } 20 | 21 | /// The parent notifier to listen to. 22 | final ValueNotifier parentNotifier; 23 | 24 | /// The function that determines whether the listeners should be notified. 25 | final WhereFilter filter; 26 | 27 | @override 28 | set value(T newValue) { 29 | parentNotifier.value = newValue; 30 | } 31 | 32 | void _parentListener() { 33 | super.value = parentNotifier.value; 34 | } 35 | 36 | @override 37 | bool updateShouldNotify(T previous, T next) { 38 | return filter(previous, next); 39 | } 40 | 41 | @override 42 | void dispose() { 43 | parentNotifier.removeListener(_parentListener); 44 | super.dispose(); 45 | } 46 | } 47 | 48 | /// An extension on [ValueNotifier] that provides a `where` method to create a 49 | /// [WhereValueNotifier] from the notifier. 50 | extension SelectedValueNotifierX on ValueNotifier { 51 | /// Creates a [WhereValueNotifier] from the notifier. 52 | WhereValueNotifier where(WhereFilter updateShouldNotify) { 53 | return WhereValueNotifierFromParent( 54 | parentNotifier: this, 55 | updateShouldNotify: updateShouldNotify, 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/lib/src/where_value_notifier/where_value_notifier_mixin.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | 3 | /// Use this mixin on any [ValueNotifier] to add a custom [updateShouldNotify] 4 | /// function to determine whether the listeners should be notified. 5 | /// 6 | /// {@macro where_value_notifier} 7 | mixin WhereValueNotifierMixin on ValueNotifier { 8 | late T _value = super.value; 9 | 10 | @override 11 | T get value => _value; 12 | 13 | @override 14 | set value(T newValue) { 15 | final previous = _value; 16 | _value = newValue; 17 | if (updateShouldNotify(previous, newValue)) notifyListeners(); 18 | } 19 | 20 | /// The function that determines whether the listeners should be notified. 21 | /// 22 | /// If this function returns `true`, the listener will be notified. 23 | bool updateShouldNotify(T previous, T next); 24 | } 25 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/lib/value_notifier_tools.dart: -------------------------------------------------------------------------------- 1 | /// Helpful lightweight tools for working with ValueNotifiers 2 | library value_notifier_tools; 3 | 4 | export 'src/history_value_notifier/history_value_notifier.dart'; 5 | export 'src/history_value_notifier/history_value_notifier_mixin.dart'; 6 | export 'src/select_value_notifier/select_value_notifier.dart'; 7 | export 'src/where_value_notifier/where_value_notifier.dart'; 8 | export 'src/where_value_notifier/where_value_notifier_from_parent.dart'; 9 | export 'src/where_value_notifier/where_value_notifier_mixin.dart'; 10 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: value_notifier_tools 2 | description: Helpful lightweight tools for working with ValueNotifiers 3 | version: 0.1.2 4 | repository: https://github.com/timcreatedit/scribble/tree/main/packages/value_notifier_tools 5 | homepage: https://whynotmake.it 6 | 7 | environment: 8 | sdk: ">=3.0.0 <4.0.0" 9 | flutter: ">=3.10.0" 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | 15 | dev_dependencies: 16 | flutter_test: 17 | sdk: flutter 18 | lintervention: ^0.1.1 19 | mocktail: ^1.0.3 20 | 21 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/test/src/history_value_notifier/history_value_notifier_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:value_notifier_tools/src/history_value_notifier/history_value_notifier.dart'; 5 | 6 | class _MockNotifier extends HistoryValueNotifier with Mock { 7 | _MockNotifier(super.value); 8 | } 9 | 10 | void main() { 11 | group('HistoryValueNotifier', () { 12 | test('can be instantiated', () { 13 | expect(HistoryValueNotifier(1), isNotNull); 14 | expect(HistoryValueNotifier(1).value, 1); 15 | }); 16 | 17 | group('as-is', () { 18 | late HistoryValueNotifier sut; 19 | 20 | setUp(() { 21 | sut = HistoryValueNotifier(0); 22 | }); 23 | test('can undo to initial state', () { 24 | expect(sut.value, 0); 25 | expect(sut.canUndo, false); 26 | expect(sut.canRedo, false); 27 | sut.value = 1; 28 | expect(sut.value, 1); 29 | expect(sut.canUndo, true); 30 | expect(sut.canRedo, false); 31 | sut.undo(); 32 | expect(sut.value, 0); 33 | expect(sut.canUndo, false); 34 | expect(sut.canRedo, true); 35 | sut.redo(); 36 | expect(sut.value, 1); 37 | expect(sut.canUndo, true); 38 | expect(sut.canRedo, false); 39 | }); 40 | 41 | test('clears redo queue when setting value while undone', () { 42 | expect(sut.value, 0); 43 | expect(sut.canUndo, false); 44 | expect(sut.canRedo, false); 45 | sut.value = 1; 46 | expect(sut.value, 1); 47 | expect(sut.canUndo, true); 48 | expect(sut.canRedo, false); 49 | sut.undo(); 50 | expect(sut.value, 0); 51 | expect(sut.canUndo, false); 52 | expect(sut.canRedo, true); 53 | sut.value = 2; 54 | expect(sut.value, 2); 55 | expect(sut.canUndo, true); 56 | expect(sut.canRedo, false); 57 | }); 58 | 59 | test('clearRedoQueue works', () { 60 | sut.value = 1; 61 | expect(sut.value, 1); 62 | sut.value = 2; 63 | expect(sut.canUndo, true); 64 | expect(sut.canRedo, false); 65 | sut.undo(); 66 | expect(sut.value, 1); 67 | expect(sut.canUndo, true); 68 | expect(sut.canRedo, true); 69 | sut.clearRedoQueue(); 70 | expect(sut.canRedo, false); 71 | }); 72 | }); 73 | 74 | group('as extension', () { 75 | late _MockNotifier sut; 76 | 77 | setUp(() { 78 | sut = _MockNotifier(0); 79 | }); 80 | 81 | test('can undo to initial state', () { 82 | expect(sut.value, 0); 83 | expect(sut.canUndo, false); 84 | expect(sut.canRedo, false); 85 | sut.value = 1; 86 | expect(sut.value, 1); 87 | expect(sut.canUndo, true); 88 | expect(sut.canRedo, false); 89 | sut.undo(); 90 | expect(sut.value, 0); 91 | expect(sut.canUndo, false); 92 | expect(sut.canRedo, true); 93 | sut.redo(); 94 | expect(sut.value, 1); 95 | expect(sut.canUndo, true); 96 | expect(sut.canRedo, false); 97 | }); 98 | 99 | test('clears redo queue when setting value while undone', () { 100 | expect(sut.value, 0); 101 | expect(sut.canUndo, false); 102 | expect(sut.canRedo, false); 103 | sut.value = 1; 104 | expect(sut.value, 1); 105 | expect(sut.canUndo, true); 106 | expect(sut.canRedo, false); 107 | sut.undo(); 108 | expect(sut.value, 0); 109 | expect(sut.canUndo, false); 110 | expect(sut.canRedo, true); 111 | sut.value = 2; 112 | expect(sut.value, 2); 113 | expect(sut.canUndo, true); 114 | expect(sut.canRedo, false); 115 | }); 116 | 117 | test('clearRedoQueue works', () { 118 | sut.value = 1; 119 | expect(sut.value, 1); 120 | sut.value = 2; 121 | expect(sut.canUndo, true); 122 | expect(sut.canRedo, false); 123 | sut.undo(); 124 | expect(sut.value, 1); 125 | expect(sut.canUndo, true); 126 | expect(sut.canRedo, true); 127 | sut.clearRedoQueue(); 128 | expect(sut.canRedo, false); 129 | }); 130 | 131 | group('maxHistoryLength', () { 132 | const changeCount = 10; 133 | const maxHistoryLength = 5; 134 | 135 | test('drops entries that where there already', () async { 136 | const changeCount = 10; 137 | 138 | for (var i = 0; i < changeCount; i++) { 139 | sut.value++; 140 | } 141 | 142 | expect(sut.canUndo, true); 143 | expect(sut.canRedo, false); 144 | 145 | sut.maxHistoryLength = maxHistoryLength; 146 | 147 | expect(sut.canUndo, true); 148 | expect(sut.canRedo, false); 149 | 150 | // We should be able to undo maxHistoryLength - 1 times 151 | for (var i = 0; i < maxHistoryLength - 1; i++) { 152 | expect(sut.canUndo, true, reason: 'iteration $i'); 153 | expect(sut.value, changeCount - i); 154 | sut.undo(); 155 | } 156 | expect(sut.canUndo, false); 157 | }); 158 | 159 | test('only collects the last entries', () async { 160 | sut.maxHistoryLength = maxHistoryLength; 161 | for (var i = 0; i < changeCount; i++) { 162 | sut.value++; 163 | } 164 | 165 | expect(sut.canUndo, true); 166 | expect(sut.canRedo, false); 167 | 168 | // We should be able to undo historyLength - 1 times 169 | for (var i = 0; i < sut.maxHistoryLength! - 1; i++) { 170 | expect(sut.canUndo, true, reason: 'iteration $i'); 171 | expect(sut.value, changeCount - i); 172 | sut.undo(); 173 | } 174 | expect(sut.canUndo, false); 175 | }); 176 | }); 177 | 178 | group('clear', () { 179 | test('clears history', () { 180 | sut 181 | ..value = 1 182 | ..value = 2 183 | ..value = 3; 184 | expect(sut.canUndo, true); 185 | expect(sut.canRedo, false); 186 | sut.clearQueue(); 187 | expect(sut.canUndo, false); 188 | expect(sut.canRedo, false); 189 | }); 190 | }); 191 | }); 192 | }); 193 | } 194 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/test/src/select_value_notifier/select_value_notifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:value_notifier_tools/src/select_value_notifier/select_value_notifier.dart'; 5 | 6 | import '../../util/mock_listener.dart'; 7 | 8 | typedef _Model = ({int interesting, int uninteresting}); 9 | 10 | void main() { 11 | group('SelectValueNotifier', () { 12 | late ValueNotifier<_Model> notifier; 13 | late MockListener notifierListener; 14 | late SelectValueNotifier<_Model, int> sut; 15 | late MockListener sutListener; 16 | 17 | setUp(() { 18 | notifier = ValueNotifier((interesting: 0, uninteresting: 0)); 19 | notifierListener = MockListener(); 20 | notifier.addListener(notifierListener.call); 21 | addTearDown(() => notifier.removeListener(notifierListener.call)); 22 | 23 | sut = SelectValueNotifier( 24 | parentNotifier: notifier, 25 | selector: (model) => model.interesting, 26 | ); 27 | sutListener = MockListener(); 28 | 29 | sut.addListener(sutListener.call); 30 | }); 31 | 32 | group('notifyListeners()', () { 33 | test( 34 | 'should notify listeners when the new value is different from the ' 35 | 'previous value', () { 36 | notifier.value = (interesting: 1, uninteresting: 1); 37 | verify(() => notifierListener.call()); 38 | verify(() => sutListener.call()); 39 | expect(sut.value, 1); 40 | }); 41 | 42 | test( 43 | 'should not notify listeners when the new value is the same as the ' 44 | 'previous value', () { 45 | notifier.value = (interesting: 0, uninteresting: 1); 46 | verify(() => notifierListener.call()); 47 | verifyNever(() => sutListener.call()); 48 | expect(sut.value, 0); 49 | }); 50 | }); 51 | 52 | group('set value', () { 53 | test('should throw an UnsupportedError', () { 54 | // ignore: invalid_use_of_protected_member 55 | expect(() => sut.value = 1, throwsUnsupportedError); 56 | }); 57 | }); 58 | 59 | group('dispose', () { 60 | test('should remove the listener from the parent notifier', () { 61 | notifier.value = (interesting: 1, uninteresting: 1); 62 | verify(() => sutListener.call()); 63 | sut.dispose(); 64 | notifier.value = (interesting: 2, uninteresting: 1); 65 | verifyNever(() => sutListener.call()); 66 | }); 67 | }); 68 | }); 69 | 70 | group('SelectValueNotifierX', () { 71 | late ValueNotifier<_Model> notifier; 72 | 73 | setUp(() { 74 | notifier = ValueNotifier((interesting: 0, uninteresting: 0)); 75 | }); 76 | 77 | group('select()', () { 78 | test('should return a SelectValueNotifier that is set up correctly', () { 79 | // ignore: omit_local_variable_types, prefer_function_declarations_over_variables 80 | final Selector<_Model, int> selector = (model) => model.interesting; 81 | final sut = notifier.select(selector); 82 | 83 | expect(sut.parentNotifier, notifier); 84 | expect(sut.selector, selector); 85 | }); 86 | }); 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/test/src/where_value_notifier/where_value_notifier_from_parent_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:value_notifier_tools/value_notifier_tools.dart'; 5 | 6 | import '../../util/mock_listener.dart'; 7 | 8 | class _MockUpdateShouldNotify extends Mock { 9 | bool call(int previous, int next); 10 | } 11 | 12 | void main() { 13 | group('WhereValueNotifierFromParent', () { 14 | late ValueNotifier notifier; 15 | late MockListener notifierListener; 16 | late WhereValueNotifierFromParent sut; 17 | late MockListener sutListener; 18 | late _MockUpdateShouldNotify updateShouldNotify; 19 | setUp(() { 20 | notifier = ValueNotifier(0); 21 | notifierListener = MockListener(); 22 | notifier.addListener(notifierListener.call); 23 | addTearDown(() => notifier.removeListener(notifierListener.call)); 24 | 25 | updateShouldNotify = _MockUpdateShouldNotify(); 26 | sut = WhereValueNotifierFromParent( 27 | parentNotifier: notifier, 28 | updateShouldNotify: updateShouldNotify.call, 29 | ); 30 | sutListener = MockListener(); 31 | sut.addListener(sutListener.call); 32 | addTearDown(() => sut.removeListener(sutListener.call)); 33 | }); 34 | 35 | group('notifyListeners()', () { 36 | test('should notify listeners when updateShouldNotify is true', () { 37 | when(() => updateShouldNotify.call(any(), any())).thenReturn(true); 38 | 39 | notifier.value = -1; 40 | verify(() => notifierListener.call()); 41 | verify(() => sut.updateShouldNotify(0, -1)); 42 | verify(() => sutListener.call()); 43 | expect(sut.value, -1); 44 | }); 45 | 46 | test('should not notify listeners when updateShouldNotify is false', () { 47 | when(() => sut.updateShouldNotify(any(), any())).thenReturn(false); 48 | 49 | notifier.value = 1; 50 | verify(() => notifierListener.call()); 51 | verify(() => sut.updateShouldNotify(0, 1)); 52 | verifyNever(() => sutListener.call()); 53 | expect(sut.value, 1); 54 | }); 55 | }); 56 | 57 | group('set value', () { 58 | test('should set the parent notifier value', () { 59 | when(() => updateShouldNotify.call(any(), any())).thenReturn(true); 60 | sut.value = 2; 61 | expect(notifier.value, 2); 62 | }); 63 | 64 | test('should notify listeners when updateShouldNotify is true', () { 65 | when(() => updateShouldNotify.call(any(), any())).thenReturn(true); 66 | 67 | sut.value = -1; 68 | verify(() => notifierListener.call()); 69 | verify(() => sut.updateShouldNotify(0, -1)); 70 | verify(() => sutListener.call()); 71 | expect(sut.value, -1); 72 | }); 73 | 74 | test('should not notify listeners when updateShouldNotify is false', () { 75 | when(() => sut.updateShouldNotify(any(), any())).thenReturn(false); 76 | 77 | sut.value = 1; 78 | verify(() => notifierListener.call()); 79 | verify(() => sut.updateShouldNotify(0, 1)); 80 | verifyNever(() => sutListener.call()); 81 | expect(sut.value, 1); 82 | }); 83 | }); 84 | 85 | group('dispose()', () { 86 | test('should remove the listener from the parent notifier', () { 87 | when(() => updateShouldNotify.call(any(), any())).thenReturn(true); 88 | 89 | notifier.value = -1; 90 | verify(() => notifierListener.call()); 91 | verify(() => sutListener.call()); 92 | sut.dispose(); 93 | notifier.value = 2; 94 | verifyNever(() => sutListener.call()); 95 | }); 96 | }); 97 | 98 | group('SelectedValueNotifierX', () { 99 | test('should create a WhereValueNotifierFromParent', () { 100 | when(() => updateShouldNotify.call(any(), any())).thenReturn(true); 101 | 102 | final where = notifier.where(updateShouldNotify.call); 103 | expect(where, isA>()); 104 | where.updateShouldNotify(0, 1); 105 | verify(() => updateShouldNotify.call(0, 1)); 106 | }); 107 | }); 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/test/src/where_value_notifier/where_value_notifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:mocktail/mocktail.dart'; 3 | import 'package:value_notifier_tools/src/where_value_notifier/where_value_notifier.dart'; 4 | 5 | import '../../util/mock_listener.dart'; 6 | 7 | class _TestNotifier extends WhereValueNotifier with Mock { 8 | _TestNotifier(super.value); 9 | 10 | @override 11 | bool updateShouldNotify(int previous, int next); 12 | } 13 | 14 | void main() { 15 | group('WhereValueNotifier', () { 16 | late _TestNotifier sut; 17 | late MockListener listener; 18 | setUp(() { 19 | sut = _TestNotifier(0); 20 | listener = MockListener(); 21 | sut.addListener(listener.call); 22 | addTearDown(() => sut.removeListener(listener.call)); 23 | }); 24 | 25 | group('notifyListeners()', () { 26 | test('should notify listeners when updateShouldNotify is true', () { 27 | when(() => sut.updateShouldNotify(any(), any())).thenReturn(true); 28 | 29 | sut.value = -1; 30 | verify(() => listener.call()); 31 | verify(() => sut.updateShouldNotify(0, -1)); 32 | expect(sut.value, -1); 33 | }); 34 | 35 | test('should not notify listeners when updateShouldNotify is false', () { 36 | when(() => sut.updateShouldNotify(any(), any())).thenReturn(false); 37 | 38 | sut.value = 1; 39 | verifyNever(() => listener.call()); 40 | verify(() => sut.updateShouldNotify(0, 1)); 41 | expect(sut.value, 1); 42 | }); 43 | }); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /packages/value_notifier_tools/test/util/mock_listener.dart: -------------------------------------------------------------------------------- 1 | import 'package:mocktail/mocktail.dart'; 2 | 3 | /// A simple listener that can be used in tests to verify that it was called. 4 | class MockListener extends Mock { 5 | void call(); 6 | } 7 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: scribble 2 | description: Scribble is a lightweight library for freehand drawing in Flutter 3 | supporting pressure, variable line width and more! 4 | version: 0.10.0+1 5 | repository: https://github.com/timcreatedit/scribble 6 | issue_tracker: https://github.com/timcreatedit/scribble/issues 7 | 8 | environment: 9 | sdk: ">=3.0.0 <4.0.0" 10 | flutter: ">=3.10.0" 11 | 12 | dependencies: 13 | flutter: 14 | sdk: flutter 15 | freezed_annotation: ">=2.4.1 <3.0.0" 16 | perfect_freehand: ^2.3.2 17 | simpli: ^0.1.1 18 | value_notifier_tools: ^0.1.2 19 | 20 | dev_dependencies: 21 | build_runner: ^2.4.9 22 | flutter_test: 23 | sdk: flutter 24 | freezed: ^2.4.7 25 | json_serializable: ^6.9.5 26 | lintervention: ^0.3.1 27 | melos: ^6.3.3 28 | mocktail: ^1.0.3 29 | 30 | flutter: 31 | uses-material-design: true -------------------------------------------------------------------------------- /scribble_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timcreatedit/scribble/c4f5b43ffbbb18d3cf4efdcdde4a6901bd8c63c5/scribble_demo.gif -------------------------------------------------------------------------------- /test/src/domain/iterable_removed_x_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:scribble/src/domain/iterable_removed_x.dart'; 3 | 4 | void main() { 5 | setUp(() {}); 6 | 7 | group('IterableRemovedX', () { 8 | group('removedIndices', () { 9 | const list = [1, 2, 3, 4, 5, 3]; 10 | test('returns empty list if iterables are the same', () async { 11 | final removedIndices = list.removedIndices(list); 12 | expect(removedIndices, isEmpty); 13 | }); 14 | 15 | test('returns indices of one removed element', () async { 16 | final removedIndices = list.removedIndices([2, 3, 4, 5, 3]); 17 | expect(removedIndices, equals([0])); 18 | }); 19 | 20 | test('works with duplicates', () async { 21 | final removedIndices = list.removedIndices([1, 2, 4, 5, 3]); 22 | expect(removedIndices, equals([2])); 23 | }); 24 | 25 | test('works with multiple removed elements', () async { 26 | final removedIndices = list.removedIndices([1, 2, 4, 5]); 27 | expect(removedIndices.toList(), [2, 5]); 28 | }); 29 | }); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /test/src/view/notifier/scribble_notifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:scribble/scribble.dart'; 5 | import 'package:scribble/src/view/simplification/sketch_simplifier.dart'; 6 | 7 | class _MockSimplifier extends Mock implements SketchSimplifier {} 8 | 9 | void main() { 10 | setUp(() { 11 | registerFallbackValue(const Sketch(lines: [])); 12 | registerFallbackValue( 13 | const SketchLine(points: [], color: 0xFFFFFF, width: 2), 14 | ); 15 | }); 16 | 17 | group('ScribbleNotifier', () { 18 | late ScribbleNotifier sut; 19 | late _MockSimplifier simplifier; 20 | 21 | const line = SketchLine(points: [Point(1, 1)], color: 0xFFFFFFFF, width: 2); 22 | const emptyLine = SketchLine(points: [], color: 0x0, width: 0); 23 | const sketch = Sketch(lines: [line]); 24 | const emptySketch = Sketch(lines: []); 25 | 26 | setUp(() { 27 | simplifier = _MockSimplifier(); 28 | 29 | when( 30 | () => simplifier.simplify( 31 | any(), 32 | pixelTolerance: any(named: "pixelTolerance"), 33 | ), 34 | ).thenReturn(emptyLine); 35 | 36 | when( 37 | () => simplifier.simplifySketch( 38 | any(), 39 | pixelTolerance: any(named: "pixelTolerance"), 40 | ), 41 | ).thenAnswer( 42 | (invocation) => invocation.namedArguments[#pixelTolerance] == 0 43 | ? invocation.positionalArguments[0] as Sketch 44 | : emptySketch, 45 | ); 46 | 47 | sut = ScribbleNotifier( 48 | sketch: sketch, 49 | simplifier: simplifier, 50 | ); 51 | }); 52 | 53 | group("constructor", () { 54 | test('initializes with the given sketch', () async { 55 | expect(sut.value.sketch, sketch); 56 | verify(() => simplifier.simplifySketch(sketch, pixelTolerance: 0)); 57 | }); 58 | }); 59 | 60 | group('setSketch', () { 61 | test('is undoable by default if nothing happened before', () async { 62 | expect(sut.canUndo, false); 63 | expect(sut.canRedo, false); 64 | sut.setSketch(sketch: emptySketch); 65 | expect(sut.canUndo, true); 66 | expect(sut.canRedo, false); 67 | sut.undo(); 68 | expect(sut.currentSketch, sketch); 69 | }); 70 | 71 | test('is undoable by default if something happened before', () async { 72 | sut 73 | ..onPointerDown(const PointerDownEvent()) 74 | ..onPointerUpdate(const PointerMoveEvent(position: Offset(100, 100))) 75 | ..onPointerUp(const PointerUpEvent(position: Offset(100, 100))); 76 | 77 | final newSketch = sut.currentSketch; 78 | expect(sut.canUndo, true); 79 | 80 | sut.setSketch(sketch: emptySketch); 81 | expect(sut.canUndo, true); 82 | expect(sut.canRedo, false); 83 | sut.undo(); 84 | expect(sut.currentSketch, newSketch); 85 | }); 86 | 87 | test('is not undoable if set so', () async { 88 | expect(sut.canUndo, false); 89 | expect(sut.canRedo, false); 90 | sut.setSketch(sketch: emptySketch, addToUndoHistory: false); 91 | expect(sut.canUndo, false); 92 | expect(sut.canRedo, false); 93 | sut.undo(); 94 | expect(sut.currentSketch, emptySketch); 95 | }); 96 | 97 | test('is not undoable if it changed nothing', () async { 98 | expect(sut.canUndo, false); 99 | expect(sut.canRedo, false); 100 | sut.setSketch(sketch: sketch); 101 | expect(sut.canUndo, false); 102 | expect(sut.canRedo, false); 103 | }); 104 | }); 105 | 106 | group("simplify", () { 107 | test('calls simplifier method', () async { 108 | sut.setSimplificationTolerance(2); 109 | expect(sut.value.simplificationTolerance, 2); 110 | sut.simplify(); 111 | verify(() => simplifier.simplifySketch(sketch, pixelTolerance: 2)); 112 | expect(sut.value.sketch, emptySketch); 113 | }); 114 | 115 | test('is undoable by default', () async { 116 | expect(sut.canUndo, false); 117 | sut 118 | ..setSimplificationTolerance(2) 119 | ..simplify(); 120 | expect(sut.canUndo, true); 121 | }); 122 | 123 | test('is not undoable if set so', () async { 124 | expect(sut.canUndo, false); 125 | sut 126 | ..setSimplificationTolerance(2) 127 | ..simplify(addToUndoHistory: false); 128 | expect(sut.canUndo, false); 129 | }); 130 | 131 | test('is not undoable if it did nothing', () async { 132 | expect(sut.canUndo, false); 133 | sut 134 | ..setSimplificationTolerance(0) 135 | ..simplify(); 136 | expect(sut.canUndo, false); 137 | }); 138 | }); 139 | }); 140 | } 141 | -------------------------------------------------------------------------------- /test/src/view/scribble_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:scribble/scribble.dart'; 4 | import 'package:scribble/src/view/painting/scribble_editing_painter.dart'; 5 | import 'package:scribble/src/view/painting/scribble_painter.dart'; 6 | 7 | void main() { 8 | setUp(() {}); 9 | 10 | group('Scribble', () { 11 | group('simulatePressure', () { 12 | Widget build({required bool simulatePressure}) { 13 | return MaterialApp( 14 | home: Scribble( 15 | notifier: ScribbleNotifier(), 16 | simulatePressure: simulatePressure, 17 | ), 18 | ); 19 | } 20 | 21 | testWidgets( 22 | 'sets simulatePressure on ScribbleEditingPainter', 23 | (WidgetTester tester) async { 24 | await tester.pumpWidget(build(simulatePressure: true)); 25 | final finder = find.byType(CustomPaint); 26 | final widgets = 27 | finder.evaluate().map((e) => e.widget).cast(); 28 | final painters = widgets.map((e) => e.foregroundPainter).toList(); 29 | final painter = painters.whereType().first; 30 | expect(painter.simulatePressure, isTrue); 31 | }, 32 | ); 33 | 34 | testWidgets( 35 | 'sets simulatePressure on ScribblePainter', 36 | (WidgetTester tester) async { 37 | await tester.pumpWidget(build(simulatePressure: true)); 38 | final finder = find.byType(CustomPaint); 39 | final widgets = 40 | finder.evaluate().map((e) => e.widget).cast(); 41 | final painters = widgets.map((e) => e.painter).toList(); 42 | final painter = painters.whereType().first; 43 | expect(painter.simulatePressure, isTrue); 44 | }, 45 | ); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/src/view/simplification/sketch_simplifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:scribble/scribble.dart'; 3 | import 'package:scribble/src/view/simplification/sketch_simplifier.dart'; 4 | 5 | void main() { 6 | setUp(() {}); 7 | 8 | group('VisvalingamSimplifier', () { 9 | late VisvalingamSimplifier sut; 10 | 11 | setUp(() { 12 | sut = const VisvalingamSimplifier(); 13 | }); 14 | group('simplify', () { 15 | const points = [ 16 | Point(0, 0), 17 | Point(1, 1), 18 | Point(2, 0), 19 | Point(3, 3), 20 | Point(4, 0), 21 | ]; 22 | const line = SketchLine(points: points, color: 0xFF000000, width: 2); 23 | test('simplifies the given list of points', () async { 24 | final simplified = sut.simplify(line, pixelTolerance: 2); 25 | 26 | expect( 27 | simplified, 28 | line.copyWith( 29 | points: [ 30 | points[0], 31 | points[2], 32 | points[3], 33 | points[4], 34 | ], 35 | ), 36 | ); 37 | }); 38 | }); 39 | 40 | group('simplifySketch', () { 41 | const sketch = Sketch( 42 | lines: [ 43 | SketchLine( 44 | points: [ 45 | Point(0, 0), 46 | Point(1, 1), 47 | Point(2, 0), 48 | Point(3, 3), 49 | Point(4, 0), 50 | ], 51 | color: 0xFF000000, 52 | width: 10, 53 | ), 54 | SketchLine( 55 | points: [ 56 | Point(0, 0), 57 | Point(1, 1), 58 | Point(2, 0), 59 | Point(3, 3), 60 | Point(4, 0), 61 | ], 62 | color: 0xFF000000, 63 | width: 10, 64 | ), 65 | ], 66 | ); 67 | test('simplifies the given sketch', () async { 68 | final simplified = sut.simplifySketch(sketch, pixelTolerance: 2); 69 | 70 | expect( 71 | simplified, 72 | const Sketch( 73 | lines: [ 74 | SketchLine( 75 | points: [ 76 | Point(0, 0), 77 | Point(2, 0), 78 | Point(3, 3), 79 | Point(4, 0), 80 | ], 81 | color: 0xFF000000, 82 | width: 10, 83 | ), 84 | SketchLine( 85 | points: [ 86 | Point(0, 0), 87 | Point(2, 0), 88 | Point(3, 3), 89 | Point(4, 0), 90 | ], 91 | color: 0xFF000000, 92 | width: 10, 93 | ), 94 | ], 95 | ), 96 | ); 97 | }); 98 | }); 99 | }); 100 | } 101 | --------------------------------------------------------------------------------