diff --git a/.github/workflows/mini_sprite.yaml b/.github/workflows/mini_sprite.yaml new file mode 100644 index 0000000..bdbea2e --- /dev/null +++ b/.github/workflows/mini_sprite.yaml @@ -0,0 +1,45 @@ +name: mini_sprite + +on: + push: + branches: + - main + paths: + - .github/workflows/mini_sprite.yaml + - packages/mini_sprite/** + + pull_request: + branches: + - main + paths: + - .github/workflows/mini_sprite.yaml + - packages/mini_sprite/** + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2.3.4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + cache: true + - uses: bluefireteam/melos-action@main + + - name: Install Dependencies + run: melos bootstrap + + - name: Format + run: melos exec --scope mini_sprite dart format --set-exit-if-changed lib + + - name: Analyze + run: melos exec --scope mini_sprite dart analyze --fatal-infos --fatal-warnings . + + - name: Run Tests + run: melos exec --scope mini_sprite flutter test --coverage + + - name: Check Code Coverage + uses: VeryGoodOpenSource/very_good_coverage@v1 + with: + path: packages/mini_sprite/coverage/lcov.info diff --git a/.github/workflows/mini_sprite_editor.yaml b/.github/workflows/mini_sprite_editor.yaml new file mode 100644 index 0000000..dd292f6 --- /dev/null +++ b/.github/workflows/mini_sprite_editor.yaml @@ -0,0 +1,45 @@ +name: mini_sprite_editor + +on: + push: + branches: + - main + paths: + - .github/workflows/mini_sprite_editor.yaml + - packages/mini_sprite_editor/** + + pull_request: + branches: + - main + paths: + - .github/workflows/mini_sprite_editor.yaml + - packages/mini_sprite_editor/** + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2.3.4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + cache: true + - uses: bluefireteam/melos-action@main + + - name: Install Dependencies + run: melos bootstrap + + - name: Format + run: melos exec --scope mini_sprite_editor flutter format --set-exit-if-changed lib + + - name: Analyze + run: melos exec --scope mini_sprite_editor flutter analyze --fatal-infos --fatal-warnings . + + - name: Run Tests + run: melos exec --scope mini_sprite_editor flutter test --coverage + + - name: Check Code Coverage + uses: VeryGoodOpenSource/very_good_coverage@v1 + with: + path: packages/mini_sprite_editor/coverage/lcov.info diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..4ae1ed7 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +mini_sprite \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5d344fd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/melos_bootstrap.xml b/.idea/runConfigurations/melos_bootstrap.xml new file mode 100644 index 0000000..d571530 --- /dev/null +++ b/.idea/runConfigurations/melos_bootstrap.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/.idea/runConfigurations/melos_clean.xml b/.idea/runConfigurations/melos_clean.xml new file mode 100644 index 0000000..f45d436 --- /dev/null +++ b/.idea/runConfigurations/melos_clean.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/.idea/runConfigurations/melos_flutter_run_mini_sprite_editor.xml b/.idea/runConfigurations/melos_flutter_run_mini_sprite_editor.xml new file mode 100644 index 0000000..ac2f0a3 --- /dev/null +++ b/.idea/runConfigurations/melos_flutter_run_mini_sprite_editor.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index 7f0854e..d14dc4a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# mini_sprite +# Mini Sprite [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] [![License: MIT][license_badge]][license_link] -A Very Good Project created by Very Good CLI. +Mini sprite is a simple, matrix based format for creating 1bit styled graphics. [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg [license_link]: https://opensource.org/licenses/MIT diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 0000000..4433e34 --- /dev/null +++ b/melos.yaml @@ -0,0 +1,5 @@ +name: mini_sprite +repository: https://github.com/bluefireteam/mini_sprite +packages: + - packages/** + - . diff --git a/melos_mini_sprite.iml b/melos_mini_sprite.iml new file mode 100644 index 0000000..9681559 --- /dev/null +++ b/melos_mini_sprite.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/mini_sprite/.gitignore b/packages/mini_sprite/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/packages/mini_sprite/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/mini_sprite/README.md b/packages/mini_sprite/README.md new file mode 100644 index 0000000..7f0854e --- /dev/null +++ b/packages/mini_sprite/README.md @@ -0,0 +1,11 @@ +# mini_sprite + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +A Very Good Project created by Very Good CLI. + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis diff --git a/packages/mini_sprite/analysis_options.yaml b/packages/mini_sprite/analysis_options.yaml new file mode 100644 index 0000000..b08262c --- /dev/null +++ b/packages/mini_sprite/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.3.0.0.yaml diff --git a/packages/mini_sprite/lib/mini_sprite.dart b/packages/mini_sprite/lib/mini_sprite.dart new file mode 100644 index 0000000..3f7ba4e --- /dev/null +++ b/packages/mini_sprite/lib/mini_sprite.dart @@ -0,0 +1,3 @@ +library mini_sprite; + +export 'src/mini_sprite.dart'; diff --git a/packages/mini_sprite/lib/src/mini_sprite.dart b/packages/mini_sprite/lib/src/mini_sprite.dart new file mode 100644 index 0000000..afb5abb --- /dev/null +++ b/packages/mini_sprite/lib/src/mini_sprite.dart @@ -0,0 +1,88 @@ +/// {@template mini_sprite} +/// A class used to manipulate a matrix of pixels. +/// first dimension of the [pixels] array is the y coordinate, +/// second is the x coordinate. +/// {@endtemplate} +class MiniSprite { + /// {@macro mini_sprite} + MiniSprite(this.pixels); + + /// {@macro mini_sprite} + /// + /// Creates an empty sprite with the given width and height. + MiniSprite.empty(int width, int height) + : pixels = + List.generate(height, (_) => List.generate(width, (_) => false)); + + /// {@macro mini_sprite} + /// + /// Returns a [MiniSprite] from the serialized data. + factory MiniSprite.fromDataString(String value) { + final blocks = value.split(';'); + + final size = blocks.removeAt(0).split(','); + final height = int.parse(size[0]); + final width = int.parse(size[1]); + + final flatten = blocks.map((rawBlock) { + final blockSplit = rawBlock.split(','); + + final count = int.parse(blockSplit[0]); + final value = int.parse(blockSplit[1]) == 1; + + return List.filled(count, value); + }).fold>(List.empty(), (value, list) { + return [ + ...value, + ...list, + ]; + }); + + final pixels = List.generate( + height, + (_) => List.generate( + width, + (_) { + return flatten.removeAt(0); + }, + ), + ); + + return MiniSprite(pixels); + } + + /// The matrix of pixels. + final List> pixels; + + /// Returns this as a data string. + String toDataString() { + final dimensions = '${pixels.length},${pixels[0].length}'; + + var counter = 0; + bool? last; + + final blocks = []; + + for (var y = 0; y < pixels.length; y++) { + for (var x = 0; x < pixels[y].length; x++) { + if (last == null) { + last = pixels[y][x]; + counter = 1; + } else { + if (last == pixels[y][x]) { + counter++; + } else { + blocks.add('$counter,${last ? 1 : 0}'); + last = pixels[y][x]; + counter = 1; + } + } + } + } + if (last != null) { + blocks.add('$counter,${last ? 1 : 0}'); + } + + return '$dimensions;${blocks.join(';')}'; + } +} diff --git a/packages/mini_sprite/melos_mini_sprite.iml b/packages/mini_sprite/melos_mini_sprite.iml new file mode 100644 index 0000000..389d07a --- /dev/null +++ b/packages/mini_sprite/melos_mini_sprite.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mini_sprite/pubspec.yaml b/packages/mini_sprite/pubspec.yaml new file mode 100644 index 0000000..27b0c47 --- /dev/null +++ b/packages/mini_sprite/pubspec.yaml @@ -0,0 +1,13 @@ +name: mini_sprite +description: A simple sprite format for building 1bit styled graphics. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.17.0 <3.0.0" + +dev_dependencies: + coverage: ^1.1.0 + mocktail: ^0.3.0 + test: ^1.19.2 + very_good_analysis: ^3.0.0 diff --git a/packages/mini_sprite/test/src/mini_sprite_test.dart b/packages/mini_sprite/test/src/mini_sprite_test.dart new file mode 100644 index 0000000..162f9f1 --- /dev/null +++ b/packages/mini_sprite/test/src/mini_sprite_test.dart @@ -0,0 +1,96 @@ +import 'package:mini_sprite/mini_sprite.dart'; +import 'package:test/test.dart'; + +void main() { + group('MiniSprite', () { + test('empty returns an empty sprite', () { + expect( + MiniSprite.empty(2, 2).pixels, + equals([ + [ + false, + false, + ], + [ + false, + false, + ], + ]), + ); + }); + + test('toDataString returns the correct data', () { + expect( + MiniSprite([ + [true, true], + [true, true], + ]).toDataString(), + equals('2,2;4,1'), + ); + + expect( + MiniSprite([ + [true, true], + [true, false], + ]).toDataString(), + equals('2,2;3,1;1,0'), + ); + + expect( + MiniSprite([ + [true, false], + [true, false], + ]).toDataString(), + equals('2,2;1,1;1,0;1,1;1,0'), + ); + }); + + test('fromDataString returns the correct parsed instance', () { + expect( + MiniSprite.fromDataString('2,2;4,1').pixels, + equals([ + [true, true], + [true, true], + ]), + ); + + expect( + MiniSprite.fromDataString('2,2;3,1;1,0').pixels, + equals([ + [true, true], + [true, false], + ]), + ); + + expect( + MiniSprite.fromDataString('2,2;1,1;1,0;1,1;1,0').pixels, + equals([ + [true, false], + [true, false], + ]), + ); + }); + + group('when dimensions are not symmetrical', () { + test('toDataString returns the correct data', () { + expect( + MiniSprite([ + [true, true, true], + [true, false, false], + ]).toDataString(), + equals('2,3;4,1;2,0'), + ); + }); + + test('fromDataString returns the correct parsed instance', () { + expect( + MiniSprite.fromDataString('2,3;4,1;2,0').pixels, + equals([ + [true, true, true], + [true, false, false], + ]), + ); + }); + }); + }); +} diff --git a/packages/mini_sprite_editor/.github/PULL_REQUEST_TEMPLATE.md b/packages/mini_sprite_editor/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6b9372e --- /dev/null +++ b/packages/mini_sprite_editor/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +## Description + + + +## Type of Change + + + +- [ ] ✨ New feature (non-breaking change which adds functionality) +- [ ] πŸ› οΈ Bug fix (non-breaking change which fixes an issue) +- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] 🧹 Code refactor +- [ ] βœ… Build configuration change +- [ ] πŸ“ Documentation +- [ ] πŸ—‘οΈ Chore diff --git a/packages/mini_sprite_editor/.github/workflows/main.yaml b/packages/mini_sprite_editor/.github/workflows/main.yaml new file mode 100644 index 0000000..b554ded --- /dev/null +++ b/packages/mini_sprite_editor/.github/workflows/main.yaml @@ -0,0 +1,10 @@ +name: mini_sprite_editor + +on: [pull_request, push] + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + flutter_channel: stable + flutter_version: 3.0.0 diff --git a/packages/mini_sprite_editor/.gitignore b/packages/mini_sprite_editor/.gitignore new file mode 100644 index 0000000..bd315f7 --- /dev/null +++ b/packages/mini_sprite_editor/.gitignore @@ -0,0 +1,127 @@ +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/* + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/* + +# Flutter repo-specific +/bin/cache/ +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/docs/doc/ +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds +.fvm/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +**/android/.idea/ +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/.last_build_id +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Coverage +coverage/ + +# Submodules +!pubspec.lock +packages/**/pubspec.lock + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Exceptions to the above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!.vscode/extensions.json +!.vscode/launch.json +!.idea/codeStyles/ +!.idea/dictionaries/ +!.idea/runConfigurations/ diff --git a/packages/mini_sprite_editor/.idea/runConfigurations/development.xml b/packages/mini_sprite_editor/.idea/runConfigurations/development.xml new file mode 100644 index 0000000..37d45fe --- /dev/null +++ b/packages/mini_sprite_editor/.idea/runConfigurations/development.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/packages/mini_sprite_editor/.idea/runConfigurations/production.xml b/packages/mini_sprite_editor/.idea/runConfigurations/production.xml new file mode 100644 index 0000000..e55961d --- /dev/null +++ b/packages/mini_sprite_editor/.idea/runConfigurations/production.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/packages/mini_sprite_editor/.idea/runConfigurations/staging.xml b/packages/mini_sprite_editor/.idea/runConfigurations/staging.xml new file mode 100644 index 0000000..3f7d621 --- /dev/null +++ b/packages/mini_sprite_editor/.idea/runConfigurations/staging.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/packages/mini_sprite_editor/.metadata b/packages/mini_sprite_editor/.metadata new file mode 100644 index 0000000..cd984dd --- /dev/null +++ b/packages/mini_sprite_editor/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + channel: stable + +project_type: app diff --git a/packages/mini_sprite_editor/.vscode/extensions.json b/packages/mini_sprite_editor/.vscode/extensions.json new file mode 100644 index 0000000..03e0c3b --- /dev/null +++ b/packages/mini_sprite_editor/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "dart-code.dart-code", + "dart-code.flutter", + "felixangelov.bloc" + ] +} diff --git a/packages/mini_sprite_editor/.vscode/launch.json b/packages/mini_sprite_editor/.vscode/launch.json new file mode 100644 index 0000000..b4e33ce --- /dev/null +++ b/packages/mini_sprite_editor/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch development", + "request": "launch", + "type": "dart", + "program": "lib/main_development.dart", + "args": [ + "--flavor", + "development", + "--target", + "lib/main_development.dart" + ] + }, + { + "name": "Launch staging", + "request": "launch", + "type": "dart", + "program": "lib/main_staging.dart", + "args": ["--flavor", "staging", "--target", "lib/main_staging.dart"] + }, + { + "name": "Launch production", + "request": "launch", + "type": "dart", + "program": "lib/main_production.dart", + "args": ["--flavor", "production", "--target", "lib/main_production.dart"] + } + ] +} diff --git a/packages/mini_sprite_editor/LICENSE b/packages/mini_sprite_editor/LICENSE new file mode 100644 index 0000000..7b93245 --- /dev/null +++ b/packages/mini_sprite_editor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Very Good Ventures + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/mini_sprite_editor/README.md b/packages/mini_sprite_editor/README.md new file mode 100644 index 0000000..e70cf7f --- /dev/null +++ b/packages/mini_sprite_editor/README.md @@ -0,0 +1,164 @@ +# Mini Sprite Editor + +![coverage][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Generated by the [Very Good CLI][very_good_cli_link] πŸ€– + +Small, standalone application that can read and write mini sprites. + +--- + +## Getting Started πŸš€ + +This project contains 3 flavors: + +- development +- staging +- production + +To run the desired flavor either use the launch configuration in VSCode/Android Studio or use the following commands: + +```sh +# Development +$ flutter run --flavor development --target lib/main_development.dart + +# Staging +$ flutter run --flavor staging --target lib/main_staging.dart + +# Production +$ flutter run --flavor production --target lib/main_production.dart +``` + +_\*Mini Sprite Editor works on iOS, Android, Web, and Windows._ + +--- + +## Running Tests πŸ§ͺ + +To run all unit and widget tests use the following command: + +```sh +$ flutter test --coverage --test-randomize-ordering-seed random +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +$ genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +$ open coverage/index.html +``` + +--- + +## Working with Translations 🌐 + +This project relies on [flutter_localizations][flutter_localizations_link] and follows the [official internationalization guide for Flutter][internationalization_link]. + +### Adding Strings + +1. To add a new localizable string, open the `app_en.arb` file at `lib/l10n/arb/app_en.arb`. + +```arb +{ + "@@locale": "en", + "counterAppBarTitle": "Counter", + "@counterAppBarTitle": { + "description": "Text shown in the AppBar of the Counter Page" + } +} +``` + +2. Then add a new key/value and description + +```arb +{ + "@@locale": "en", + "counterAppBarTitle": "Counter", + "@counterAppBarTitle": { + "description": "Text shown in the AppBar of the Counter Page" + }, + "helloWorld": "Hello World", + "@helloWorld": { + "description": "Hello World Text" + } +} +``` + +3. Use the new string + +```dart +import 'package:mini_sprite_editor/l10n/l10n.dart'; + +@override +Widget build(BuildContext context) { + final l10n = context.l10n; + return Text(l10n.helloWorld); +} +``` + +### Adding Supported Locales + +Update the `CFBundleLocalizations` array in the `Info.plist` at `ios/Runner/Info.plist` to include the new locale. + +```xml + ... + + CFBundleLocalizations + + en + es + + + ... +``` + +### Adding Translations + +1. For each supported locale, add a new ARB file in `lib/l10n/arb`. + +``` +β”œβ”€β”€ l10n +β”‚ β”œβ”€β”€ arb +β”‚ β”‚ β”œβ”€β”€ app_en.arb +β”‚ β”‚ └── app_es.arb +``` + +2. Add the translated strings to each `.arb` file: + +`app_en.arb` + +```arb +{ + "@@locale": "en", + "counterAppBarTitle": "Counter", + "@counterAppBarTitle": { + "description": "Text shown in the AppBar of the Counter Page" + } +} +``` + +`app_es.arb` + +```arb +{ + "@@locale": "es", + "counterAppBarTitle": "Contador", + "@counterAppBarTitle": { + "description": "Texto mostrado en la AppBar de la pΓ‘gina del contador" + } +} +``` + +[coverage_badge]: coverage_badge.svg +[flutter_localizations_link]: https://api.flutter.dev/flutter/flutter_localizations/flutter_localizations-library.html +[internationalization_link]: https://flutter.dev/docs/development/accessibility-and-localization/internationalization +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli diff --git a/packages/mini_sprite_editor/analysis_options.yaml b/packages/mini_sprite_editor/analysis_options.yaml new file mode 100644 index 0000000..b4d573c --- /dev/null +++ b/packages/mini_sprite_editor/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:very_good_analysis/analysis_options.3.0.0.yaml +linter: + rules: + public_member_api_docs: false diff --git a/packages/mini_sprite_editor/coverage_badge.svg b/packages/mini_sprite_editor/coverage_badge.svg new file mode 100644 index 0000000..88bfadf --- /dev/null +++ b/packages/mini_sprite_editor/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + \ No newline at end of file diff --git a/packages/mini_sprite_editor/l10n.yaml b/packages/mini_sprite_editor/l10n.yaml new file mode 100644 index 0000000..6f72a55 --- /dev/null +++ b/packages/mini_sprite_editor/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n/arb +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +nullable-getter: false diff --git a/packages/mini_sprite_editor/lib/app/app.dart b/packages/mini_sprite_editor/lib/app/app.dart new file mode 100644 index 0000000..f23ab3c --- /dev/null +++ b/packages/mini_sprite_editor/lib/app/app.dart @@ -0,0 +1 @@ +export 'view/app.dart'; diff --git a/packages/mini_sprite_editor/lib/app/view/app.dart b/packages/mini_sprite_editor/lib/app/view/app.dart new file mode 100644 index 0000000..473129c --- /dev/null +++ b/packages/mini_sprite_editor/lib/app/view/app.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:mini_sprite_editor/l10n/l10n.dart'; +import 'package:mini_sprite_editor/sprite/sprite.dart'; + +class App extends StatelessWidget { + const App({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(), + darkTheme: ThemeData.dark(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: const SpritePage(), + ); + } +} diff --git a/packages/mini_sprite_editor/lib/bootstrap.dart b/packages/mini_sprite_editor/lib/bootstrap.dart new file mode 100644 index 0000000..e6c6e7a --- /dev/null +++ b/packages/mini_sprite_editor/lib/bootstrap.dart @@ -0,0 +1,35 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/widgets.dart'; + +class AppBlocObserver extends BlocObserver { + @override + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + log('onChange(${bloc.runtimeType}, $change)'); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + log('onError(${bloc.runtimeType}, $error, $stackTrace)'); + super.onError(bloc, error, stackTrace); + } +} + +Future bootstrap(FutureOr Function() builder) async { + FlutterError.onError = (details) { + log(details.exceptionAsString(), stackTrace: details.stack); + }; + + await runZonedGuarded( + () async { + await BlocOverrides.runZoned( + () async => runApp(await builder()), + blocObserver: AppBlocObserver(), + ); + }, + (error, stackTrace) => log(error.toString(), stackTrace: stackTrace), + ); +} diff --git a/packages/mini_sprite_editor/lib/l10n/arb/app_en.arb b/packages/mini_sprite_editor/lib/l10n/arb/app_en.arb new file mode 100644 index 0000000..acf8cc6 --- /dev/null +++ b/packages/mini_sprite_editor/lib/l10n/arb/app_en.arb @@ -0,0 +1,43 @@ +{ + "@@locale": "en", + "zoomIn": "Zoom In", + "@zoomIn": { + "description": "Zoom In tooltip" + }, + "zoomOut": "Zoom Out", + "@zoomOut": { + "description": "Zoom Out tooltip" + }, + "copyToClipboard": "Copy to clipboard", + "@copyToClipboard": { + "description": "Copy to clipboard label" + }, + "copiedWithSuccess": "Data copied to clipboard", + "@copiedWithSuccess": { + "description": "Message of data copied to clipboard" + }, + "importFromClipBoard": "Import from clipboard", + "@importFromClipBoard": { + "description": "Import from clipboard label" + }, + "importSuccess": "Data imported from clipboard", + "@importSuccess": { + "description": "Message of data imported to clipboard" + }, + "brush": "Brush", + "@brush": { + "description": "Brush label" + }, + "eraser": "Eraser", + "@eraser": { + "description": "Eraser label" + }, + "bucket": "Fill", + "@bucket": { + "description": "Fill label" + }, + "bucketEraser": "Unfill", + "@bucketEraser": { + "description": "Unfill label" + } +} diff --git a/packages/mini_sprite_editor/lib/l10n/l10n.dart b/packages/mini_sprite_editor/lib/l10n/l10n.dart new file mode 100644 index 0000000..17c891b --- /dev/null +++ b/packages/mini_sprite_editor/lib/l10n/l10n.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +export 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +extension AppLocalizationsX on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} diff --git a/packages/mini_sprite_editor/lib/main.dart b/packages/mini_sprite_editor/lib/main.dart new file mode 100644 index 0000000..7faf4e3 --- /dev/null +++ b/packages/mini_sprite_editor/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:mini_sprite_editor/app/app.dart'; +import 'package:mini_sprite_editor/bootstrap.dart'; + +void main() { + bootstrap(() => const App()); +} diff --git a/packages/mini_sprite_editor/lib/sprite/cubit/sprite_cubit.dart b/packages/mini_sprite_editor/lib/sprite/cubit/sprite_cubit.dart new file mode 100644 index 0000000..f1ab0a2 --- /dev/null +++ b/packages/mini_sprite_editor/lib/sprite/cubit/sprite_cubit.dart @@ -0,0 +1,136 @@ +import 'dart:ui'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/services.dart'; +import 'package:mini_sprite/mini_sprite.dart'; + +part 'sprite_state.dart'; + +class SpriteCubit extends Cubit { + SpriteCubit({ + Future Function(ClipboardData)? setClipboardData, + Future Function(String)? getClipboardData, + }) : _setClipboardData = setClipboardData ?? Clipboard.setData, + _getClipboardData = getClipboardData ?? Clipboard.getData, + super(SpriteState.initial()); + + final Future Function(ClipboardData) _setClipboardData; + final Future Function(String) _getClipboardData; + + void copyToClipboard() { + final sprite = MiniSprite(state.pixels); + final data = sprite.toDataString(); + _setClipboardData(ClipboardData(text: data)); + } + + Future importFromClipboard() async { + final data = await _getClipboardData('text/plain'); + final text = data?.text; + if (text != null) { + final sprite = MiniSprite.fromDataString(text); + emit(state.copyWith(pixels: sprite.pixels)); + } + } + + void zoomIn() { + emit(state.copyWith(pixelSize: state.pixelSize + 10)); + } + + void zoomOut() { + emit(state.copyWith(pixelSize: state.pixelSize - 10)); + } + + void cursorLeft() { + emit(state.copyWith(cursorPosition: const Offset(-1, -1))); + } + + void selectTool(SpriteTool tool) { + emit(state.copyWith(tool: tool)); + } + + Offset _projectOffset(Offset position) { + final projected = position / state.pixelSize.toDouble(); + final x = projected.dx.floorToDouble(); + final y = projected.dy.floorToDouble(); + + return Offset(x, y); + } + + void _setPixel(bool value) { + final newPixels = [ + ...state.pixels.map((e) => [...e]), + ]; + final x = state.cursorPosition.dx.toInt(); + final y = state.cursorPosition.dy.toInt(); + + newPixels[y][x] = value; + emit(state.copyWith(pixels: newPixels)); + } + + void _floodFillSeek(int x, int y, bool value, List> pixels) { + if (y < 0 || y >= pixels.length || x < 0 || x >= pixels[0].length) { + return; + } + + if (pixels[y][x] == value) { + return; + } + pixels[y][x] = value; + _floodFillSeek(x + 1, y, value, pixels); + _floodFillSeek(x - 1, y, value, pixels); + _floodFillSeek(x, y + 1, value, pixels); + _floodFillSeek(x, y - 1, value, pixels); + } + + void _floodFill(bool value) { + final newPixels = [ + ...state.pixels.map((e) => [...e]), + ]; + + _floodFillSeek( + state.cursorPosition.dx.toInt(), + state.cursorPosition.dy.toInt(), + value, + newPixels, + ); + + emit(state.copyWith(pixels: newPixels, toolActive: false)); + } + + void _processTool() { + switch (state.tool) { + case SpriteTool.brush: + case SpriteTool.eraser: + if (state.toolActive) { + _setPixel(state.tool == SpriteTool.brush); + } + break; + case SpriteTool.bucket: + case SpriteTool.bucketEraser: + if (state.toolActive) { + _floodFill(state.tool == SpriteTool.bucket); + } + break; + } + } + + void cursorHover(Offset position) { + final projected = _projectOffset(position); + if (projected != state.cursorPosition) { + emit(state.copyWith(cursorPosition: projected)); + _processTool(); + } + } + + void cursorDown(Offset position) { + final projected = _projectOffset(position); + emit(state.copyWith(cursorPosition: projected, toolActive: true)); + _processTool(); + } + + void cursorUp() { + emit(state.copyWith(toolActive: false)); + _processTool(); + } +} diff --git a/packages/mini_sprite_editor/lib/sprite/cubit/sprite_state.dart b/packages/mini_sprite_editor/lib/sprite/cubit/sprite_state.dart new file mode 100644 index 0000000..848b037 --- /dev/null +++ b/packages/mini_sprite_editor/lib/sprite/cubit/sprite_state.dart @@ -0,0 +1,63 @@ +part of 'sprite_cubit.dart'; + +const _defaultSpriteSize = 16; + +enum SpriteTool { + brush, + eraser, + bucket, + bucketEraser, +} + +class SpriteState extends Equatable { + const SpriteState({ + required this.pixelSize, + required this.pixels, + required this.cursorPosition, + required this.tool, + required this.toolActive, + }); + + SpriteState.initial() + : this( + pixels: List.filled( + _defaultSpriteSize, + List.filled(_defaultSpriteSize, false), + ), + pixelSize: 25, + cursorPosition: const Offset(-1, -1), + tool: SpriteTool.brush, + toolActive: false, + ); + + final int pixelSize; + final List> pixels; + final Offset cursorPosition; + final SpriteTool tool; + final bool toolActive; + + SpriteState copyWith({ + int? pixelSize, + List>? pixels, + Offset? cursorPosition, + SpriteTool? tool, + bool? toolActive, + }) { + return SpriteState( + pixelSize: pixelSize ?? this.pixelSize, + pixels: pixels ?? this.pixels, + cursorPosition: cursorPosition ?? this.cursorPosition, + tool: tool ?? this.tool, + toolActive: toolActive ?? this.toolActive, + ); + } + + @override + List get props => [ + pixelSize, + pixels, + cursorPosition, + tool, + toolActive, + ]; +} diff --git a/packages/mini_sprite_editor/lib/sprite/sprite.dart b/packages/mini_sprite_editor/lib/sprite/sprite.dart new file mode 100644 index 0000000..c941a61 --- /dev/null +++ b/packages/mini_sprite_editor/lib/sprite/sprite.dart @@ -0,0 +1,2 @@ +export 'cubit/sprite_cubit.dart'; +export 'view/view.dart'; diff --git a/packages/mini_sprite_editor/lib/sprite/view/pixel_cell.dart b/packages/mini_sprite_editor/lib/sprite/view/pixel_cell.dart new file mode 100644 index 0000000..d328f64 --- /dev/null +++ b/packages/mini_sprite_editor/lib/sprite/view/pixel_cell.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class PixelCell extends StatelessWidget { + const PixelCell({ + super.key, + required this.selected, + required this.hovered, + required this.pixelSize, + }); + + final bool selected; + final bool hovered; + final int pixelSize; + + @override + Widget build(BuildContext context) { + final selectedColor = + Theme.of(context).textTheme.bodySmall?.color ?? Colors.black; + + final unselectedColor = Theme.of(context).scaffoldBackgroundColor; + + return Container( + width: pixelSize.toDouble(), + height: pixelSize.toDouble(), + decoration: BoxDecoration( + color: selected + ? selectedColor + : hovered + ? selectedColor.withOpacity(.2) + : unselectedColor, + border: Border.all( + color: selectedColor, + ), + ), + ); + } +} diff --git a/packages/mini_sprite_editor/lib/sprite/view/sprite_page.dart b/packages/mini_sprite_editor/lib/sprite/view/sprite_page.dart new file mode 100644 index 0000000..af07ad9 --- /dev/null +++ b/packages/mini_sprite_editor/lib/sprite/view/sprite_page.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mini_sprite_editor/l10n/l10n.dart'; +import 'package:mini_sprite_editor/sprite/sprite.dart'; + +class SpritePage extends StatelessWidget { + const SpritePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SpriteCubit(), + child: const SpriteView(), + ); + } +} + +class SpriteView extends StatelessWidget { + const SpriteView({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final state = context.watch().state; + final pixels = state.pixels; + final cursorPosition = state.cursorPosition; + final tool = state.tool; + + final pixelSize = state.pixelSize; + + final spriteHeight = pixelSize * pixels.length; + final spriteWidth = pixelSize * pixels[0].length; + + return Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: SizedBox.expand( + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + child: MouseRegion( + onHover: (event) { + context + .read() + .cursorHover(event.localPosition); + }, + child: GestureDetector( + onPanStart: (event) { + context + .read() + .cursorDown(event.localPosition); + }, + onPanEnd: (event) { + context.read().cursorUp(); + }, + onPanUpdate: (event) { + context + .read() + .cursorHover(event.localPosition); + }, + child: SizedBox( + key: const Key('board_key'), + width: spriteWidth.toDouble(), + height: spriteHeight.toDouble(), + child: Column( + children: [ + for (var y = 0; y < pixels.length; y++) + Row( + children: [ + for (var x = 0; x < pixels[y].length; x++) + PixelCell( + pixelSize: pixelSize, + selected: pixels[y][x], + hovered: cursorPosition == + Offset( + x.toDouble(), + y.toDouble(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + Positioned( + top: 32, + right: 32, + child: Card( + child: Column( + children: [ + IconButton( + key: const Key('brush_key'), + onPressed: tool == SpriteTool.brush + ? null + : () { + context + .read() + .selectTool(SpriteTool.brush); + }, + tooltip: l10n.brush, + icon: const Icon(Icons.brush), + ), + IconButton( + key: const Key('eraser_key'), + onPressed: tool == SpriteTool.eraser + ? null + : () { + context + .read() + .selectTool(SpriteTool.eraser); + }, + tooltip: l10n.eraser, + icon: const Icon(Icons.rectangle), + ), + IconButton( + key: const Key('bucket_key'), + onPressed: tool == SpriteTool.bucket + ? null + : () { + context + .read() + .selectTool(SpriteTool.bucket); + }, + tooltip: l10n.bucket, + icon: const Icon(Icons.egg_sharp), + ), + IconButton( + key: const Key('bucket_eraser_key'), + onPressed: tool == SpriteTool.bucketEraser + ? null + : () { + context + .read() + .selectTool(SpriteTool.bucketEraser); + }, + tooltip: l10n.bucketEraser, + icon: const Icon(Icons.egg_outlined), + ), + IconButton( + key: const Key('zoom_in_key'), + onPressed: () { + context.read().zoomIn(); + }, + tooltip: l10n.zoomIn, + icon: const Icon(Icons.zoom_in), + ), + IconButton( + key: const Key('zoom_out_key'), + onPressed: () { + context.read().zoomOut(); + }, + tooltip: l10n.zoomOut, + icon: const Icon(Icons.zoom_out), + ), + const Divider(), + IconButton( + key: const Key('copy_to_clipboard_key'), + onPressed: () { + context.read().copyToClipboard(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.copiedWithSuccess)), + ); + }, + tooltip: l10n.copyToClipboard, + icon: const Icon(Icons.download), + ), + IconButton( + key: const Key('import_from_clipboard_key'), + onPressed: () { + context.read().importFromClipboard(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.importSuccess)), + ); + }, + tooltip: l10n.importFromClipBoard, + icon: const Icon(Icons.import_export), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/mini_sprite_editor/lib/sprite/view/view.dart b/packages/mini_sprite_editor/lib/sprite/view/view.dart new file mode 100644 index 0000000..20d1980 --- /dev/null +++ b/packages/mini_sprite_editor/lib/sprite/view/view.dart @@ -0,0 +1,2 @@ +export 'pixel_cell.dart'; +export 'sprite_page.dart'; diff --git a/packages/mini_sprite_editor/pubspec.lock b/packages/mini_sprite_editor/pubspec.lock new file mode 100644 index 0000000..f6d068e --- /dev/null +++ b/packages/mini_sprite_editor/pubspec.lock @@ -0,0 +1,460 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "39.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.8.2" + bloc: + dependency: "direct main" + description: + name: bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.3" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + url: "https://pub.dartlang.org" + source: hosted + version: "9.0.3" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.1" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + mini_sprite: + dependency: "direct main" + description: + path: "../mini_sprite" + relative: true + source: path + version: "1.0.0+1" + mocktail: + dependency: "direct dev" + description: + name: mocktail + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + provider: + dependency: transitive + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + test: + dependency: transitive + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.1" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.9" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.13" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "7.5.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" +sdks: + dart: ">=2.17.0 <3.0.0" + flutter: ">=1.16.0" diff --git a/packages/mini_sprite_editor/pubspec.yaml b/packages/mini_sprite_editor/pubspec.yaml new file mode 100644 index 0000000..a05a156 --- /dev/null +++ b/packages/mini_sprite_editor/pubspec.yaml @@ -0,0 +1,29 @@ +name: mini_sprite_editor +description: Small, standalone application that can read and write mini sprites +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + bloc: ^8.0.3 + equatable: ^2.0.3 + flutter: + sdk: flutter + flutter_bloc: ^8.0.1 + flutter_localizations: + sdk: flutter + intl: ^0.17.0 + mini_sprite: 1.0.0+1 + +dev_dependencies: + bloc_test: ^9.0.3 + flutter_test: + sdk: flutter + mocktail: ^0.3.0 + very_good_analysis: ^3.0.0 + +flutter: + uses-material-design: true + generate: true diff --git a/packages/mini_sprite_editor/test/app/view/app_test.dart b/packages/mini_sprite_editor/test/app/view/app_test.dart new file mode 100644 index 0000000..bbdc7b1 --- /dev/null +++ b/packages/mini_sprite_editor/test/app/view/app_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mini_sprite_editor/app/app.dart'; +import 'package:mini_sprite_editor/sprite/sprite.dart'; + +void main() { + group('App', () { + testWidgets('renders CounterPage', (tester) async { + await tester.pumpWidget(const App()); + expect(find.byType(SpritePage), findsOneWidget); + }); + }); +} diff --git a/packages/mini_sprite_editor/test/helpers/helpers.dart b/packages/mini_sprite_editor/test/helpers/helpers.dart new file mode 100644 index 0000000..b15fe65 --- /dev/null +++ b/packages/mini_sprite_editor/test/helpers/helpers.dart @@ -0,0 +1 @@ +export 'pump_app.dart'; diff --git a/packages/mini_sprite_editor/test/helpers/pump_app.dart b/packages/mini_sprite_editor/test/helpers/pump_app.dart new file mode 100644 index 0000000..f9f1f16 --- /dev/null +++ b/packages/mini_sprite_editor/test/helpers/pump_app.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mini_sprite_editor/l10n/l10n.dart'; + +extension PumpApp on WidgetTester { + Future pumpApp(Widget widget) { + return pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: widget, + ), + ); + } +} diff --git a/packages/mini_sprite_editor/test/sprite/cubit/sprite_cubit_test.dart b/packages/mini_sprite_editor/test/sprite/cubit/sprite_cubit_test.dart new file mode 100644 index 0000000..2b96f7c --- /dev/null +++ b/packages/mini_sprite_editor/test/sprite/cubit/sprite_cubit_test.dart @@ -0,0 +1,400 @@ +// ignore_for_file: one_member_abstracts + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mini_sprite/mini_sprite.dart'; +import 'package:mini_sprite_editor/sprite/sprite.dart'; +import 'package:mocktail/mocktail.dart'; + +abstract class _SetClipboardStub { + Future setClipboardData(ClipboardData data); +} + +class SetClipboardStub extends Mock implements _SetClipboardStub {} + +abstract class _GetClipboardStub { + Future getClipboardData(String format); +} + +class GetClipboardStub extends Mock implements _GetClipboardStub {} + +void main() { + group('SpriteCubit', () { + setUpAll(() { + registerFallbackValue(const ClipboardData(text: '')); + }); + + test('can be instantiated', () { + expect( + SpriteCubit(), + isNotNull, + ); + }); + + test( + 'copyToClipboard sets the serialized sprite to the clipboard', + () async { + final stub = SetClipboardStub(); + when(() => stub.setClipboardData(any())).thenAnswer((_) async {}); + + final cubit = SpriteCubit(setClipboardData: stub.setClipboardData); + + final expected = MiniSprite(cubit.state.pixels).toDataString(); + cubit.copyToClipboard(); + + verify( + () => stub.setClipboardData( + any( + that: isA().having( + (data) => data.text, + 'text', + equals(expected), + ), + ), + ), + ).called(1); + }, + ); + + group('importFromClipboard', () { + late GetClipboardStub stub; + final sprite = MiniSprite([ + [true, false], + [false, true] + ]); + + setUp(() { + stub = GetClipboardStub(); + }); + + blocTest( + 'emits the updated pixels when there is data', + build: () => SpriteCubit(getClipboardData: stub.getClipboardData), + setUp: () { + when(() => stub.getClipboardData('text/plain')).thenAnswer( + (_) async => ClipboardData(text: sprite.toDataString()), + ); + }, + act: (cubit) => cubit.importFromClipboard(), + expect: () => [ + SpriteState.initial().copyWith(pixels: sprite.pixels), + ], + ); + }); + + blocTest( + 'increase pixel size on zoom in', + build: SpriteCubit.new, + act: (cubit) => cubit.zoomIn(), + expect: () => [ + SpriteState.initial().copyWith(pixelSize: 35), + ], + ); + + blocTest( + 'decrease pixel size on zoom out', + build: SpriteCubit.new, + act: (cubit) => cubit.zoomOut(), + expect: () => [ + SpriteState.initial().copyWith(pixelSize: 15), + ], + ); + + blocTest( + 'resets cursor on cursorLeft', + build: SpriteCubit.new, + seed: () => SpriteState.initial().copyWith(cursorPosition: Offset.zero), + act: (cubit) => cubit.cursorLeft(), + expect: () => [ + SpriteState.initial().copyWith(cursorPosition: const Offset(-1, -1)), + ], + ); + + blocTest( + 'changes tool on selectTool', + build: SpriteCubit.new, + act: (cubit) => cubit.selectTool(SpriteTool.bucket), + expect: () => [ + SpriteState.initial().copyWith(tool: SpriteTool.bucket), + ], + ); + + group('cursorHover', () { + final state = SpriteState.initial().copyWith( + pixels: [ + [true, true], + [true, true], + ], + ); + + blocTest( + 'change cursor position on cursorHover', + build: SpriteCubit.new, + seed: () => state, + act: (cubit) => cubit.cursorHover(const Offset(30, 30)), + expect: () => [ + state.copyWith(cursorPosition: const Offset(1, 1)), + ], + ); + + blocTest( + "doesn't change the position when the position is the same", + build: SpriteCubit.new, + seed: () => state.copyWith(cursorPosition: const Offset(1, 1)), + act: (cubit) => cubit.cursorHover(const Offset(30, 30)), + expect: () => [], + ); + }); + + group('cursorHover', () { + final state = SpriteState.initial().copyWith( + pixels: [ + [true, true], + [true, true], + ], + ); + + blocTest( + 'change cursor position on cursorDown and active the tool', + build: SpriteCubit.new, + seed: () => state, + act: (cubit) => cubit.cursorDown(const Offset(30, 30)), + expect: () => [ + state.copyWith( + cursorPosition: const Offset(1, 1), + toolActive: true, + ), + ], + ); + }); + + group('cursorUp', () { + final state = SpriteState.initial().copyWith( + pixels: [ + [true, true], + [true, true], + ], + ); + + blocTest( + 'change cursor position on cursorUp and deactive the tool', + build: SpriteCubit.new, + seed: () => state.copyWith(toolActive: true), + act: (cubit) => cubit.cursorUp(), + expect: () => [ + state.copyWith( + toolActive: false, + ), + ], + ); + }); + + group('tools', () { + final emptyState = SpriteState.initial().copyWith( + pixels: [ + [false, false, false], + [false, false, false], + [false, false, false], + ], + ); + final filledState = SpriteState.initial().copyWith( + pixels: [ + [true, true, true], + [true, true, true], + [true, true, true], + ], + ); + + group('brush', () { + blocTest( + 'paints the board', + build: SpriteCubit.new, + seed: () => emptyState, + act: (cubit) => cubit + ..cursorDown(Offset.zero) + ..cursorHover(const Offset(30, 0)) + ..cursorHover(const Offset(60, 0)) + ..cursorUp(), + expect: () => [ + emptyState.copyWith(cursorPosition: Offset.zero, toolActive: true), + emptyState.copyWith( + cursorPosition: Offset.zero, + toolActive: true, + pixels: [ + [true, false, false], + [false, false, false], + [false, false, false], + ], + ), + emptyState.copyWith( + cursorPosition: const Offset(1, 0), + toolActive: true, + pixels: [ + [true, false, false], + [false, false, false], + [false, false, false], + ], + ), + emptyState.copyWith( + cursorPosition: const Offset(1, 0), + toolActive: true, + pixels: [ + [true, true, false], + [false, false, false], + [false, false, false], + ], + ), + emptyState.copyWith( + cursorPosition: const Offset(2, 0), + toolActive: true, + pixels: [ + [true, true, false], + [false, false, false], + [false, false, false], + ], + ), + emptyState.copyWith( + cursorPosition: const Offset(2, 0), + toolActive: true, + pixels: [ + [true, true, true], + [false, false, false], + [false, false, false], + ], + ), + emptyState.copyWith( + cursorPosition: const Offset(2, 0), + toolActive: false, + pixels: [ + [true, true, true], + [false, false, false], + [false, false, false], + ], + ), + ], + ); + }); + + group('eraser', () { + blocTest( + 'clears the board', + build: SpriteCubit.new, + seed: () => filledState.copyWith(tool: SpriteTool.eraser), + act: (cubit) => cubit + ..cursorDown(Offset.zero) + ..cursorHover(const Offset(30, 0)) + ..cursorHover(const Offset(60, 0)) + ..cursorUp(), + expect: () => [ + filledState.copyWith( + cursorPosition: Offset.zero, + toolActive: true, + tool: SpriteTool.eraser, + ), + filledState.copyWith( + cursorPosition: Offset.zero, + toolActive: true, + tool: SpriteTool.eraser, + pixels: [ + [false, true, true], + [true, true, true], + [true, true, true], + ], + ), + filledState.copyWith( + cursorPosition: const Offset(1, 0), + toolActive: true, + tool: SpriteTool.eraser, + pixels: [ + [false, true, true], + [true, true, true], + [true, true, true], + ], + ), + filledState.copyWith( + cursorPosition: const Offset(1, 0), + toolActive: true, + tool: SpriteTool.eraser, + pixels: [ + [false, false, true], + [true, true, true], + [true, true, true], + ], + ), + filledState.copyWith( + cursorPosition: const Offset(2, 0), + toolActive: true, + tool: SpriteTool.eraser, + pixels: [ + [false, false, true], + [true, true, true], + [true, true, true], + ], + ), + filledState.copyWith( + cursorPosition: const Offset(2, 0), + toolActive: true, + tool: SpriteTool.eraser, + pixels: [ + [false, false, false], + [true, true, true], + [true, true, true], + ], + ), + filledState.copyWith( + cursorPosition: const Offset(2, 0), + toolActive: false, + tool: SpriteTool.eraser, + pixels: [ + [false, false, false], + [true, true, true], + [true, true, true], + ], + ), + ], + ); + }); + + group('bucket', () { + blocTest( + 'fills the board', + build: SpriteCubit.new, + seed: () => emptyState.copyWith(tool: SpriteTool.bucket), + act: (cubit) => cubit..cursorDown(Offset.zero), + expect: () => [ + emptyState.copyWith( + cursorPosition: Offset.zero, + toolActive: true, + tool: SpriteTool.bucket, + ), + filledState.copyWith( + tool: SpriteTool.bucket, + cursorPosition: Offset.zero, + ), + ], + ); + }); + + group('bucketEraser', () { + blocTest( + 'fills the board', + build: SpriteCubit.new, + seed: () => filledState.copyWith(tool: SpriteTool.bucketEraser), + act: (cubit) => cubit..cursorDown(Offset.zero), + expect: () => [ + filledState.copyWith( + cursorPosition: Offset.zero, + toolActive: true, + tool: SpriteTool.bucketEraser, + ), + emptyState.copyWith( + tool: SpriteTool.bucketEraser, + cursorPosition: Offset.zero, + ), + ], + ); + }); + }); + }); +} diff --git a/packages/mini_sprite_editor/test/sprite/cubit/sprite_state_test.dart b/packages/mini_sprite_editor/test/sprite/cubit/sprite_state_test.dart new file mode 100644 index 0000000..e8b7a5b --- /dev/null +++ b/packages/mini_sprite_editor/test/sprite/cubit/sprite_state_test.dart @@ -0,0 +1,252 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mini_sprite_editor/sprite/sprite.dart'; + +void main() { + group('SpriteState', () { + test('can be instantiated', () { + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + equals( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + isNot( + equals( + SpriteState( + pixelSize: 11, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + isNot( + equals( + SpriteState( + pixelSize: 10, + pixels: const [ + [true] + ], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + isNot( + equals( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset(1, 1), + tool: SpriteTool.brush, + toolActive: false, + ), + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + isNot( + equals( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.eraser, + toolActive: false, + ), + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + isNot( + equals( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: true, + ), + ), + ), + ); + }); + + test('copyWith returns a new isntance with the field updated', () { + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ).copyWith(pixelSize: 11), + equals( + SpriteState( + pixelSize: 11, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ).copyWith( + pixels: [ + [true] + ], + ), + equals( + SpriteState( + pixelSize: 10, + pixels: const [ + [true] + ], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ).copyWith(cursorPosition: Offset(1, 1)), + equals( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset(1, 1), + tool: SpriteTool.brush, + toolActive: false, + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ).copyWith(tool: SpriteTool.eraser), + equals( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.eraser, + toolActive: false, + ), + ), + ); + + expect( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: false, + ).copyWith(toolActive: true), + equals( + SpriteState( + pixelSize: 10, + pixels: const [], + cursorPosition: Offset.zero, + tool: SpriteTool.brush, + toolActive: true, + ), + ), + ); + }); + }); +} diff --git a/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_empty.png b/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_empty.png new file mode 100644 index 0000000..ad696eb Binary files /dev/null and b/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_empty.png differ diff --git a/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_hovered.png b/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_hovered.png new file mode 100644 index 0000000..379a983 Binary files /dev/null and b/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_hovered.png differ diff --git a/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_selected.png b/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_selected.png new file mode 100644 index 0000000..c2ab119 Binary files /dev/null and b/packages/mini_sprite_editor/test/sprite/view/goldens/pixel_cell_selected.png differ diff --git a/packages/mini_sprite_editor/test/sprite/view/pixel_cell_test.dart b/packages/mini_sprite_editor/test/sprite/view/pixel_cell_test.dart new file mode 100644 index 0000000..e3ea3ce --- /dev/null +++ b/packages/mini_sprite_editor/test/sprite/view/pixel_cell_test.dart @@ -0,0 +1,53 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mini_sprite_editor/sprite/sprite.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('PixelCell', () { + testWidgets('renders correctly when selected', (tester) async { + await tester.pumpApp( + Scaffold( + body: PixelCell(selected: true, hovered: false, pixelSize: 50), + ), + ); + + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('goldens/pixel_cell_selected.png'), + ); + }); + + testWidgets('renders correctly when hovered', (tester) async { + await tester.pumpApp( + Scaffold( + body: PixelCell(selected: false, hovered: true, pixelSize: 50), + ), + ); + + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('goldens/pixel_cell_hovered.png'), + ); + }); + + testWidgets( + 'renders correctly when not selected or hovered', + (tester) async { + await tester.pumpApp( + Scaffold( + body: PixelCell(selected: false, hovered: false, pixelSize: 50), + ), + ); + + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('goldens/pixel_cell_empty.png'), + ); + }, + ); + }); +} diff --git a/packages/mini_sprite_editor/test/sprite/view/sprite_view_test.dart b/packages/mini_sprite_editor/test/sprite/view/sprite_view_test.dart new file mode 100644 index 0000000..035fc7b --- /dev/null +++ b/packages/mini_sprite_editor/test/sprite/view/sprite_view_test.dart @@ -0,0 +1,281 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mini_sprite_editor/sprite/sprite.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; + +class _MockSpriteCubit extends Mock implements SpriteCubit {} + +extension TestWidgetText on WidgetTester { + Future pumpTest({SpriteCubit? cubit}) async { + await pumpApp( + BlocProvider.value( + value: cubit ?? _MockSpriteCubit(), + child: SpriteView(), + ), + ); + } +} + +void main() { + group('SpriteView', () { + late SpriteCubit cubit; + + setUpAll(() { + registerFallbackValue(SpriteTool.brush); + registerFallbackValue(Offset.zero); + }); + + setUp(() { + cubit = _MockSpriteCubit(); + }); + + void _mockState(SpriteState state) { + whenListen( + cubit, + Stream.fromIterable([state]), + initialState: state, + ); + } + + testWidgets('emits cursor down on pan start', (tester) async { + when(() => cubit.cursorDown(any())).thenAnswer((_) {}); + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + + await tester.drag(find.byKey(const Key('board_key')), Offset(30, 30)); + await tester.pump(); + + verify(() => cubit.cursorDown(any())).called(1); + }); + + testWidgets('emits cursor hover on pan update', (tester) async { + when(() => cubit.cursorHover(any())).thenAnswer((_) {}); + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + + await tester.drag(find.byKey(const Key('board_key')), Offset(30, 30)); + await tester.pump(); + + verify(() => cubit.cursorHover(any())).called(2); + }); + + testWidgets('emits cursor hover on hover', (tester) async { + when(() => cubit.cursorHover(any())).thenAnswer((_) {}); + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + await gesture.moveTo( + tester.getCenter( + find.byKey(const Key('board_key')), + ), + ); + await tester.pump(); + + verify(() => cubit.cursorHover(any())).called(1); + }); + + testWidgets('emits cursor up on pan up', (tester) async { + when(() => cubit.cursorHover(any())).thenAnswer((_) {}); + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + + await tester.drag(find.byKey(const Key('board_key')), Offset(30, 30)); + await tester.pump(); + + verify(() => cubit.cursorUp()).called(1); + }); + + group('tools', () { + group('brush', () { + testWidgets( + 'selects the tool when tapped', + (tester) async { + _mockState(SpriteState.initial().copyWith(tool: SpriteTool.eraser)); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('brush_key'))); + await tester.pump(); + verify(() => cubit.selectTool(SpriteTool.brush)).called(1); + }, + ); + + testWidgets( + 'does nothing when it is already selected', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('brush_key'))); + await tester.pump(); + verifyNever(() => cubit.selectTool(any())); + }, + ); + }); + + group('eraser', () { + testWidgets( + 'selects the tool when tapped', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('eraser_key'))); + await tester.pump(); + verify(() => cubit.selectTool(SpriteTool.eraser)).called(1); + }, + ); + + testWidgets( + 'does nothing when it is already selected', + (tester) async { + _mockState(SpriteState.initial().copyWith(tool: SpriteTool.eraser)); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('eraser_key'))); + await tester.pump(); + verifyNever(() => cubit.selectTool(any())); + }, + ); + }); + + group('bucket', () { + testWidgets( + 'selects the tool when tapped', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('bucket_key'))); + await tester.pump(); + verify(() => cubit.selectTool(SpriteTool.bucket)).called(1); + }, + ); + + testWidgets( + 'does nothing when it is already selected', + (tester) async { + _mockState(SpriteState.initial().copyWith(tool: SpriteTool.bucket)); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('bucket_key'))); + await tester.pump(); + verifyNever(() => cubit.selectTool(any())); + }, + ); + }); + + group('bucket eraser', () { + testWidgets( + 'selects the tool when tapped', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('bucket_eraser_key'))); + await tester.pump(); + verify(() => cubit.selectTool(SpriteTool.bucketEraser)).called(1); + }, + ); + + testWidgets( + 'does nothing when it is already selected', + (tester) async { + _mockState( + SpriteState.initial().copyWith(tool: SpriteTool.bucketEraser), + ); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('bucket_eraser_key'))); + await tester.pump(); + verifyNever(() => cubit.selectTool(any())); + }, + ); + }); + + testWidgets( + 'zooms in', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('zoom_in_key'))); + await tester.pump(); + verify(cubit.zoomIn).called(1); + }, + ); + + testWidgets( + 'zooms out', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('zoom_out_key'))); + await tester.pump(); + verify(cubit.zoomOut).called(1); + }, + ); + + group('copy to clipboard', () { + setUp(() { + when(cubit.copyToClipboard).thenAnswer((_) async {}); + }); + + testWidgets( + 'copies to the clipboard', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('copy_to_clipboard_key'))); + await tester.pump(); + verify(cubit.copyToClipboard).called(1); + }, + ); + + testWidgets( + 'shows the success message', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap(find.byKey(const Key('copy_to_clipboard_key'))); + await tester.pump(); + expect(find.byType(SnackBar), findsOneWidget); + }, + ); + }); + + group('import from clipboard', () { + setUp(() { + when(cubit.importFromClipboard).thenAnswer((_) async {}); + }); + + testWidgets( + 'copies to the clipboard', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap( + find.byKey(const Key('import_from_clipboard_key')), + ); + await tester.pump(); + verify(cubit.importFromClipboard).called(1); + }, + ); + + testWidgets( + 'shows the success message', + (tester) async { + _mockState(SpriteState.initial()); + await tester.pumpTest(cubit: cubit); + await tester.tap( + find.byKey(const Key('import_from_clipboard_key')), + ); + await tester.pump(); + expect(find.byType(SnackBar), findsOneWidget); + }, + ); + }); + }); + }); +} diff --git a/packages/mini_sprite_editor/web/favicon.png b/packages/mini_sprite_editor/web/favicon.png new file mode 100644 index 0000000..66a69cb Binary files /dev/null and b/packages/mini_sprite_editor/web/favicon.png differ diff --git a/packages/mini_sprite_editor/web/icons/Icon-192.png b/packages/mini_sprite_editor/web/icons/Icon-192.png new file mode 100644 index 0000000..69c31fc Binary files /dev/null and b/packages/mini_sprite_editor/web/icons/Icon-192.png differ diff --git a/packages/mini_sprite_editor/web/icons/Icon-512.png b/packages/mini_sprite_editor/web/icons/Icon-512.png new file mode 100644 index 0000000..d920815 Binary files /dev/null and b/packages/mini_sprite_editor/web/icons/Icon-512.png differ diff --git a/packages/mini_sprite_editor/web/icons/favicon.png b/packages/mini_sprite_editor/web/icons/favicon.png new file mode 100644 index 0000000..66a69cb Binary files /dev/null and b/packages/mini_sprite_editor/web/icons/favicon.png differ diff --git a/packages/mini_sprite_editor/web/index.html b/packages/mini_sprite_editor/web/index.html new file mode 100644 index 0000000..c3ec965 --- /dev/null +++ b/packages/mini_sprite_editor/web/index.html @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + Mini Sprite Editor + + + + + + + + + \ No newline at end of file diff --git a/packages/mini_sprite_editor/web/manifest.json b/packages/mini_sprite_editor/web/manifest.json new file mode 100644 index 0000000..df3df38 --- /dev/null +++ b/packages/mini_sprite_editor/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Mini Sprite Editor", + "short_name": "Mini Sprite Editor", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Small, standalone application that can read and write mini sprites, mainly used by the developer team to add new default sprites on the game"", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +}