From 4b7ae3004484a5f3ccc21333b551b83c20d4f7e2 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Tue, 20 Oct 2026 09:44:27 -0300 Subject: [PATCH 1/2] feat: Adding palette support --- CLAUDE.md | 11 +- docs/architecture.md | 47 +++- .../bloc/component_editor_bloc.dart | 26 ++ .../bloc/component_editor_event.dart | 12 + .../bloc/component_editor_state.dart | 8 + .../view/component_editor_view.dart | 39 ++- .../widgets/component_preview.dart | 16 +- .../widgets/editor_top_bar.dart | 52 ++++ .../widgets/properties_panel.dart | 15 +- .../widgets/property_editors/color_field.dart | 119 +++++++-- .../property_editors/paint_editor.dart | 19 +- lib/component_editor/widgets/widgets.dart | 1 + lib/palette/bloc/palette_bloc.dart | 184 +++++++++++++ lib/palette/bloc/palette_event.dart | 69 +++++ lib/palette/bloc/palette_state.dart | 130 ++++++++++ lib/palette/palette.dart | 2 + lib/palette/view/palette_dialog.dart | 47 ++++ lib/palette/view/palette_view.dart | 189 ++++++++++++++ lib/palette/view/view.dart | 2 + .../src/code_generation/code_generation.dart | 1 + .../component_code_generator.dart | 44 +++- .../palette_code_generator.dart | 59 +++++ .../lib/src/component_repository.dart | 103 +++++++- .../lib/src/models/component_paint.dart | 43 +++- .../lib/src/models/component_tree.dart | 29 +++ .../lib/src/models/models.dart | 1 + .../lib/src/models/palette.dart | 132 ++++++++++ .../component_code_generator_test.dart | 36 +++ .../palette_code_generator_test.dart | 57 +++++ .../test/src/component_repository_test.dart | 154 +++++++++++ .../test/src/models/component_paint_test.dart | 66 +++++ .../test/src/models/component_tree_test.dart | 58 +++++ .../test/src/models/palette_test.dart | 133 ++++++++++ .../bloc/component_editor_bloc_test.dart | 76 ++++++ .../view/component_editor_page_test.dart | 58 +++++ .../property_editors/paint_editor_test.dart | 94 +++++++ test/palette/bloc/palette_bloc_test.dart | 241 ++++++++++++++++++ test/palette/view/palette_dialog_test.dart | 222 ++++++++++++++++ test/project/view/project_page_test.dart | 2 + 39 files changed, 2529 insertions(+), 68 deletions(-) create mode 100644 lib/component_editor/widgets/editor_top_bar.dart create mode 100644 lib/palette/bloc/palette_bloc.dart create mode 100644 lib/palette/bloc/palette_event.dart create mode 100644 lib/palette/bloc/palette_state.dart create mode 100644 lib/palette/palette.dart create mode 100644 lib/palette/view/palette_dialog.dart create mode 100644 lib/palette/view/palette_view.dart create mode 100644 lib/palette/view/view.dart create mode 100644 packages/component_repository/lib/src/code_generation/palette_code_generator.dart create mode 100644 packages/component_repository/lib/src/models/palette.dart create mode 100644 packages/component_repository/test/src/code_generation/palette_code_generator_test.dart create mode 100644 packages/component_repository/test/src/models/palette_test.dart create mode 100644 test/palette/bloc/palette_bloc_test.dart create mode 100644 test/palette/view/palette_dialog_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index ba17056..9c0af1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,15 @@ three pass. - Features: `home` (open project + recent projects), `project` (dashboard: list/add/edit/delete components; adding asks for a name and a component type), `project_settings` (settings dialog: generated code output path, - stored in `/.flame_editor/settings.json`) and `component_editor` - (component tree on the left to add/remove/select/rename nested children, + stored in `/.flame_editor/settings.json`), `palette` (dialog to + manage the project's named colors, stored in + `/.flame_editor/palette.json` and generated as `GamePalette` in + `palette.generated.dart`; paint colors can be picked from it and their + generated code references `GamePalette.`; colors in use can't be + renamed or removed — see "Palette" in + [docs/architecture.md](docs/architecture.md)) and `component_editor` + (a top bar below the app bar for project-wide actions — the palette + button —, component tree on the left to add/remove/select/rename nested children, live Flame preview in the center, with a toolbar above it whose toggle highlights the selected node with a magenta border, properties of the selected node on the right, explicit save with button or Cmd/Ctrl+S, unsaved-changes prompt when diff --git a/docs/architecture.md b/docs/architecture.md index f1305ee..2db78f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,11 +129,12 @@ dependencies: | Data | `packages/key_value_storage_client` | Generic string key-value storage (`shared_preferences`) | | Data | `packages/file_system_client` | Native directory picker (`file_selector`), directory/file existence, list/read/write/delete files | | Repository | `packages/project_repository` | `Project` and `ProjectSettings` models; pick/open projects; last 10 recent projects; read/write project settings (`/.flame_editor/settings.json`) and pick a project-relative output folder | -| Repository | `packages/component_repository` | Sealed `Component` model (one subclass per `ComponentType`), shared property models (`ComponentPosition`, `ComponentSize`, `ComponentPaint`, `ComponentVertices`…), the `PolygonShape` presets new polygons start with, capability interfaces and the `ComponentTree` extension for nested children (including child name validation); list/create/save/delete a project's components (one JSON file each in `/.flame_editor/components/`); `ComponentCodeGenerator` and writing/deleting the generated Dart code (see "Code generation") | +| Repository | `packages/component_repository` | Sealed `Component` model (one subclass per `ComponentType`), shared property models (`ComponentPosition`, `ComponentSize`, `ComponentPaint`, `ComponentVertices`…), the `PolygonShape` presets new polygons start with, capability interfaces and the `ComponentTree` extension for nested children (including child name validation); list/create/save/delete a project's components (one JSON file each in `/.flame_editor/components/`); `ComponentCodeGenerator` and writing/deleting the generated Dart code (see "Code generation"); the project's `Palette` (`/.flame_editor/palette.json`) and its `PaletteCodeGenerator` (see "Palette") | | Bloc + view | `lib/home/` | Landing screen: "Open project" button and recent projects list | | Bloc + view | `lib/project/` | Project dashboard: lists components, add (name + type dialog → editor), edit, delete (with confirmation; also deletes the generated code), settings icon (opens the settings dialog). Pushed from home with `ProjectPage.route` | -| Bloc + view | `lib/component_editor/` | Component editor, `ComponentEditorPage.route(project, component)`: component tree (left, add/remove children, root selected by default), Flame preview (center, magenta border around the selected node; a toolbar above it toggles that highlight), properties panel for the selected node (right; children also get a Name field), save button + Cmd/Ctrl+S (also generates the Dart code), unsaved-changes marker in the title, `PopScope` prompt (cancel / discard / save) when leaving with unsaved changes | +| Bloc + view | `lib/component_editor/` | Component editor, `ComponentEditorPage.route(project, component)`: a top bar (`EditorTopBar`, below the app bar, above the three panels) for project-wide actions (the palette button), component tree (left, add/remove children, root selected by default), Flame preview (center, magenta border around the selected node; a toolbar above it toggles that highlight), properties panel for the selected node (right; children also get a Name field), save button + Cmd/Ctrl+S (also generates the Dart code), unsaved-changes marker in the title, `PopScope` prompt (cancel / discard / save) when leaving with unsaved changes | | Bloc + view | `lib/vertices_editor/` | Vertices editor dialog, `VerticesEditorDialog.show(context, vertices:, paint:)`: a `CustomPaint` canvas (`VerticesCanvas`, `VerticesPainter`) where the user drags vertices, adds one from the "+" handle in the middle of an edge, and deletes the selected one (Delete/Backspace, context menu or button; at least 3 are kept). A side bar (`VerticesList`) has X/Y fields per vertex to type exact coordinates, and a delete button per row; selecting a row or focusing its fields selects the vertex. `VerticesEditorCubit` edits a working copy; Apply returns it normalized, Cancel/Esc returns `null`. `VerticesViewTransform` maps local coordinates to the canvas (fitted once when the dialog opens) and does the hit testing | +| Bloc + view | `lib/palette/` | Palette dialog, `PaletteDialog.show(context, project:, component:)` (opened from the editor's top bar; returns the saved `Palette` or `null`): add, rename, change and remove named colors; colors used by a component (including the unsaved edits of the open one) can't be renamed or removed. `PaletteBloc` saves the palette and generates its code | | Bloc + view | `lib/project_settings/` | Project settings dialog, `ProjectSettingsDialog.show(context, project)`: output path for generated Dart code (default `lib/components`, text field + Browse) | `project_repository` and `component_repository` have no Flutter imports, but @@ -245,6 +246,48 @@ shortcuts. Widget tests that show the preview can't use `pumpAndSettle` (the game loop never settles), so use `tester.pumpRouteTransition()` from `test/helpers`. +### Palette + +A project has one `Palette`: an ordered list of named colors +(`PaletteColor`), stored in `/.flame_editor/palette.json` and read +and written by `ComponentRepository` (`getPalette`, `savePalette`, +`writePaletteCode`). It lives in `component_repository` because component +code references it, components can't take its file name, and "in use" is +computed from the components; a separate repository would have to depend on +this one. + +- **Names** are their generated member names, so they are validated like + child names (`Palette.validateColorName`, `PaletteColorNameError`): a valid + identifier, not a keyword, unique after camelCase normalization. +- **Paints reference colors by name**: `ComponentPaint.paletteColor`. Its + `color` keeps the value the palette color had when it was picked, as a + fallback when the palette doesn't have it (`resolvedColor(palette)`). + `withColor` (picked by hand) clears the reference; `withPaletteColor` + sets it. `paletteColor` is only written to JSON when set. +- **Editing**: `ColorField` gets a palette button (a menu of the colors) + when it is given a palette and `onPaletteColorSelected`; `PaintEditor` + wires it. The editor bloc loads the palette on `ComponentEditorStarted` + and gets the saved one from the dialog with + `ComponentEditorPaletteChanged`; the preview draws + `component.withPaletteColorsResolved(palette)`. Palette changes are not + component changes (`hasChanges` is unaffected). +- **Colors in use can't be renamed or removed** (`PaletteState.usersOf`, + `ComponentTree.usedPaletteColors`); their value can change. That keeps + every reference in saved components and generated code valid. +- **Code**: `PaletteCodeGenerator` writes + `/palette.generated.dart` with + `abstract final class GamePalette { static const primary = Color(…); }` + (no `$` prefix: generated component classes all have one, so they never + clash). Component code uses `Paint()..color = GamePalette.primary` and + imports `palette.generated.dart` for colors the palette has; missing ones + fall back to the inline value. `writeComponentCode` also rewrites the + palette file when the component uses it, so the import always resolves + (e.g. after the output path changed), and `isComponentCodeUpToDate` + compares against code generated with the current palette. Components named + `palette` (any name whose file is `palette`) are rejected on creation, and + an older one's code is never written over the palette's + (`GeneratedFileConflictException`). + ### Code generation Saving a component in the editor also writes its Dart code to diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index b6674a2..a2afb2c 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -41,11 +41,16 @@ class ComponentEditorBloc on(_onRadiusChanged); on(_onVerticesChanged); on(_onPaintChanged); + on(_onPaletteChanged); on(_onSelectionHighlightToggled); on(_onSaveRequested); } final Project _project; + + /// The project the component belongs to. + Project get project => _project; + final ComponentRepository _componentRepository; final ProjectRepository _projectRepository; @@ -53,6 +58,9 @@ class ComponentEditorBloc /// doesn't overwrite what the save found. int _saves = 0; + /// Whether a [ComponentEditorPaletteChanged] was handled. + bool _isPaletteChanged = false; + /// The status after an edit: edits are allowed while saving, but must not /// end the save, or another save could run at the same time. ComponentEditorStatus get _editedStatus => @@ -70,6 +78,16 @@ class ComponentEditorBloc bool isSaving() => _saves != saves || state.status == ComponentEditorStatus.saving; + try { + final palette = await _componentRepository.getPalette(_project.path); + // A palette changed meanwhile is newer. + if (!_isPaletteChanged && palette != state.palette) { + emit(state.copyWith(palette: palette)); + } + } on Exception { + // Paints keep the last known value of their palette colors. + } + try { final taken = await _loadTakenClassNames(); if (isSaving()) return; @@ -257,6 +275,14 @@ class ComponentEditorBloc }); } + void _onPaletteChanged( + ComponentEditorPaletteChanged event, + Emitter emit, + ) { + _isPaletteChanged = true; + emit(state.copyWith(palette: event.palette)); + } + /// Replaces the selected node with the result of [edit]. void _editSelected( Emitter emit, diff --git a/lib/component_editor/bloc/component_editor_event.dart b/lib/component_editor/bloc/component_editor_event.dart index 76f7b8c..d066f66 100644 --- a/lib/component_editor/bloc/component_editor_event.dart +++ b/lib/component_editor/bloc/component_editor_event.dart @@ -157,6 +157,18 @@ final class ComponentEditorPaintChanged extends ComponentEditorEvent { List get props => [paint]; } +/// The palette of the project changed, e.g. in the palette dialog. +final class ComponentEditorPaletteChanged extends ComponentEditorEvent { + /// Creates a [ComponentEditorPaletteChanged] event. + const new(this.palette); + + /// The new palette. + final Palette palette; + + @override + List get props => [palette]; +} + /// The user toggled the highlight of the selected node in the preview. final class ComponentEditorSelectionHighlightToggled extends ComponentEditorEvent { diff --git a/lib/component_editor/bloc/component_editor_state.dart b/lib/component_editor/bloc/component_editor_state.dart index 7ede112..c004886 100644 --- a/lib/component_editor/bloc/component_editor_state.dart +++ b/lib/component_editor/bloc/component_editor_state.dart @@ -45,6 +45,7 @@ final class ComponentEditorState extends Equatable { this.takenClassNames = const {}, this.isCodeOutdated = false, this.isSelectionHighlighted = true, + this.palette = const Palette(), }); /// The current status. @@ -79,6 +80,10 @@ final class ComponentEditorState extends Equatable { /// or a radius (see [SizedComponent] and [RadiusComponent]). final bool isSelectionHighlighted; + /// The palette of the project, whose colors paints can use (see + /// [ComponentPaint.paletteColor]). + final Palette palette; + /// Whether the component can be saved: it has changes or its code is /// outdated, and it is not being saved already. bool get canSave => @@ -120,6 +125,7 @@ final class ComponentEditorState extends Equatable { Map? takenClassNames, bool? isCodeOutdated, bool? isSelectionHighlighted, + Palette? palette, }) { return ComponentEditorState( status: status ?? this.status, @@ -130,6 +136,7 @@ final class ComponentEditorState extends Equatable { isCodeOutdated: isCodeOutdated ?? this.isCodeOutdated, isSelectionHighlighted: isSelectionHighlighted ?? this.isSelectionHighlighted, + palette: palette ?? this.palette, ); } @@ -142,5 +149,6 @@ final class ComponentEditorState extends Equatable { takenClassNames, isCodeOutdated, isSelectionHighlighted, + palette, ]; } diff --git a/lib/component_editor/view/component_editor_view.dart b/lib/component_editor/view/component_editor_view.dart index 60762b5..49aeca8 100644 --- a/lib/component_editor/view/component_editor_view.dart +++ b/lib/component_editor/view/component_editor_view.dart @@ -10,7 +10,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// {@template component_editor_view} /// Shows the tree of the component being edited on the left, its preview /// in the center, below a toolbar, and the properties of the selected node -/// on the right. +/// on the right, below a top bar with the project actions (such as the +/// palette). /// /// Changes are saved with the save button or Cmd/Ctrl+S. /// {@endtemplate} @@ -62,26 +63,38 @@ class ComponentEditorView extends StatelessWidget { title: const _Title(), actions: [_SaveButton(onPressed: save)], ), - body: const Row( + body: const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox(width: treePanelWidth, child: ComponentTreePanel()), - VerticalDivider(width: 1), + EditorTopBar(), + Divider(height: 1), Expanded( - child: Column( + child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - EditorToolbar(), - Divider(height: 1), - Expanded(child: ComponentPreview()), + SizedBox( + width: treePanelWidth, + child: ComponentTreePanel(), + ), + VerticalDivider(width: 1), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + EditorToolbar(), + Divider(height: 1), + Expanded(child: ComponentPreview()), + ], + ), + ), + VerticalDivider(width: 1), + SizedBox( + width: propertiesPanelWidth, + child: PropertiesPanel(), + ), ], ), ), - VerticalDivider(width: 1), - SizedBox( - width: propertiesPanelWidth, - child: PropertiesPanel(), - ), ], ), ), diff --git a/lib/component_editor/widgets/component_preview.dart b/lib/component_editor/widgets/component_preview.dart index 13fd226..673186c 100644 --- a/lib/component_editor/widgets/component_preview.dart +++ b/lib/component_editor/widgets/component_preview.dart @@ -1,3 +1,4 @@ +import 'package:component_repository/component_repository.dart'; import 'package:flame/game.dart'; import 'package:flame_component_editor/component_editor/bloc/component_editor_bloc.dart'; import 'package:flame_component_editor/component_editor/widgets/preview/preview.dart'; @@ -8,6 +9,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// Renders the component being edited with Flame, highlighting the /// selected node unless [ComponentEditorState.isSelectionHighlighted] is /// off. +/// +/// Paints that use palette colors are drawn with their current value in +/// [ComponentEditorState.palette]. /// {@endtemplate} class ComponentPreview extends StatefulWidget { /// {@macro component_preview} @@ -24,11 +28,14 @@ class _ComponentPreviewState extends State { static ComponentPreviewGame _createGame(ComponentEditorState state) { return ComponentPreviewGame( - component: state.component, + component: _previewed(state), highlightedPath: _highlightedPath(state), ); } + static Component _previewed(ComponentEditorState state) => + state.component.withPaletteColorsResolved(state.palette); + static List? _highlightedPath(ComponentEditorState state) => state.isSelectionHighlighted ? state.selectedPath : null; @@ -37,12 +44,15 @@ class _ComponentPreviewState extends State { return BlocListener( listenWhen: (previous, current) => previous.component != current.component || + previous.palette != current.palette || previous.selectedPath != current.selectedPath || previous.isSelectionHighlighted != current.isSelectionHighlighted, // A single call, so a removed node and the new selection are shown // together. - listener: (context, state) => - _game.show(state.component, highlightedPath: _highlightedPath(state)), + listener: (context, state) => _game.show( + _previewed(state), + highlightedPath: _highlightedPath(state), + ), // The preview takes no keyboard input; without this the game widget // would grab focus and swallow the editor shortcuts (e.g. Cmd+S). child: GameWidget(game: _game, autofocus: false), diff --git a/lib/component_editor/widgets/editor_top_bar.dart b/lib/component_editor/widgets/editor_top_bar.dart new file mode 100644 index 0000000..4e39848 --- /dev/null +++ b/lib/component_editor/widgets/editor_top_bar.dart @@ -0,0 +1,52 @@ +import 'package:flame_component_editor/component_editor/bloc/component_editor_bloc.dart'; +import 'package:flame_component_editor/palette/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// {@template editor_top_bar} +/// The bar below the app bar, above the editor panels, with the actions +/// that concern the whole project, such as managing its palette. +/// {@endtemplate} +class EditorTopBar extends StatelessWidget { + /// {@macro editor_top_bar} + const new({super.key}); + + /// Height of the bar. + static const height = 40.0; + + Future _openPalette(BuildContext context) async { + final bloc = context.read(); + final palette = await PaletteDialog.show( + context, + project: bloc.project, + component: bloc.state.component, + ); + // The editor may have been closed meanwhile. + if (palette != null && context.mounted) { + bloc.add(ComponentEditorPaletteChanged(palette)); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ColoredBox( + color: colorScheme.surfaceContainer, + child: SizedBox( + height: height, + child: Row( + children: [ + const SizedBox(width: 8), + IconButton( + tooltip: 'Palette', + iconSize: 20, + color: colorScheme.onSurfaceVariant, + icon: const Icon(Icons.palette_outlined), + onPressed: () => _openPalette(context), + ), + ], + ), + ), + ); + } +} diff --git a/lib/component_editor/widgets/properties_panel.dart b/lib/component_editor/widgets/properties_panel.dart index 01820d9..ba4f9cc 100644 --- a/lib/component_editor/widgets/properties_panel.dart +++ b/lib/component_editor/widgets/properties_panel.dart @@ -19,9 +19,13 @@ class PropertiesPanel extends StatelessWidget { @override Widget build(BuildContext context) { - final (component, selectedPath) = context - .select)>( - (bloc) => (bloc.state.selectedNode, bloc.state.selectedPath), + final (component, selectedPath, palette) = context + .select, Palette)>( + (bloc) => ( + bloc.state.selectedNode, + bloc.state.selectedPath, + bloc.state.palette, + ), ); final bloc = context.read(); final isRoot = selectedPath.isEmpty; @@ -79,7 +83,9 @@ class PropertiesPanel extends StatelessWidget { VerticesEditor( value: polygon.vertices, paint: switch (component) { - PaintedComponent(:final paint) => paint, + PaintedComponent(:final paint) => paint.copyWith( + color: paint.resolvedColor(palette), + ), _ => const ComponentPaint(), }, onChanged: (vertices) => @@ -88,6 +94,7 @@ class PropertiesPanel extends StatelessWidget { if (component case final PaintedComponent painted) PaintEditor( value: painted.paint, + palette: palette, onChanged: (paint) => bloc.add(ComponentEditorPaintChanged(paint)), ), ], diff --git a/lib/component_editor/widgets/property_editors/color_field.dart b/lib/component_editor/widgets/property_editors/color_field.dart index 7568f75..da1b573 100644 --- a/lib/component_editor/widgets/property_editors/color_field.dart +++ b/lib/component_editor/widgets/property_editors/color_field.dart @@ -1,9 +1,14 @@ +import 'package:component_repository/component_repository.dart'; import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; /// {@template color_field} /// Shows a color swatch and its hex code; tapping it opens a color picker. /// +/// When [onPaletteColorSelected] is given and the [palette] has colors, a +/// button next to it lists them, so a palette color can be picked instead. +/// A [paletteColor] in use is shown by name. +/// /// Colors are 32 bit ARGB values. /// {@endtemplate} class ColorField extends StatelessWidget { @@ -12,6 +17,9 @@ class ColorField extends StatelessWidget { required this.label, required this.value, required this.onChanged, + this.palette = const Palette(), + this.paletteColor, + this.onPaletteColorSelected, super.key, }); @@ -19,15 +27,28 @@ class ColorField extends StatelessWidget { final String label; /// The current color, as a 32 bit ARGB value. + /// + /// When [paletteColor] is in the [palette], its value is shown instead. final int value; - /// Called with the color the user picked. + /// Called with the color the user picked by hand. final ValueChanged onChanged; + /// The colors that can be picked by name. + final Palette palette; + + /// The name of the [palette] color in use, if any. + final String? paletteColor; + + /// Called with the name of the [palette] color the user picked. + final ValueChanged? onPaletteColorSelected; + + int get _color => palette.colorOf(paletteColor) ?? value; + Future _pick(BuildContext context) async { final color = await showColorPickerDialog( context, - Color(value), + Color(_color), title: Text(label), pickersEnabled: const { ColorPickerType.primary: true, @@ -39,33 +60,89 @@ class ColorField extends StatelessWidget { colorCodeHasColor: true, constraints: const BoxConstraints(minWidth: 320, maxWidth: 320), ); + // A cancelled picker returns the color it opened with, so the same + // color is no change (and keeps the palette color). final picked = color.toARGB32(); - if (picked != value) onChanged(picked); + if (picked != _color) onChanged(picked); } @override Widget build(BuildContext context) { - final hex = value.toRadixString(16).padLeft(8, '0').toUpperCase(); - return InkWell( - onTap: () => _pick(context), - borderRadius: BorderRadius.circular(4), - child: InputDecorator( - decoration: InputDecoration(labelText: label, isDense: true), - child: Row( - spacing: 8, - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: Color(value), - border: Border.all(color: Theme.of(context).dividerColor), - borderRadius: BorderRadius.circular(4), + final color = _color; + final hex = '#${_hex(color)}'; + final name = palette.contains(paletteColor) ? paletteColor : null; + final onPaletteColorSelected = this.onPaletteColorSelected; + return Row( + children: [ + Expanded( + child: InkWell( + onTap: () => _pick(context), + borderRadius: BorderRadius.circular(4), + child: InputDecorator( + decoration: InputDecoration(labelText: label, isDense: true), + child: Row( + spacing: 8, + children: [ + ColorSwatchBox(color: color), + Flexible( + child: Text( + name == null ? hex : '$name · $hex', + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), - Text('#$hex'), - ], + ), ), + if (onPaletteColorSelected != null && palette.colors.isNotEmpty) + MenuAnchor( + menuChildren: [ + for (final PaletteColor(name: colorName, :color) + in palette.colors) + MenuItemButton( + leadingIcon: ColorSwatchBox(color: color), + trailingIcon: colorName == name + ? const Icon(Icons.check, size: 18) + : null, + onPressed: () => onPaletteColorSelected(colorName), + child: Text(colorName), + ), + ], + builder: (context, controller, _) => IconButton( + tooltip: 'Pick from palette', + icon: const Icon(Icons.palette_outlined), + onPressed: () => + controller.isOpen ? controller.close() : controller.open(), + ), + ), + ], + ); + } + + static String _hex(int color) => + color.toRadixString(16).padLeft(8, '0').toUpperCase(); +} + +/// {@template color_swatch_box} +/// A small rounded square filled with [color]. +/// {@endtemplate} +class ColorSwatchBox extends StatelessWidget { + /// {@macro color_swatch_box} + const new({required this.color, super.key}); + + /// The color, as a 32 bit ARGB value. + final int color; + + @override + Widget build(BuildContext context) { + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Color(color), + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), ), ); } diff --git a/lib/component_editor/widgets/property_editors/paint_editor.dart b/lib/component_editor/widgets/property_editors/paint_editor.dart index 9475461..f8e9de9 100644 --- a/lib/component_editor/widgets/property_editors/paint_editor.dart +++ b/lib/component_editor/widgets/property_editors/paint_editor.dart @@ -6,14 +6,24 @@ import 'package:flutter/material.dart'; /// {@template paint_editor} /// Edits a [ComponentPaint]. Used by every [PaintedComponent]. +/// +/// Its color is picked by hand or from the [palette]. /// {@endtemplate} class PaintEditor extends StatelessWidget { /// {@macro paint_editor} - const new({required this.value, required this.onChanged, super.key}); + const new({ + required this.value, + required this.onChanged, + this.palette = const Palette(), + super.key, + }); /// The current paint. final ComponentPaint value; + /// The palette of the project. + final Palette palette; + /// Called with the edited paint. final ValueChanged onChanged; @@ -25,7 +35,12 @@ class PaintEditor extends StatelessWidget { ColorField( label: 'Color', value: value.color, - onChanged: (color) => onChanged(value.copyWith(color: color)), + palette: palette, + paletteColor: value.paletteColor, + onChanged: (color) => onChanged(value.withColor(color)), + onPaletteColorSelected: (name) => onChanged( + value.withPaletteColor(name, palette.colorOf(name) ?? value.color), + ), ), EnumField( label: 'Style', diff --git a/lib/component_editor/widgets/widgets.dart b/lib/component_editor/widgets/widgets.dart index 89f7357..566c2e0 100644 --- a/lib/component_editor/widgets/widgets.dart +++ b/lib/component_editor/widgets/widgets.dart @@ -1,6 +1,7 @@ export 'component_preview.dart'; export 'component_tree_panel.dart'; export 'editor_toolbar.dart'; +export 'editor_top_bar.dart'; export 'preview/preview.dart'; export 'properties_panel.dart'; export 'property_editors/property_editors.dart'; diff --git a/lib/palette/bloc/palette_bloc.dart b/lib/palette/bloc/palette_bloc.dart new file mode 100644 index 0000000..20a42de --- /dev/null +++ b/lib/palette/bloc/palette_bloc.dart @@ -0,0 +1,184 @@ +import 'package:bloc/bloc.dart'; +import 'package:component_repository/component_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:project_repository/project_repository.dart'; + +part 'palette_event.dart'; +part 'palette_state.dart'; + +/// {@template palette_bloc} +/// Loads, edits and saves the [Palette] of a [Project]. +/// +/// Saving also generates the Dart code of the palette in the output folder +/// of the project settings. +/// +/// Colors used by a component, including the unsaved changes of the +/// [Component] being edited, can't be renamed or removed; their value can +/// still change. +/// {@endtemplate} +class PaletteBloc extends Bloc { + /// {@macro palette_bloc} + new({ + required this._project, + required this._editedComponent, + required this._componentRepository, + required this._projectRepository, + }) : super(const PaletteState()) { + on(_onStarted); + on(_onColorAdded); + on(_onColorRenamed); + on(_onColorValueChanged); + on(_onColorRemoved); + on(_onSaveRequested); + } + + final Project _project; + final Component _editedComponent; + final ComponentRepository _componentRepository; + final ProjectRepository _projectRepository; + + /// The next [PaletteEntry.id]. + int _nextId = 0; + + Future _onStarted( + PaletteStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PaletteStatus.loading)); + try { + final palette = await _componentRepository.getPalette(_project.path); + final components = [ + for (final component in await _componentRepository.getComponents( + _project.path, + )) + if (component.name != _editedComponent.name) component, + _editedComponent, + ]..sort((a, b) => a.name.compareTo(b.name)); + final usage = >{}; + for (final component in components) { + for (final color in component.usedPaletteColors) { + (usage[color] ??= []).add(component.name); + } + } + emit( + state.copyWith( + status: PaletteStatus.ready, + savedPalette: palette, + usage: usage, + entries: [ + for (final color in palette.colors) + PaletteEntry(id: _nextId++, color: color, savedName: color.name), + ], + ), + ); + } on Exception { + emit(state.copyWith(status: PaletteStatus.failure)); + } + } + + void _onColorAdded(PaletteColorAdded event, Emitter emit) { + if (!state.isEditable) return; + final color = PaletteColor( + name: state.palette.uniqueColorName(), + color: ComponentPaint.defaultColor, + ); + emit( + state.copyWith( + status: PaletteStatus.ready, + entries: [ + ...state.entries, + PaletteEntry(id: _nextId++, color: color), + ], + ), + ); + } + + void _onColorRenamed(PaletteColorRenamed event, Emitter emit) { + // Invalid names are kept, so the user can fix them; they prevent saving. + _editEntry(emit, event.id, (entry) { + if (state.usersOf(entry).isNotEmpty) return entry; + return entry.copyWith(color: entry.color.copyWith(name: event.name)); + }); + } + + void _onColorValueChanged( + PaletteColorValueChanged event, + Emitter emit, + ) { + _editEntry( + emit, + event.id, + (entry) => + entry.copyWith(color: entry.color.copyWith(color: event.color)), + ); + } + + void _onColorRemoved(PaletteColorRemoved event, Emitter emit) { + if (!state.isEditable) return; + final entries = [ + for (final entry in state.entries) + if (entry.id != event.id || state.usersOf(entry).isNotEmpty) entry, + ]; + if (entries.length == state.entries.length) return; + emit(state.copyWith(status: PaletteStatus.ready, entries: entries)); + } + + void _editEntry( + Emitter emit, + int id, + PaletteEntry Function(PaletteEntry entry) edit, + ) { + if (!state.isEditable) return; + final entries = state.entries + .map((entry) => entry.id == id ? edit(entry) : entry) + .toList(); + emit(state.copyWith(status: PaletteStatus.ready, entries: entries)); + } + + Future _onSaveRequested( + PaletteSaveRequested event, + Emitter emit, + ) async { + if (!state.canSave) return; + final palette = state.palette; + emit(state.copyWith(status: PaletteStatus.saving)); + try { + await _componentRepository.savePalette(_project.path, palette); + } on Exception { + emit(state.copyWith(status: PaletteStatus.failure)); + return; + } + + // Saved colors are now referenced by their current name. + void emitSaved(PaletteStatus status) { + emit( + state.copyWith( + status: status, + savedPalette: palette, + entries: [ + for (final entry in state.entries) + PaletteEntry( + id: entry.id, + color: entry.color, + savedName: entry.color.name, + ), + ], + ), + ); + } + + try { + final settings = await _projectRepository.getSettings(_project); + await _componentRepository.writePaletteCode( + _project.path, + settings.outputPath, + palette, + ); + emitSaved(PaletteStatus.saved); + } on GeneratedFileConflictException { + emitSaved(PaletteStatus.codeConflict); + } on Exception { + emitSaved(PaletteStatus.codeFailure); + } + } +} diff --git a/lib/palette/bloc/palette_event.dart b/lib/palette/bloc/palette_event.dart new file mode 100644 index 0000000..8978487 --- /dev/null +++ b/lib/palette/bloc/palette_event.dart @@ -0,0 +1,69 @@ +part of 'palette_bloc.dart'; + +/// An event of the [PaletteBloc]. +sealed class PaletteEvent extends Equatable { + const new(); + + @override + List get props => []; +} + +/// The palette dialog was opened. +final class PaletteStarted extends PaletteEvent { + /// Creates a [PaletteStarted] event. + const new(); +} + +/// The user added a color. +final class PaletteColorAdded extends PaletteEvent { + /// Creates a [PaletteColorAdded] event. + const new(); +} + +/// The user renamed the color of the entry with [id]. +final class PaletteColorRenamed extends PaletteEvent { + /// Creates a [PaletteColorRenamed] event. + const new(this.id, this.name); + + /// The [PaletteEntry.id] of the color. + final int id; + + /// The new name. + final String name; + + @override + List get props => [id, name]; +} + +/// The user changed the value of the color of the entry with [id]. +final class PaletteColorValueChanged extends PaletteEvent { + /// Creates a [PaletteColorValueChanged] event. + const new(this.id, this.color); + + /// The [PaletteEntry.id] of the color. + final int id; + + /// The new value, as a 32 bit ARGB value. + final int color; + + @override + List get props => [id, color]; +} + +/// The user removed the color of the entry with [id]. +final class PaletteColorRemoved extends PaletteEvent { + /// Creates a [PaletteColorRemoved] event. + const new(this.id); + + /// The [PaletteEntry.id] of the color. + final int id; + + @override + List get props => [id]; +} + +/// The user asked to save the palette. +final class PaletteSaveRequested extends PaletteEvent { + /// Creates a [PaletteSaveRequested] event. + const new(); +} diff --git a/lib/palette/bloc/palette_state.dart b/lib/palette/bloc/palette_state.dart new file mode 100644 index 0000000..fc9b507 --- /dev/null +++ b/lib/palette/bloc/palette_state.dart @@ -0,0 +1,130 @@ +part of 'palette_bloc.dart'; + +/// The status of the [PaletteBloc]. +enum PaletteStatus { + /// Nothing has been loaded yet. + initial, + + /// The palette is being loaded. + loading, + + /// The palette can be edited. + ready, + + /// The palette is being saved. + saving, + + /// The palette was saved and its code generated. + saved, + + /// The palette was saved, but its code was not generated because a file + /// that was not generated by the editor is in the way. + codeConflict, + + /// The palette was saved, but generating its code failed. + codeFailure, + + /// Something went wrong while loading or saving. + failure; + + /// Whether the palette was saved, even if its code was not generated. + bool get isSaved => + this == saved || this == codeConflict || this == codeFailure; +} + +/// {@template palette_entry} +/// A color of the palette being edited. +/// {@endtemplate} +final class PaletteEntry extends Equatable { + /// {@macro palette_entry} + const new({required this.id, required this.color, this.savedName}); + + /// Identifies the entry while it is edited, even when renamed. + final int id; + + /// The color, with the name and value being edited. + final PaletteColor color; + + /// The name of the color in the saved palette, or `null` for a color + /// added since. Components reference colors by this name. + final String? savedName; + + /// Returns a copy of this entry with [color] replaced. + PaletteEntry copyWith({PaletteColor? color}) { + return PaletteEntry( + id: id, + color: color ?? this.color, + savedName: savedName, + ); + } + + @override + List get props => [id, color, savedName]; +} + +/// {@template palette_state} +/// The state of the [PaletteBloc]. +/// {@endtemplate} +final class PaletteState extends Equatable { + /// {@macro palette_state} + const new({ + this.status = PaletteStatus.initial, + this.entries = const [], + this.savedPalette = const Palette(), + this.usage = const {}, + }); + + /// The current status. + final PaletteStatus status; + + /// The colors being edited, in order. + final List entries; + + /// The palette as it was last saved. + final Palette savedPalette; + + /// The names of the components that use each palette color. + final Map> usage; + + /// The palette being edited. + Palette get palette => + Palette(colors: [for (final entry in entries) entry.color]); + + /// Whether the palette has changes that were not saved yet. + bool get hasChanges => palette != savedPalette; + + /// Whether the palette is loaded and not being saved. + bool get isEditable => + status != PaletteStatus.initial && + status != PaletteStatus.loading && + status != PaletteStatus.saving; + + /// Whether the palette can be saved: every name is valid (see + /// [Palette.validateColorName]) and it is not loading or being saved. + bool get canSave => isEditable && palette.isValid; + + /// The names of the components that use the color of [entry]. A color in + /// use can't be renamed or removed, which would break their references. + List usersOf(PaletteEntry entry) { + final savedName = entry.savedName; + return savedName == null ? const [] : usage[savedName] ?? const []; + } + + /// Returns a copy of this state with the given fields replaced. + PaletteState copyWith({ + PaletteStatus? status, + List? entries, + Palette? savedPalette, + Map>? usage, + }) { + return PaletteState( + status: status ?? this.status, + entries: entries ?? this.entries, + savedPalette: savedPalette ?? this.savedPalette, + usage: usage ?? this.usage, + ); + } + + @override + List get props => [status, entries, savedPalette, usage]; +} diff --git a/lib/palette/palette.dart b/lib/palette/palette.dart new file mode 100644 index 0000000..75da65d --- /dev/null +++ b/lib/palette/palette.dart @@ -0,0 +1,2 @@ +export 'bloc/palette_bloc.dart'; +export 'view/view.dart'; diff --git a/lib/palette/view/palette_dialog.dart b/lib/palette/view/palette_dialog.dart new file mode 100644 index 0000000..207342f --- /dev/null +++ b/lib/palette/view/palette_dialog.dart @@ -0,0 +1,47 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/palette/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:project_repository/project_repository.dart'; + +/// {@template palette_dialog} +/// A dialog to manage the [Palette] of a [Project]. +/// {@endtemplate} +class PaletteDialog extends StatelessWidget { + /// {@macro palette_dialog} + const new({required this.project, required this.component, super.key}); + + /// Shows the palette dialog of [project], opened while editing + /// [component], whose unsaved palette colors count as used. + /// + /// Returns the saved palette, or `null` if it was not saved. + static Future show( + BuildContext context, { + required Project project, + required Component component, + }) { + return showDialog( + context: context, + builder: (_) => PaletteDialog(project: project, component: component), + ); + } + + /// The project whose palette is edited. + final Project project; + + /// The component being edited. + final Component component; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => PaletteBloc( + project: project, + editedComponent: component, + componentRepository: context.read(), + projectRepository: context.read(), + )..add(const PaletteStarted()), + child: const PaletteView(), + ); + } +} diff --git a/lib/palette/view/palette_view.dart b/lib/palette/view/palette_view.dart new file mode 100644 index 0000000..fca805f --- /dev/null +++ b/lib/palette/view/palette_view.dart @@ -0,0 +1,189 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/component_editor/widgets/property_editors/color_field.dart'; +import 'package:flame_component_editor/palette/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// {@template palette_view} +/// Lists the colors of the palette to add, rename, change and remove +/// them, and pops with the palette once it is saved. +/// +/// Colors used by components can't be renamed or removed. +/// {@endtemplate} +class PaletteView extends StatelessWidget { + /// {@macro palette_view} + const new({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (!state.status.isSaved) return; + final message = switch (state.status) { + PaletteStatus.codeConflict => + 'Palette saved, but its code was not generated: a file that was ' + 'not generated by the editor is in the way', + PaletteStatus.codeFailure => + 'Palette saved, but its code could not be generated', + _ => null, + }; + if (message != null) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + Navigator.of(context).pop(state.palette); + }, + builder: (context, state) { + final bloc = context.read(); + final loading = + state.status == PaletteStatus.initial || + state.status == PaletteStatus.loading; + + return AlertDialog( + title: const Text('Palette'), + content: SizedBox( + width: 520, + child: loading + ? const Center( + heightFactor: 1, + child: CircularProgressIndicator(), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.entries.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text( + 'No colors yet. Named colors added here can be ' + 'picked in any color field.', + ), + ), + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + for (final (index, entry) in state.entries.indexed) + _PaletteEntryRow( + key: ValueKey(entry.id), + entry: entry, + users: state.usersOf(entry), + error: state.palette.validateColorName( + index, + entry.color.name, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Add color'), + onPressed: state.isEditable + ? () => bloc.add(const PaletteColorAdded()) + : null, + ), + ), + if (state.status == PaletteStatus.failure) + Text( + 'Something went wrong', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: state.canSave + ? () => bloc.add(const PaletteSaveRequested()) + : null, + child: const Text('Save'), + ), + ], + ); + }, + ); + } +} + +class _PaletteEntryRow extends StatelessWidget { + const new({ + required this.entry, + required this.users, + required this.error, + super.key, + }); + + final PaletteEntry entry; + + /// The components that use the color. + final List users; + + final PaletteColorNameError? error; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + final inUse = users.isNotEmpty; + final usedBy = 'Used by ${users.join(', ')}'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Expanded( + child: TextFormField( + initialValue: entry.color.name, + readOnly: inUse, + decoration: InputDecoration( + labelText: 'Name', + isDense: true, + helperText: inUse ? usedBy : null, + // A color in use can't be renamed, so the other one with + // the same name shows the error. + errorText: switch (inUse ? null : error) { + null => null, + PaletteColorNameError.invalid => + 'Must be a valid Dart identifier', + PaletteColorNameError.duplicate => + 'Another color has this name', + }, + ), + onChanged: (name) => + bloc.add(PaletteColorRenamed(entry.id, name)), + ), + ), + SizedBox( + width: 180, + child: ColorField( + label: 'Color', + value: entry.color.color, + onChanged: (color) => + bloc.add(PaletteColorValueChanged(entry.id, color)), + ), + ), + IconButton( + tooltip: inUse ? "$usedBy, so it can't be removed" : 'Remove', + icon: const Icon(Icons.delete_outline), + onPressed: inUse + ? null + : () => bloc.add(PaletteColorRemoved(entry.id)), + ), + ], + ), + ); + } +} diff --git a/lib/palette/view/view.dart b/lib/palette/view/view.dart new file mode 100644 index 0000000..c845f69 --- /dev/null +++ b/lib/palette/view/view.dart @@ -0,0 +1,2 @@ +export 'palette_dialog.dart'; +export 'palette_view.dart'; diff --git a/packages/component_repository/lib/src/code_generation/code_generation.dart b/packages/component_repository/lib/src/code_generation/code_generation.dart index 02ae8cf..0ec6db5 100644 --- a/packages/component_repository/lib/src/code_generation/code_generation.dart +++ b/packages/component_repository/lib/src/code_generation/code_generation.dart @@ -1 +1,2 @@ export 'component_code_generator.dart'; +export 'palette_code_generator.dart'; diff --git a/packages/component_repository/lib/src/code_generation/component_code_generator.dart b/packages/component_repository/lib/src/code_generation/component_code_generator.dart index 11be0f9..fc57210 100644 --- a/packages/component_repository/lib/src/code_generation/component_code_generator.dart +++ b/packages/component_repository/lib/src/code_generation/component_code_generator.dart @@ -36,7 +36,11 @@ class ComponentCodeGenerator { } /// Returns the formatted Dart source of [component]. - String generate(Component component) { + /// + /// Paints that use a color of [palette] reference its generated class + /// (see [PaletteCodeGenerator]); the others, including those whose + /// palette color no longer exists, use their color value. + String generate(Component component, {Palette palette = const Palette()}) { final rootClass = dartRootClassName(component.name); final buffer = StringBuffer() ..writeln(header) @@ -51,29 +55,33 @@ class ComponentCodeGenerator { ..writeln("import 'dart:ui';") ..writeln(); } - buffer - ..writeln("import 'package:flame/components.dart';") - ..writeln(); + buffer.writeln("import 'package:flame/components.dart';"); + if (component.usedPaletteColors.any(palette.contains)) { + buffer.writeln("import '${PaletteCodeGenerator.fileName}';"); + } + buffer.writeln(); _writeClass( buffer, component, + palette: palette, rootName: component.name, className: rootClass, doc: 'The `${component.name}` component.', parameters: '{super.position, super.angle, super.anchor, super.priority}', - arguments: _arguments(component, withPosition: false), + arguments: _arguments(component, palette, withPosition: false), ); for (final node in component.nodes.skip(1)) { if (node.children.isEmpty) continue; _writeClass( buffer, node, + palette: palette, rootName: component.name, className: dartChildClassName(component.name, node.name), doc: 'The `${node.name}` child component of [$rootClass].', parameters: '', - arguments: _arguments(node, withPosition: true), + arguments: _arguments(node, palette, withPosition: true), ); } return _formatter.format(buffer.toString()); @@ -82,6 +90,7 @@ class ComponentCodeGenerator { void _writeClass( StringBuffer buffer, Component component, { + required Palette palette, required String rootName, required String className, required String doc, @@ -112,7 +121,7 @@ class ComponentCodeGenerator { ? _flameClass(child) : dartChildClassName(rootName, child.name); final value = child.children.isEmpty - ? '$type(${_list(_arguments(child, withPosition: true))})' + ? '$type(${_list(_arguments(child, palette, withPosition: true))})' : '$type()'; buffer ..writeln() @@ -136,7 +145,8 @@ class ComponentCodeGenerator { /// The arguments of the Flame constructor, one per property. The root /// position, angle and anchor are left to whoever uses the component. static List _arguments( - Component component, { + Component component, + Palette palette, { required bool withPosition, }) { return [ @@ -156,7 +166,8 @@ class ComponentCodeGenerator { 'size: ${_vector(c.size.width, c.size.height)}', if (component case final RadiusComponent c) 'radius: ${_number(c.radius.radius)}', - if (component case final PaintedComponent c) 'paint: ${_paint(c.paint)}', + if (component case final PaintedComponent c) + 'paint: ${_paint(c.paint, palette)}', ]; } @@ -183,11 +194,20 @@ class ComponentCodeGenerator { : 'Anchor(${_number(anchor.x)}, ${_number(anchor.y)})'; } - static String _paint(ComponentPaint paint) { - final color = paint.color.toRadixString(16).toUpperCase().padLeft(8, '0'); + /// The `Color` constant of a 32 bit ARGB [color]. + static String colorLiteral(int color) { + final hex = color.toRadixString(16).toUpperCase().padLeft(8, '0'); + return 'Color(0x$hex)'; + } + + static String _paint(ComponentPaint paint, Palette palette) { + final paletteColor = paint.paletteColor; + final color = paletteColor != null && palette.contains(paletteColor) + ? PaletteCodeGenerator.reference(paletteColor) + : 'const ${colorLiteral(paint.color)}'; return [ 'Paint()', - '..color = const Color(0x$color)', + '..color = $color', if (paint.style == PaintStyle.stroke) '..style = PaintingStyle.stroke', ].join(); } diff --git a/packages/component_repository/lib/src/code_generation/palette_code_generator.dart b/packages/component_repository/lib/src/code_generation/palette_code_generator.dart new file mode 100644 index 0000000..49a910e --- /dev/null +++ b/packages/component_repository/lib/src/code_generation/palette_code_generator.dart @@ -0,0 +1,59 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:component_repository/src/code_generation/dart_names.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:pub_semver/pub_semver.dart'; + +/// {@template palette_code_generator} +/// Generates the Dart source of a [Palette]: a class with a `Color` +/// constant for every color, which the code of the components that use +/// them references (`GamePalette.primary`). +/// +/// Generated component classes all start with `$`, so [className] never +/// clashes with them. +/// {@endtemplate} +class PaletteCodeGenerator { + /// {@macro palette_code_generator} + const new(); + + /// The name of the generated class. + static const className = 'GamePalette'; + + /// The name, without extension, of the generated file. Components can't + /// use it (see [ComponentRepository.createComponent]). + static const baseName = 'palette'; + + /// The name of the generated file, in the output folder of the project, + /// next to the code of the components. + static const fileName = '$baseName${ComponentCodeGenerator.fileExtension}'; + + static final _formatter = DartFormatter(languageVersion: Version(3, 8, 0)); + + /// The expression of the color called [name] in the generated code. + static String reference(String name) => '$className.${dartMemberName(name)}'; + + /// Returns the formatted Dart source of [palette]. + String generate(Palette palette) { + final buffer = StringBuffer() + ..writeln(ComponentCodeGenerator.header) + ..writeln('// Generated by Flame Component Editor.') + ..writeln() + ..writeln('// coverage:ignore-file') + ..writeln('// ignore_for_file: type=lint') + ..writeln() + ..writeln("import 'dart:ui';") + ..writeln() + ..writeln('/// The colors of the game palette.') + ..writeln('abstract final class $className {'); + for (final PaletteColor(:name, :color) in palette.colors) { + buffer + ..writeln(' /// The `$name` color.') + ..writeln( + ' static const ${dartMemberName(name)} = ' + '${ComponentCodeGenerator.colorLiteral(color)};', + ) + ..writeln(); + } + buffer.writeln('}'); + return _formatter.format(buffer.toString()); + } +} diff --git a/packages/component_repository/lib/src/component_repository.dart b/packages/component_repository/lib/src/component_repository.dart index 0cd4c15..a7ebfe9 100644 --- a/packages/component_repository/lib/src/component_repository.dart +++ b/packages/component_repository/lib/src/component_repository.dart @@ -56,6 +56,18 @@ class GeneratedFileConflictException implements Exception { final String path; } +/// {@template invalid_palette_exception} +/// Thrown when saving a [Palette] with a color name that can't be used, +/// see [Palette.validateColorName]. +/// {@endtemplate} +class InvalidPaletteException implements Exception { + /// {@macro invalid_palette_exception} + const new(this.palette); + + /// The rejected palette. + final Palette palette; +} + /// {@template component_repository} /// Lists, creates, saves and deletes the components of a project. /// @@ -63,20 +75,29 @@ class GeneratedFileConflictException implements Exception { /// [componentsDirectory] of the project folder, and its Dart code is /// generated by the [ComponentCodeGenerator] inside the output folder of /// the project. +/// +/// The [Palette] shared by the components is stored in the [paletteFile] +/// of the project folder, and its Dart code is generated by the +/// [PaletteCodeGenerator] in the same output folder as the components. /// {@endtemplate} class ComponentRepository { /// {@macro component_repository} new({ required this._fileSystem, this._codeGenerator = const ComponentCodeGenerator(), + this._paletteCodeGenerator = const PaletteCodeGenerator(), }); final FileSystemClient _fileSystem; final ComponentCodeGenerator _codeGenerator; + final PaletteCodeGenerator _paletteCodeGenerator; /// Folder, relative to the project folder, where components are stored. static const componentsDirectory = '.flame_editor/components'; + /// File, relative to the project folder, where the palette is stored. + static const paletteFile = '.flame_editor/palette.json'; + static final _validName = RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$'); /// Returns the components of the project at [projectPath], sorted by name. @@ -105,7 +126,8 @@ class ComponentRepository { /// [polygonShape]. /// /// Throws an [InvalidComponentNameException] if [name] is not a valid - /// identifier or doesn't make a valid class name (such as `_1`), or a + /// identifier or doesn't make a valid class name (such as `_1`), or if + /// its code would take the place of the palette's (`palette`), or a /// [ComponentAlreadyExistsException] if a component with that name, or /// one that generates the same file (such as `playerShip` and /// `PlayerShip`), already exists. Throws a @@ -118,7 +140,10 @@ class ComponentRepository { PolygonShape polygonShape = PolygonShape.triangle, }) async { final className = dartClassName(name); - if (!_validName.hasMatch(name) || !isDartIdentifier(className)) { + final fileName = dartFileName(name); + if (!_validName.hasMatch(name) || + !isDartIdentifier(className) || + fileName == PaletteCodeGenerator.baseName) { throw InvalidComponentNameException(name); } @@ -126,7 +151,6 @@ class ComponentRepository { _directory(projectPath), extension: '.json', ); - final fileName = dartFileName(name); if (existing.any( (file) => dartFileName(p.basenameWithoutExtension(file)) == fileName, )) { @@ -155,18 +179,84 @@ class ComponentRepository { /// relative to the project at [projectPath], replacing the previously /// generated code. /// + /// Paints that use colors of the project's palette reference them, so + /// the code of the palette is written too, keeping the reference valid. + /// /// Throws a [GeneratedFileConflictException] if the file exists but was - /// not generated by the editor; it is never overwritten. + /// not generated by the editor, or is the palette's (a component named + /// `palette` created before the name was reserved); it is never + /// overwritten. Future writeComponentCode( String projectPath, String outputPath, Component component, ) async { final path = _codeFile(projectPath, outputPath, component); + if (_codeGenerator.fileName(component) == PaletteCodeGenerator.fileName) { + throw GeneratedFileConflictException(path); + } + await _writeGenerated(path, () async { + final palette = await getPalette(projectPath); + if (component.usedPaletteColors.any(palette.contains)) { + await writePaletteCode(projectPath, outputPath, palette); + } + return _codeGenerator.generate(component, palette: palette); + }); + } + + /// Returns the palette of the project at [projectPath]. + /// + /// Returns an empty palette if none was saved or the stored data is + /// unreadable. + Future getPalette(String projectPath) async { + final path = p.join(projectPath, paletteFile); + try { + if (!await _fileSystem.fileExists(path)) return const Palette(); + return Palette.fromJson(jsonDecode(await _fileSystem.readFile(path))); + } on Object { + return const Palette(); + } + } + + /// Saves [palette] as the palette of the project at [projectPath]. + /// + /// Throws an [InvalidPaletteException] if a color has a name that can't + /// be used (see [Palette.validateColorName]). + Future savePalette(String projectPath, Palette palette) async { + if (!palette.isValid) throw InvalidPaletteException(palette); + await _fileSystem.writeFile( + p.join(projectPath, paletteFile), + jsonEncode(palette.toJson()), + ); + } + + /// Writes the Dart code of [palette] to the folder [outputPath], relative + /// to the project at [projectPath], replacing the previously generated + /// code. + /// + /// Throws a [GeneratedFileConflictException] if the file exists but was + /// not generated by the editor; it is never overwritten. + Future writePaletteCode( + String projectPath, + String outputPath, + Palette palette, + ) { + return _writeGenerated( + p.join(projectPath, outputPath, PaletteCodeGenerator.fileName), + () async => _paletteCodeGenerator.generate(palette), + ); + } + + /// Writes the code built by [generate] to [path], unless a file that was + /// not generated by the editor is there. + Future _writeGenerated( + String path, + Future Function() generate, + ) async { if (await _fileSystem.fileExists(path) && !await _isGenerated(path)) { throw GeneratedFileConflictException(path); } - await _fileSystem.writeFile(path, _codeGenerator.generate(component)); + await _fileSystem.writeFile(path, await generate()); } /// Whether the folder [outputPath], relative to the project at @@ -182,8 +272,9 @@ class ComponentRepository { ) async { final path = _codeFile(projectPath, outputPath, component); if (!await _fileSystem.fileExists(path)) return false; + final palette = await getPalette(projectPath); return await _fileSystem.readFile(path) == - _codeGenerator.generate(component); + _codeGenerator.generate(component, palette: palette); } /// Deletes [component] from the project at [projectPath], along with its diff --git a/packages/component_repository/lib/src/models/component_paint.dart b/packages/component_repository/lib/src/models/component_paint.dart index 84ded28..269ad2c 100644 --- a/packages/component_repository/lib/src/models/component_paint.dart +++ b/packages/component_repository/lib/src/models/component_paint.dart @@ -3,10 +3,18 @@ import 'package:equatable/equatable.dart'; /// {@template component_paint} /// The paint property shared by every component that draws with a paint. +/// +/// Its color is either picked by hand or a [paletteColor] of the project's +/// [Palette], in which case [color] keeps its last known value, used when +/// the palette doesn't have it. /// {@endtemplate} class ComponentPaint extends Equatable { /// {@macro component_paint} - const new({this.color = defaultColor, this.style = PaintStyle.fill}); + const new({ + this.color = defaultColor, + this.style = PaintStyle.fill, + this.paletteColor, + }); /// Creates a [ComponentPaint] from its JSON representation. /// @@ -14,9 +22,11 @@ class ComponentPaint extends Equatable { factory fromJson(Object? json) { final map = json is Map ? json : const {}; final color = map['color']; + final paletteColor = map['paletteColor']; return ComponentPaint( color: color is int ? color : defaultColor, style: PaintStyle.fromName(map['style']) ?? PaintStyle.fill, + paletteColor: paletteColor is String ? paletteColor : null, ); } @@ -24,22 +34,49 @@ class ComponentPaint extends Equatable { static const defaultColor = 0xFFFFFFFF; /// The color as a 32 bit ARGB value. + /// + /// When [paletteColor] is set, the value it had when it was last picked. final int color; /// Whether the shape is filled or stroked. final PaintStyle style; + /// The name of the [Palette] color this paint uses, or `null` when its + /// [color] was picked by hand. + final String? paletteColor; + + /// The color to draw with: the [paletteColor] in [palette], or [color] + /// when there is none. + int resolvedColor(Palette palette) => palette.colorOf(paletteColor) ?? color; + /// Returns a copy of this paint with the given fields replaced. ComponentPaint copyWith({int? color, PaintStyle? style}) { return ComponentPaint( color: color ?? this.color, style: style ?? this.style, + paletteColor: paletteColor, ); } + /// Returns a copy of this paint with [color] picked by hand, which no + /// longer uses a palette color. + ComponentPaint withColor(int color) { + return ComponentPaint(color: color, style: style); + } + + /// Returns a copy of this paint that uses the palette color called + /// [name], whose current value is [color]. + ComponentPaint withPaletteColor(String name, int color) { + return ComponentPaint(color: color, style: style, paletteColor: name); + } + /// Converts this paint to its JSON representation. - Map toJson() => {'color': color, 'style': style.name}; + Map toJson() => { + 'color': color, + 'style': style.name, + 'paletteColor': ?paletteColor, + }; @override - List get props => [color, style]; + List get props => [color, style, paletteColor]; } diff --git a/packages/component_repository/lib/src/models/component_tree.dart b/packages/component_repository/lib/src/models/component_tree.dart index ac280dd..55b1383 100644 --- a/packages/component_repository/lib/src/models/component_tree.dart +++ b/packages/component_repository/lib/src/models/component_tree.dart @@ -93,6 +93,35 @@ extension ComponentTree on Component { } } + /// The names of the [Palette] colors the nodes of this tree paint with. + Set get usedPaletteColors { + return { + for (final node in nodes) + if (node case PaintedComponent( + paint: ComponentPaint(:final String paletteColor), + )) + paletteColor, + }; + } + + /// Returns a copy of this tree whose paints have the current value of + /// their palette color in [palette] as their color (see + /// [ComponentPaint.resolvedColor]), which is what they draw with. + Component withPaletteColorsResolved(Palette palette) { + final node = switch (this) { + final PaintedComponent painted when painted.paint.paletteColor != null => + painted.copyWithPaint( + painted.paint.copyWith(color: painted.paint.resolvedColor(palette)), + ), + final node => node, + }; + if (children.isEmpty) return node; + return node.copyWithChildren([ + for (final child in node.children) + child.withPaletteColorsResolved(palette), + ]); + } + /// The name of the class generated for this component as the root of its /// tree, such as `$PlayerShip`. String get generatedClassName => dartRootClassName(name); diff --git a/packages/component_repository/lib/src/models/models.dart b/packages/component_repository/lib/src/models/models.dart index 9363487..9951091 100644 --- a/packages/component_repository/lib/src/models/models.dart +++ b/packages/component_repository/lib/src/models/models.dart @@ -10,4 +10,5 @@ export 'component_tree.dart'; export 'component_type.dart'; export 'component_vertices.dart'; export 'paint_style.dart'; +export 'palette.dart'; export 'polygon_shape.dart'; diff --git a/packages/component_repository/lib/src/models/palette.dart b/packages/component_repository/lib/src/models/palette.dart new file mode 100644 index 0000000..68247a2 --- /dev/null +++ b/packages/component_repository/lib/src/models/palette.dart @@ -0,0 +1,132 @@ +import 'package:component_repository/src/code_generation/dart_names.dart'; +import 'package:equatable/equatable.dart'; + +/// Why a name can't be used for a [PaletteColor], see +/// [Palette.validateColorName]. +enum PaletteColorNameError { + /// The name is not a valid Dart identifier, or its member would be a + /// Dart keyword. + invalid, + + /// Another color of the palette already uses the name. + duplicate, +} + +/// {@template palette_color} +/// A named color of a [Palette]. +/// {@endtemplate} +class PaletteColor extends Equatable { + /// {@macro palette_color} + const new({required this.name, required this.color}); + + /// The name of the color, which names its member in the generated code. + final String name; + + /// The color as a 32 bit ARGB value. + final int color; + + /// Returns a copy of this color with the given fields replaced. + PaletteColor copyWith({String? name, int? color}) { + return PaletteColor(name: name ?? this.name, color: color ?? this.color); + } + + /// Converts this color to its JSON representation. + Map toJson() => {'name': name, 'color': color}; + + @override + List get props => [name, color]; +} + +/// {@template palette} +/// The named colors of a project, shared by its components. +/// +/// Its Dart code is a class with a constant for every color, which the +/// code of the components that use them references. +/// {@endtemplate} +class Palette extends Equatable { + /// {@macro palette} + const new({this.colors = const []}); + + /// Creates a [Palette] from its JSON representation. + /// + /// Invalid entries are skipped, and so are names already taken. + factory fromJson(Object? json) { + final list = json is Map ? json['colors'] : null; + final colors = []; + if (list is List) { + for (final entry in list) { + if (entry case {'name': final String name, 'color': final int color} + when Palette(colors: colors) + .validateColorName(colors.length, name) == + null) { + colors.add(PaletteColor(name: name, color: color)); + } + } + } + return Palette(colors: colors); + } + + /// The colors, in the order they are shown and generated. + final List colors; + + /// The value of the color called [name], or `null` if there is none. + int? colorOf(String? name) { + for (final color in colors) { + if (color.name == name) return color.color; + } + return null; + } + + /// Whether the palette has a color called [name]. + bool contains(String? name) => colorOf(name) != null; + + /// Checks whether [name] can be used for the color at [index] (which may + /// be [colors]' length, for a new color). + /// + /// Returns `null` when it can. The name must be a valid Dart identifier + /// whose member is not a keyword, and no other color may have the same + /// member name (`main_color` and `mainColor` are both `mainColor`). + PaletteColorNameError? validateColorName(int index, String name) { + final member = dartMemberName(name); + if (!isDartIdentifier(name) || + !isDartIdentifier(member) || + isDartKeyword(member)) { + return PaletteColorNameError.invalid; + } + for (final (i, color) in colors.indexed) { + if (i != index && dartMemberName(color.name) == member) { + return PaletteColorNameError.duplicate; + } + } + return null; + } + + /// Whether every color has a valid name, see [validateColorName]. + bool get isValid { + for (final (index, color) in colors.indexed) { + if (validateColorName(index, color.name) != null) return false; + } + return true; + } + + /// A name for a new color that no color uses yet: `color1`, `color2`… + String uniqueColorName() { + for (var i = 1; ; i++) { + final name = 'color$i'; + if (validateColorName(colors.length, name) == null) return name; + } + } + + /// Returns a copy of this palette with [colors] replaced. + Palette copyWith({List? colors}) { + return Palette(colors: colors ?? this.colors); + } + + /// Converts this palette to its JSON representation. + Map toJson() => { + 'colors': [for (final color in colors) color.toJson()], + }; + + @override + List get props => [colors]; +} diff --git a/packages/component_repository/test/src/code_generation/component_code_generator_test.dart b/packages/component_repository/test/src/code_generation/component_code_generator_test.dart index 7a50d1e..5208d2d 100644 --- a/packages/component_repository/test/src/code_generation/component_code_generator_test.dart +++ b/packages/component_repository/test/src/code_generation/component_code_generator_test.dart @@ -415,5 +415,41 @@ class $PlayerHead extends PositionComponent { contains('const Color(0x00000001)'), ); }); + + test('references the palette for palette colors', () { + const palette = Palette( + colors: [PaletteColor(name: 'dark_grey', color: 0xFF333333)], + ); + + final code = generator.generate( + const RectangleComponent( + name: 'Player', + paint: ComponentPaint(paletteColor: 'dark_grey'), + children: [ + CircleComponent( + name: 'eye', + paint: ComponentPaint(color: 0xFF0000FF), + ), + ], + ), + palette: palette, + ); + + expect(code, contains("import 'palette.generated.dart';")); + expect(code, contains('paint: Paint()..color = GamePalette.darkGrey')); + expect(code, contains('const Color(0xFF0000FF)')); + }); + + test('uses the color value when the palette color is missing', () { + final code = generator.generate( + const RectangleComponent( + name: 'Player', + paint: ComponentPaint(color: 0xFF00FF00, paletteColor: 'missing'), + ), + ); + + expect(code, isNot(contains('palette.generated.dart'))); + expect(code, contains('const Color(0xFF00FF00)')); + }); }); } diff --git a/packages/component_repository/test/src/code_generation/palette_code_generator_test.dart b/packages/component_repository/test/src/code_generation/palette_code_generator_test.dart new file mode 100644 index 0000000..a173404 --- /dev/null +++ b/packages/component_repository/test/src/code_generation/palette_code_generator_test.dart @@ -0,0 +1,57 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(PaletteCodeGenerator, () { + const generator = PaletteCodeGenerator(); + + test('fileName is next to the generated components', () { + expect(PaletteCodeGenerator.fileName, equals('palette.generated.dart')); + }); + + test('reference is the camelCase member of the class', () { + expect( + PaletteCodeGenerator.reference('dark_grey'), + equals('GamePalette.darkGrey'), + ); + }); + + test('generates a constant for every color', () { + expect( + generator.generate( + const Palette( + colors: [ + PaletteColor(name: 'primary', color: 0xFFFF0000), + PaletteColor(name: 'dark_grey', color: 0x00333333), + ], + ), + ), + equals(''' +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated by Flame Component Editor. + +// coverage:ignore-file +// ignore_for_file: type=lint + +import 'dart:ui'; + +/// The colors of the game palette. +abstract final class GamePalette { + /// The `primary` color. + static const primary = Color(0xFFFF0000); + + /// The `dark_grey` color. + static const darkGrey = Color(0x00333333); +} +'''), + ); + }); + + test('generates an empty class for an empty palette', () { + expect( + generator.generate(const Palette()), + contains('abstract final class GamePalette {}'), + ); + }); + }); +} diff --git a/packages/component_repository/test/src/component_repository_test.dart b/packages/component_repository/test/src/component_repository_test.dart index af92886..0437fb0 100644 --- a/packages/component_repository/test/src/component_repository_test.dart +++ b/packages/component_repository/test/src/component_repository_test.dart @@ -20,9 +20,21 @@ void main() { late FileSystemClient fileSystem; late ComponentRepository repository; + final paletteFile = p.join(projectPath, ComponentRepository.paletteFile); + final paletteCodeFile = p.join( + projectPath, + 'lib/components', + PaletteCodeGenerator.fileName, + ); + const palette = Palette( + colors: [PaletteColor(name: 'primary', color: 0xFFFF0000)], + ); + setUp(() { fileSystem = _MockFileSystemClient(); repository = ComponentRepository(fileSystem: fileSystem); + when(() => fileSystem.fileExists(paletteFile)) + .thenAnswer((_) async => false); }); group('getComponents', () { @@ -111,6 +123,9 @@ void main() { '../x', '_', '_1', + // Its code would take the place of the palette's. + 'palette', + 'Palette', ]) { test('throws InvalidComponentNameException for "$name"', () { expect( @@ -266,6 +281,145 @@ void main() { ); verifyNever(() => fileSystem.writeFile(any(), any())); }); + + test('throws GeneratedFileConflictException for a component whose ' + 'code would replace the palette', () async { + await expectLater( + repository.writeComponentCode( + projectPath, + 'lib/components', + const RectangleComponent(name: 'palette'), + ), + throwsA(isA()), + ); + verifyNever(() => fileSystem.writeFile(any(), any())); + }); + + test('references the palette and writes its code when the component ' + 'uses it', () async { + const painted = RectangleComponent( + name: 'PlayerShip', + paint: ComponentPaint(paletteColor: 'primary'), + ); + when(() => fileSystem.fileExists(paletteFile)) + .thenAnswer((_) async => true); + when(() => fileSystem.readFile(paletteFile)) + .thenAnswer((_) async => jsonEncode(palette.toJson())); + when(() => fileSystem.fileExists(codeFile)) + .thenAnswer((_) async => false); + when(() => fileSystem.fileExists(paletteCodeFile)) + .thenAnswer((_) async => false); + + await repository.writeComponentCode( + projectPath, + 'lib/components', + painted, + ); + + verify( + () => fileSystem.writeFile( + paletteCodeFile, + const PaletteCodeGenerator().generate(palette), + ), + ).called(1); + verify( + () => fileSystem.writeFile( + codeFile, + generator.generate(painted, palette: palette), + ), + ).called(1); + }); + }); + + group('getPalette', () { + test('returns the stored palette', () async { + when(() => fileSystem.fileExists(paletteFile)) + .thenAnswer((_) async => true); + when(() => fileSystem.readFile(paletteFile)) + .thenAnswer((_) async => jsonEncode(palette.toJson())); + + expect(await repository.getPalette(projectPath), equals(palette)); + }); + + test('returns an empty palette when none was saved', () async { + expect( + await repository.getPalette(projectPath), + equals(const Palette()), + ); + }); + + test('returns an empty palette when unreadable', () async { + when(() => fileSystem.fileExists(paletteFile)) + .thenAnswer((_) async => true); + when(() => fileSystem.readFile(paletteFile)) + .thenAnswer((_) async => 'not json'); + + expect( + await repository.getPalette(projectPath), + equals(const Palette()), + ); + }); + }); + + group('savePalette', () { + test('writes the palette file', () async { + when(() => fileSystem.writeFile(any(), any())).thenAnswer((_) async {}); + + await repository.savePalette(projectPath, palette); + + verify( + () => fileSystem.writeFile(paletteFile, jsonEncode(palette.toJson())), + ).called(1); + }); + + test('throws InvalidPaletteException for invalid names', () async { + await expectLater( + repository.savePalette( + projectPath, + const Palette(colors: [PaletteColor(name: 'class', color: 0)]), + ), + throwsA(isA()), + ); + verifyNever(() => fileSystem.writeFile(any(), any())); + }); + }); + + group('writePaletteCode', () { + setUp(() { + when(() => fileSystem.writeFile(any(), any())).thenAnswer((_) async {}); + }); + + test('writes the generated code to the output folder', () async { + when(() => fileSystem.fileExists(paletteCodeFile)) + .thenAnswer((_) async => false); + + await repository.writePaletteCode( + projectPath, + 'lib/components', + palette, + ); + + verify( + () => fileSystem.writeFile( + paletteCodeFile, + const PaletteCodeGenerator().generate(palette), + ), + ).called(1); + }); + + test('throws GeneratedFileConflictException and keeps a file that was ' + 'not generated', () async { + when(() => fileSystem.fileExists(paletteCodeFile)) + .thenAnswer((_) async => true); + when(() => fileSystem.readFile(paletteCodeFile)) + .thenAnswer((_) async => 'class GamePalette {}'); + + await expectLater( + repository.writePaletteCode(projectPath, 'lib/components', palette), + throwsA(isA()), + ); + verifyNever(() => fileSystem.writeFile(any(), any())); + }); }); group('isComponentCodeUpToDate', () { diff --git a/packages/component_repository/test/src/models/component_paint_test.dart b/packages/component_repository/test/src/models/component_paint_test.dart index 07c24e1..68c276c 100644 --- a/packages/component_repository/test/src/models/component_paint_test.dart +++ b/packages/component_repository/test/src/models/component_paint_test.dart @@ -34,5 +34,71 @@ void main() { equals(const ComponentPaint(style: PaintStyle.stroke)), ); }); + + test('round-trips a palette color through JSON', () { + const paint = ComponentPaint(color: 0xFF00FF00, paletteColor: 'primary'); + + expect(paint.toJson()['paletteColor'], equals('primary')); + expect(ComponentPaint.fromJson(paint.toJson()), equals(paint)); + }); + + test('omits the palette color from JSON when unset', () { + expect(const ComponentPaint().toJson(), isNot(contains('paletteColor'))); + }); + + test('copyWith keeps the palette color', () { + expect( + const ComponentPaint(paletteColor: 'primary') + .copyWith(style: PaintStyle.stroke) + .paletteColor, + equals('primary'), + ); + }); + + test('withColor replaces the palette color by a color', () { + expect( + const ComponentPaint( + paletteColor: 'primary', + style: PaintStyle.stroke, + ).withColor(0xFF000000), + equals( + const ComponentPaint(color: 0xFF000000, style: PaintStyle.stroke), + ), + ); + }); + + test('withPaletteColor uses a palette color', () { + expect( + const ComponentPaint().withPaletteColor('primary', 0xFF00FF00), + equals( + const ComponentPaint(color: 0xFF00FF00, paletteColor: 'primary'), + ), + ); + }); + + test('resolvedColor reads the palette color, or falls back', () { + const palette = Palette( + colors: [PaletteColor(name: 'primary', color: 0xFF0000FF)], + ); + + expect( + const ComponentPaint( + color: 0xFF00FF00, + paletteColor: 'primary', + ).resolvedColor(palette), + equals(0xFF0000FF), + ); + expect( + const ComponentPaint( + color: 0xFF00FF00, + paletteColor: 'missing', + ).resolvedColor(palette), + equals(0xFF00FF00), + ); + expect( + const ComponentPaint(color: 0xFF00FF00).resolvedColor(palette), + equals(0xFF00FF00), + ); + }); }); } diff --git a/packages/component_repository/test/src/models/component_tree_test.dart b/packages/component_repository/test/src/models/component_tree_test.dart index e05edbd..8620a49 100644 --- a/packages/component_repository/test/src/models/component_tree_test.dart +++ b/packages/component_repository/test/src/models/component_tree_test.dart @@ -59,6 +59,64 @@ void main() { ); }); + test('usedPaletteColors lists the palette colors of every node', () { + expect(tree.usedPaletteColors, isEmpty); + expect( + const RectangleComponent( + name: 'Player', + paint: ComponentPaint(paletteColor: 'primary'), + children: [ + PositionComponent( + name: 'body', + children: [ + CircleComponent( + name: 'eye', + paint: ComponentPaint(paletteColor: 'accent'), + ), + RectangleComponent( + name: 'arm', + paint: ComponentPaint(paletteColor: 'primary'), + ), + ], + ), + ], + ).usedPaletteColors, + equals({'primary', 'accent'}), + ); + }); + + test('withPaletteColorsResolved sets the palette values as colors', () { + const palette = Palette( + colors: [PaletteColor(name: 'primary', color: 0xFF0000FF)], + ); + final resolved = const RectangleComponent( + name: 'Player', + paint: ComponentPaint(color: 0xFF000000, paletteColor: 'primary'), + children: [ + PositionComponent( + name: 'body', + children: [ + CircleComponent( + name: 'eye', + paint: ComponentPaint(paletteColor: 'primary'), + ), + RectangleComponent( + name: 'arm', + paint: ComponentPaint(color: 0xFF00FF00), + ), + ], + ), + ], + ).withPaletteColorsResolved(palette); + + int colorAt(List path) => + (resolved.nodeAt(path) as PaintedComponent).paint.color; + expect(colorAt(const []), equals(0xFF0000FF)); + expect(colorAt(const [0, 0]), equals(0xFF0000FF)); + expect(colorAt(const [0, 1]), equals(0xFF00FF00)); + expect(tree.withPaletteColorsResolved(palette), equals(tree)); + }); + test('uniqueChildName picks the first name that is not taken', () { expect( tree.uniqueChildName(ComponentType.rectangle), diff --git a/packages/component_repository/test/src/models/palette_test.dart b/packages/component_repository/test/src/models/palette_test.dart new file mode 100644 index 0000000..76948d3 --- /dev/null +++ b/packages/component_repository/test/src/models/palette_test.dart @@ -0,0 +1,133 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(PaletteColor, () { + test('copyWith replaces the given fields', () { + const color = PaletteColor(name: 'primary', color: 0xFF000000); + + expect( + color.copyWith(name: 'accent'), + equals(const PaletteColor(name: 'accent', color: 0xFF000000)), + ); + expect( + color.copyWith(color: 0xFFFFFFFF), + equals(const PaletteColor(name: 'primary', color: 0xFFFFFFFF)), + ); + }); + }); + + group(Palette, () { + const palette = Palette( + colors: [ + PaletteColor(name: 'primary', color: 0xFFFF0000), + PaletteColor(name: 'dark_grey', color: 0xFF333333), + ], + ); + + test('round-trips through JSON', () { + expect(Palette.fromJson(palette.toJson()), equals(palette)); + }); + + test('falls back to an empty palette for invalid JSON', () { + expect(Palette.fromJson(null), equals(const Palette())); + expect(Palette.fromJson(const {'colors': 'red'}), const Palette()); + }); + + test('skips invalid entries and taken names', () { + expect( + Palette.fromJson(const { + 'colors': [ + {'name': 'primary', 'color': 1}, + {'name': 'primary', 'color': 2}, + {'name': 'class', 'color': 3}, + {'name': 'accent', 'color': 'red'}, + 'accent', + {'name': 'accent', 'color': 4}, + ], + }), + equals( + const Palette( + colors: [ + PaletteColor(name: 'primary', color: 1), + PaletteColor(name: 'accent', color: 4), + ], + ), + ), + ); + }); + + test('colorOf returns the color with the given name', () { + expect(palette.colorOf('dark_grey'), equals(0xFF333333)); + expect(palette.colorOf('missing'), isNull); + expect(palette.colorOf(null), isNull); + expect(palette.contains('primary'), isTrue); + expect(palette.contains('missing'), isFalse); + }); + + group('validateColorName', () { + test('accepts unique identifiers', () { + expect(palette.validateColorName(2, 'accent'), isNull); + // A color keeps its own name. + expect(palette.validateColorName(0, 'primary'), isNull); + }); + + test('rejects invalid names and keywords', () { + expect( + palette.validateColorName(2, '1st'), + equals(PaletteColorNameError.invalid), + ); + expect( + palette.validateColorName(2, ''), + equals(PaletteColorNameError.invalid), + ); + expect( + palette.validateColorName(2, 'class'), + equals(PaletteColorNameError.invalid), + ); + expect( + palette.validateColorName(2, '_'), + equals(PaletteColorNameError.invalid), + ); + }); + + test('rejects names with the same member as another color', () { + expect( + palette.validateColorName(2, 'darkGrey'), + equals(PaletteColorNameError.duplicate), + ); + expect( + palette.validateColorName(1, 'primary'), + equals(PaletteColorNameError.duplicate), + ); + }); + }); + + test('isValid checks every name', () { + expect(palette.isValid, isTrue); + expect( + const Palette( + colors: [ + PaletteColor(name: 'a', color: 0), + PaletteColor(name: 'a', color: 0), + ], + ).isValid, + isFalse, + ); + }); + + test('uniqueColorName returns the first free name', () { + expect(const Palette().uniqueColorName(), equals('color1')); + expect( + const Palette(colors: [PaletteColor(name: 'color1', color: 0)]) + .uniqueColorName(), + equals('color2'), + ); + }); + + test('copyWith replaces the colors', () { + expect(palette.copyWith(colors: const []), equals(const Palette())); + expect(palette.copyWith(), equals(palette)); + }); + }); +} diff --git a/test/component_editor/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index e130be9..3184a83 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -22,6 +22,9 @@ void main() { const size = ComponentSize(width: 10, height: 20); const paint = ComponentPaint(color: 0xFFFF0000, style: PaintStyle.stroke); const edited = RectangleComponent(name: 'Player', size: size); + const palette = Palette( + colors: [PaletteColor(name: 'primary', color: 0xFF00FF00)], + ); late ComponentRepository componentRepository; late ProjectRepository projectRepository; @@ -32,6 +35,8 @@ void main() { when( () => projectRepository.getSettings(project), ).thenAnswer((_) async => const ProjectSettings(outputPath: 'lib/game')); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => const Palette()); }); ComponentEditorBloc buildBloc() => ComponentEditorBloc( @@ -342,6 +347,77 @@ void main() { act: (bloc) => bloc.add(const ComponentEditorStarted()), expect: () => const [], ); + + blocTest( + 'loads the palette', + setUp: () { + stubComponents(const []); + stubUpToDate(isUpToDate: true); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => palette); + }, + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorStarted()), + expect: () => const [ + ComponentEditorState( + component: component, + savedComponent: component, + palette: palette, + ), + ], + ); + + blocTest( + 'keeps a palette changed while loading', + setUp: () { + stubComponents(const []); + stubUpToDate(isUpToDate: true); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => const Palette()); + }, + build: buildBloc, + act: (bloc) => bloc + ..add(const ComponentEditorPaletteChanged(palette)) + ..add(const ComponentEditorStarted()), + expect: () => const [ + ComponentEditorState( + component: component, + savedComponent: component, + palette: palette, + ), + ], + ); + + blocTest( + 'keeps an empty palette when it can not be loaded', + setUp: () { + stubComponents(const []); + stubUpToDate(isUpToDate: true); + when(() => componentRepository.getPalette(project.path)) + .thenThrow(Exception()); + }, + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorStarted()), + verify: (bloc) => expect(bloc.state.palette, equals(const Palette())), + ); + }); + + blocTest( + 'ComponentEditorPaletteChanged replaces the palette without changes', + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorPaletteChanged(palette)), + expect: () => const [ + ComponentEditorState( + component: component, + savedComponent: component, + palette: palette, + ), + ], + verify: (bloc) => expect(bloc.state.hasChanges, isFalse), + ); + + test('exposes the project', () { + expect(buildBloc().project, equals(project)); }); group('component tree', () { diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index 3c6208c..c1db08e 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -5,6 +5,7 @@ import 'package:component_repository/component_repository.dart'; import 'package:flame/game.dart'; import 'package:flame_component_editor/component_editor/component_editor.dart'; import 'package:flame_component_editor/component_editor/widgets/widgets.dart'; +import 'package:flame_component_editor/palette/palette.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -38,6 +39,8 @@ void main() { final projectRepository = _MockProjectRepository(); when(() => componentRepository.getComponents(project.path)) .thenAnswer((_) async => const [component]); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => const Palette()); when(() => projectRepository.getSettings(project)) .thenAnswer((_) async => const ProjectSettings()); when( @@ -145,6 +148,61 @@ void main() { expect(toolbar.bottom, lessThanOrEqualTo(preview.top)); }); + testWidgets('top bar sits above the panels', (tester) async { + await tester.pumpApp(buildSubject()); + + final topBar = tester.getRect(find.byType(EditorTopBar)); + final tree = tester.getRect(find.byType(ComponentTreePanel)); + final properties = tester.getRect(find.byType(PropertiesPanel)); + expect(topBar.left, equals(tree.left)); + expect(topBar.right, equals(properties.right)); + expect(topBar.bottom, lessThanOrEqualTo(tree.top)); + }); + + testWidgets('top bar opens the palette and passes on the saved palette', ( + tester, + ) async { + const palette = Palette( + colors: [PaletteColor(name: 'color1', color: 0xFFFFFFFF)], + ); + final componentRepository = _MockComponentRepository(); + final projectRepository = _MockProjectRepository(); + when(() => bloc.project).thenReturn(project); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => const Palette()); + when(() => componentRepository.getComponents(project.path)) + .thenAnswer((_) async => const [component]); + when(() => componentRepository.savePalette(project.path, palette)) + .thenAnswer((_) async {}); + when(() => projectRepository.getSettings(project)) + .thenAnswer((_) async => const ProjectSettings()); + when( + () => componentRepository.writePaletteCode( + project.path, + const ProjectSettings().outputPath, + palette, + ), + ).thenAnswer((_) async {}); + await tester.pumpApp( + buildSubject(), + componentRepository: componentRepository, + projectRepository: projectRepository, + ); + + await tester.tap(find.byTooltip('Palette')); + await tester.pumpRouteTransition(); + expect(find.byType(PaletteView), findsOneWidget); + + await tester.tap(find.text('Add color')); + await tester.pump(); + await tester.tap(find.text('Save')); + await tester.pumpRouteTransition(); + + expect(find.byType(PaletteView), findsNothing); + verify(() => bloc.add(const ComponentEditorPaletteChanged(palette))) + .called(1); + }); + testWidgets('dispatches highlight toggles', (tester) async { await tester.pumpApp(buildSubject()); diff --git a/test/component_editor/widgets/property_editors/paint_editor_test.dart b/test/component_editor/widgets/property_editors/paint_editor_test.dart index 42185dd..cde5110 100644 --- a/test/component_editor/widgets/property_editors/paint_editor_test.dart +++ b/test/component_editor/widgets/property_editors/paint_editor_test.dart @@ -59,5 +59,99 @@ void main() { expect(changes, isEmpty); }); + + group('palette', () { + const palette = Palette( + colors: [ + PaletteColor(name: 'primary', color: 0xFFFF0000), + PaletteColor(name: 'accent', color: 0xFF00FF00), + ], + ); + + Future pumpWithPalette( + WidgetTester tester, { + ComponentPaint value = const ComponentPaint(color: 0xFF112233), + }) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PaintEditor( + value: value, + palette: palette, + onChanged: changes.add, + ), + ), + ), + ); + + testWidgets('has no palette button without colors', (tester) async { + await pumpSubject(tester); + + expect(find.byTooltip('Pick from palette'), findsNothing); + }); + + testWidgets('picks a palette color', (tester) async { + await pumpWithPalette(tester); + + await tester.tap(find.byTooltip('Pick from palette')); + await tester.pumpAndSettle(); + await tester.tap(find.text('accent')); + await tester.pumpAndSettle(); + + expect( + changes, + equals([ + const ComponentPaint(color: 0xFF00FF00, paletteColor: 'accent'), + ]), + ); + }); + + testWidgets('shows the palette color by name, with its value', ( + tester, + ) async { + await pumpWithPalette( + tester, + value: const ComponentPaint( + color: 0xFF112233, + paletteColor: 'primary', + ), + ); + + expect(find.text('primary · #FFFF0000'), findsOneWidget); + + await tester.tap(find.byTooltip('Pick from palette')); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('shows the last value of a missing palette color', ( + tester, + ) async { + await pumpWithPalette( + tester, + value: const ComponentPaint(color: 0xFF112233, paletteColor: 'gone'), + ); + + expect(find.text('#FF112233'), findsOneWidget); + }); + + testWidgets('keeps the palette color when the picker is cancelled', ( + tester, + ) async { + await pumpWithPalette( + tester, + value: const ComponentPaint( + color: 0xFF112233, + paletteColor: 'primary', + ), + ); + + await tester.tap(find.byType(ColorField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(changes, isEmpty); + }); + }); }); } diff --git a/test/palette/bloc/palette_bloc_test.dart b/test/palette/bloc/palette_bloc_test.dart new file mode 100644 index 0000000..a24313e --- /dev/null +++ b/test/palette/bloc/palette_bloc_test.dart @@ -0,0 +1,241 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/palette/palette.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:project_repository/project_repository.dart'; + +class _MockComponentRepository extends Mock implements ComponentRepository; + +class _MockProjectRepository extends Mock implements ProjectRepository; + +void main() { + setUpAll(() => registerFallbackValue(const Palette())); + + group(PaletteBloc, () { + const project = Project(path: '/projects/my_game'); + const primary = PaletteColor(name: 'primary', color: 0xFFFF0000); + const accent = PaletteColor(name: 'accent', color: 0xFF00FF00); + const palette = Palette(colors: [primary, accent]); + // Saved without colors, but uses `primary` in the editor. + const edited = RectangleComponent( + name: 'Player', + paint: ComponentPaint(paletteColor: 'primary'), + ); + const enemy = RectangleComponent( + name: 'Enemy', + paint: ComponentPaint(paletteColor: 'primary'), + ); + const loaded = PaletteState( + status: PaletteStatus.ready, + savedPalette: palette, + usage: { + 'primary': ['Enemy', 'Player'], + }, + entries: [ + PaletteEntry(id: 0, color: primary, savedName: 'primary'), + PaletteEntry(id: 1, color: accent, savedName: 'accent'), + ], + ); + + late ComponentRepository componentRepository; + late ProjectRepository projectRepository; + + setUp(() { + componentRepository = _MockComponentRepository(); + projectRepository = _MockProjectRepository(); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => palette); + when(() => componentRepository.getComponents(project.path)).thenAnswer( + (_) async => const [enemy, RectangleComponent(name: 'Player')], + ); + when( + () => projectRepository.getSettings(project), + ).thenAnswer((_) async => const ProjectSettings(outputPath: 'lib/game')); + when(() => componentRepository.savePalette(any(), any())) + .thenAnswer((_) async {}); + when(() => componentRepository.writePaletteCode(any(), any(), any())) + .thenAnswer((_) async {}); + }); + + PaletteBloc buildBloc() => PaletteBloc( + project: project, + editedComponent: edited, + componentRepository: componentRepository, + projectRepository: projectRepository, + ); + + /// A bloc whose palette was loaded, with entries 0 and 1. + Future loadedBloc() async { + final bloc = buildBloc()..add(const PaletteStarted()); + await bloc.stream.firstWhere((s) => s.status == PaletteStatus.ready); + return bloc; + } + + blocTest( + 'PaletteStarted loads the palette and which components use it, with ' + 'the unsaved changes of the edited component', + build: buildBloc, + act: (bloc) => bloc.add(const PaletteStarted()), + expect: () => const [PaletteState(status: PaletteStatus.loading), loaded], + verify: (bloc) { + expect(bloc.state.palette, equals(palette)); + expect(bloc.state.hasChanges, isFalse); + expect(bloc.state.usersOf(bloc.state.entries[0]), ['Enemy', 'Player']); + expect(bloc.state.usersOf(bloc.state.entries[1]), isEmpty); + }, + ); + + blocTest( + 'PaletteStarted fails when the palette can not be loaded', + setUp: () => + when(() => componentRepository.getPalette(project.path)) + .thenThrow(Exception()), + build: buildBloc, + act: (bloc) => bloc.add(const PaletteStarted()), + expect: () => const [ + PaletteState(status: PaletteStatus.loading), + PaletteState(status: PaletteStatus.failure), + ], + ); + + test('ignores edits before loading', () async { + final bloc = buildBloc() + ..add(const PaletteColorAdded()) + ..add(const PaletteColorRemoved(0)) + ..add(const PaletteColorValueChanged(0, 1)); + await pumpEventQueue(); + + expect(bloc.state, equals(const PaletteState())); + expect(bloc.state.canSave, isFalse); + }); + + test('PaletteColorAdded adds a white color with a unique name', () async { + final bloc = await loadedBloc(); + bloc.add(const PaletteColorAdded()); + await pumpEventQueue(); + + expect( + bloc.state.entries.last, + equals( + const PaletteEntry( + id: 2, + color: PaletteColor(name: 'color1', color: 0xFFFFFFFF), + ), + ), + ); + expect(bloc.state.hasChanges, isTrue); + expect(bloc.state.usersOf(bloc.state.entries.last), isEmpty); + }); + + test('PaletteColorRenamed renames colors that are not used', () async { + final bloc = await loadedBloc(); + bloc + ..add(const PaletteColorRenamed(1, 'highlight')) + ..add(const PaletteColorRenamed(0, 'main')); + await pumpEventQueue(); + + expect( + bloc.state.palette.colors.map((c) => c.name), + equals(['primary', 'highlight']), + ); + }); + + test( + 'PaletteColorRenamed keeps invalid names, which prevent saving', + () async { + final bloc = await loadedBloc(); + bloc.add(const PaletteColorRenamed(1, 'primary')); + await pumpEventQueue(); + + expect(bloc.state.palette.colors.last.name, equals('primary')); + expect(bloc.state.canSave, isFalse); + }, + ); + + test('PaletteColorValueChanged changes any color', () async { + final bloc = await loadedBloc(); + bloc.add(const PaletteColorValueChanged(0, 0xFF0000FF)); + await pumpEventQueue(); + + expect(bloc.state.palette.colorOf('primary'), equals(0xFF0000FF)); + }); + + test('PaletteColorRemoved removes colors that are not used', () async { + final bloc = await loadedBloc(); + bloc + ..add(const PaletteColorRemoved(0)) + ..add(const PaletteColorRemoved(1)); + await pumpEventQueue(); + + expect(bloc.state.palette, equals(const Palette(colors: [primary]))); + }); + + test('PaletteSaveRequested saves the palette and its code', () async { + final bloc = await loadedBloc(); + bloc + ..add(const PaletteColorRenamed(1, 'highlight')) + ..add(const PaletteSaveRequested()); + await bloc.stream.firstWhere((s) => s.status == PaletteStatus.saved); + + const saved = Palette( + colors: [ + primary, + PaletteColor(name: 'highlight', color: 0xFF00FF00), + ], + ); + verify(() => componentRepository.savePalette(project.path, saved)) + .called(1); + verify( + () => componentRepository.writePaletteCode( + project.path, + 'lib/game', + saved, + ), + ).called(1); + expect(bloc.state.savedPalette, equals(saved)); + expect(bloc.state.entries.last.savedName, equals('highlight')); + expect(bloc.state.hasChanges, isFalse); + }); + + test('PaletteSaveRequested does nothing with invalid names', () async { + final bloc = await loadedBloc(); + bloc + ..add(const PaletteColorRenamed(1, 'class')) + ..add(const PaletteSaveRequested()); + await pumpEventQueue(); + + verifyNever(() => componentRepository.savePalette(any(), any())); + }); + + for (final (exception, status) in [ + (const GeneratedFileConflictException('x'), PaletteStatus.codeConflict), + (Exception(), PaletteStatus.codeFailure), + ]) { + test('PaletteSaveRequested reports $status when the code is not ' + 'generated', () async { + when(() => componentRepository.writePaletteCode(any(), any(), any())) + .thenThrow(exception); + final bloc = await loadedBloc(); + bloc.add(const PaletteSaveRequested()); + await pumpEventQueue(); + + expect(bloc.state.status, equals(status)); + expect(bloc.state.status.isSaved, isTrue); + }); + } + + test('PaletteSaveRequested fails when the palette is not saved', () async { + when(() => componentRepository.savePalette(any(), any())) + .thenThrow(Exception()); + final bloc = await loadedBloc(); + bloc.add(const PaletteSaveRequested()); + await pumpEventQueue(); + + expect(bloc.state.status, equals(PaletteStatus.failure)); + verifyNever( + () => componentRepository.writePaletteCode(any(), any(), any()), + ); + }); + }); +} diff --git a/test/palette/view/palette_dialog_test.dart b/test/palette/view/palette_dialog_test.dart new file mode 100644 index 0000000..d4e2df3 --- /dev/null +++ b/test/palette/view/palette_dialog_test.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/palette/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:project_repository/project_repository.dart'; + +import '../../helpers/helpers.dart'; + +class _MockComponentRepository extends Mock implements ComponentRepository; + +class _MockPaletteBloc extends MockBloc + implements PaletteBloc; + +void main() { + const project = Project(path: '/projects/my_game'); + const component = RectangleComponent(name: 'Player'); + const primary = PaletteColor(name: 'primary', color: 0xFFFF0000); + const accent = PaletteColor(name: 'accent', color: 0xFF00FF00); + + setUpAll(() => registerFallbackValue(const PaletteStarted())); + + group(PaletteDialog, () { + testWidgets('show opens the dialog with the stored palette', ( + tester, + ) async { + final componentRepository = _MockComponentRepository(); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => const Palette(colors: [primary])); + when(() => componentRepository.getComponents(project.path)) + .thenAnswer((_) async => const []); + + await tester.pumpApp( + Builder( + builder: (context) => TextButton( + onPressed: () => PaletteDialog.show( + context, + project: project, + component: component, + ), + child: const Text('open'), + ), + ), + componentRepository: componentRepository, + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.byType(PaletteView), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'primary'), findsOneWidget); + }); + }); + + group(PaletteView, () { + const ready = PaletteState( + status: PaletteStatus.ready, + savedPalette: Palette(colors: [primary, accent]), + usage: { + 'primary': ['Enemy', 'Player'], + }, + entries: [ + PaletteEntry(id: 0, color: primary, savedName: 'primary'), + PaletteEntry(id: 1, color: accent, savedName: 'accent'), + ], + ); + + late PaletteBloc bloc; + + setUp(() { + bloc = _MockPaletteBloc(); + when(() => bloc.state).thenReturn(ready); + }); + + Widget buildSubject() => Scaffold( + body: BlocProvider.value(value: bloc, child: const PaletteView()), + ); + + testWidgets('shows a progress indicator while loading', (tester) async { + when(() => bloc.state) + .thenReturn(const PaletteState(status: PaletteStatus.loading)); + await tester.pumpApp(buildSubject()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('explains an empty palette', (tester) async { + when(() => bloc.state) + .thenReturn(const PaletteState(status: PaletteStatus.ready)); + await tester.pumpApp(buildSubject()); + + expect(find.textContaining('No colors yet'), findsOneWidget); + }); + + testWidgets('locks the colors used by components', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(find.text('Used by Enemy, Player'), findsOneWidget); + final fields = tester + .widgetList(find.byType(TextField)) + .toList(); + expect(fields[0].readOnly, isTrue); + expect(fields[1].readOnly, isFalse); + + final removeButtons = tester + .widgetList( + find.widgetWithIcon(IconButton, Icons.delete_outline), + ) + .toList(); + expect(removeButtons[0].onPressed, isNull); + expect(removeButtons[1].onPressed, isNotNull); + }); + + testWidgets('dispatches edits', (tester) async { + await tester.pumpApp(buildSubject()); + + await tester.enterText(find.byType(TextField).last, 'highlight'); + await tester.tap( + find.widgetWithIcon(IconButton, Icons.delete_outline).last, + ); + await tester.tap(find.text('Add color')); + await tester.tap(find.text('Save')); + + verify(() => bloc.add(const PaletteColorRenamed(1, 'highlight'))) + .called(1); + verify(() => bloc.add(const PaletteColorRemoved(1))).called(1); + verify(() => bloc.add(const PaletteColorAdded())).called(1); + verify(() => bloc.add(const PaletteSaveRequested())).called(1); + }); + + testWidgets('shows name errors and disables saving', (tester) async { + when(() => bloc.state).thenReturn( + ready.copyWith( + entries: const [ + PaletteEntry(id: 0, color: primary, savedName: 'primary'), + PaletteEntry(id: 1, color: PaletteColor(name: 'primary', color: 0)), + PaletteEntry(id: 2, color: PaletteColor(name: '1st', color: 0)), + ], + ), + ); + await tester.pumpApp(buildSubject()); + + expect(find.text('Another color has this name'), findsOneWidget); + expect(find.text('Must be a valid Dart identifier'), findsOneWidget); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('shows a failure', (tester) async { + when(() => bloc.state) + .thenReturn(ready.copyWith(status: PaletteStatus.failure)); + await tester.pumpApp(buildSubject()); + + expect(find.text('Something went wrong'), findsOneWidget); + }); + + for (final (status, message) in [ + (PaletteStatus.saved, null), + (PaletteStatus.codeConflict, 'not generated by the editor'), + (PaletteStatus.codeFailure, 'could not be generated'), + ]) { + testWidgets('pops with the palette when $status', (tester) async { + final saved = ready.copyWith(status: status); + whenListen(bloc, Stream.value(saved), initialState: ready); + Palette? result; + + await tester.pumpApp( + Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async => result = await showDialog( + context: context, + builder: (_) => BlocProvider.value( + value: bloc, + child: const PaletteView(), + ), + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.byType(PaletteView), findsNothing); + expect(result, equals(saved.palette)); + if (message != null) { + expect(find.textContaining(message), findsOneWidget); + } + }); + } + + testWidgets('cancel pops without a palette', (tester) async { + Palette? result = const Palette(colors: [primary]); + await tester.pumpApp( + Builder( + builder: (context) => TextButton( + onPressed: () async => result = await showDialog( + context: context, + builder: (_) => + BlocProvider.value(value: bloc, child: const PaletteView()), + ), + child: const Text('open'), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + }); +} diff --git a/test/project/view/project_page_test.dart b/test/project/view/project_page_test.dart index 42ca42f..703d7b2 100644 --- a/test/project/view/project_page_test.dart +++ b/test/project/view/project_page_test.dart @@ -79,6 +79,8 @@ void main() { projectRepository = _MockProjectRepository(); when(() => componentRepository.getComponents(project.path)) .thenAnswer((_) async => [player]); + when(() => componentRepository.getPalette(project.path)) + .thenAnswer((_) async => const Palette()); when(() => projectRepository.getSettings(project)) .thenAnswer((_) async => const ProjectSettings()); when( From be213a7a59be37a6107c48d2f8d66e41a8168d6b Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Tue, 20 Oct 2026 09:57:51 -0300 Subject: [PATCH 2/2] fix lint --- .../bloc/component_editor_bloc.dart | 3 --- .../view/component_editor_page.dart | 2 +- .../view/component_editor_view.dart | 14 +++++++++----- lib/component_editor/widgets/editor_top_bar.dart | 8 ++++++-- .../bloc/component_editor_bloc_test.dart | 4 ---- .../view/component_editor_page_test.dart | 9 +++++---- 6 files changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index a2afb2c..5997a2e 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -48,9 +48,6 @@ class ComponentEditorBloc final Project _project; - /// The project the component belongs to. - Project get project => _project; - final ComponentRepository _componentRepository; final ProjectRepository _projectRepository; diff --git a/lib/component_editor/view/component_editor_page.dart b/lib/component_editor/view/component_editor_page.dart index 33ec388..899c2c6 100644 --- a/lib/component_editor/view/component_editor_page.dart +++ b/lib/component_editor/view/component_editor_page.dart @@ -35,7 +35,7 @@ class ComponentEditorPage extends StatelessWidget { componentRepository: context.read(), projectRepository: context.read(), )..add(const ComponentEditorStarted()), - child: const ComponentEditorView(), + child: ComponentEditorView(project: project), ); } } diff --git a/lib/component_editor/view/component_editor_view.dart b/lib/component_editor/view/component_editor_view.dart index 49aeca8..80bad6f 100644 --- a/lib/component_editor/view/component_editor_view.dart +++ b/lib/component_editor/view/component_editor_view.dart @@ -6,6 +6,7 @@ import 'package:flame_component_editor/component_editor/widgets/widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:project_repository/project_repository.dart'; /// {@template component_editor_view} /// Shows the tree of the component being edited on the left, its preview @@ -17,7 +18,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// {@endtemplate} class ComponentEditorView extends StatelessWidget { /// {@macro component_editor_view} - const new({super.key}); + const new({required this.project, super.key}); + + /// The project the component belongs to. + final Project project; /// Width of the component tree panel. static const treePanelWidth = 240.0; @@ -63,12 +67,12 @@ class ComponentEditorView extends StatelessWidget { title: const _Title(), actions: [_SaveButton(onPressed: save)], ), - body: const Column( + body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - EditorTopBar(), - Divider(height: 1), - Expanded( + EditorTopBar(project: project), + const Divider(height: 1), + const Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/component_editor/widgets/editor_top_bar.dart b/lib/component_editor/widgets/editor_top_bar.dart index 4e39848..aa1381f 100644 --- a/lib/component_editor/widgets/editor_top_bar.dart +++ b/lib/component_editor/widgets/editor_top_bar.dart @@ -2,6 +2,7 @@ import 'package:flame_component_editor/component_editor/bloc/component_editor_bl import 'package:flame_component_editor/palette/palette.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:project_repository/project_repository.dart'; /// {@template editor_top_bar} /// The bar below the app bar, above the editor panels, with the actions @@ -9,7 +10,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// {@endtemplate} class EditorTopBar extends StatelessWidget { /// {@macro editor_top_bar} - const new({super.key}); + const new({required this.project, super.key}); + + /// The project the edited component belongs to. + final Project project; /// Height of the bar. static const height = 40.0; @@ -18,7 +22,7 @@ class EditorTopBar extends StatelessWidget { final bloc = context.read(); final palette = await PaletteDialog.show( context, - project: bloc.project, + project: project, component: bloc.state.component, ); // The editor may have been closed meanwhile. diff --git a/test/component_editor/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index 3184a83..5677410 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -416,10 +416,6 @@ void main() { verify: (bloc) => expect(bloc.state.hasChanges, isFalse), ); - test('exposes the project', () { - expect(buildBloc().project, equals(project)); - }); - group('component tree', () { const child = RectangleComponent(name: 'rectangle1'); const tree = RectangleComponent(name: 'Player', children: [child]); diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index c1db08e..5ceb4de 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -85,8 +85,10 @@ void main() { ); }); - Widget buildSubject() => - BlocProvider.value(value: bloc, child: const ComponentEditorView()); + Widget buildSubject() => BlocProvider.value( + value: bloc, + child: const ComponentEditorView(project: project), + ); testWidgets('shows the preview and the property editors', (tester) async { await tester.pumpApp(buildSubject()); @@ -167,7 +169,6 @@ void main() { ); final componentRepository = _MockComponentRepository(); final projectRepository = _MockProjectRepository(); - when(() => bloc.project).thenReturn(project); when(() => componentRepository.getPalette(project.path)) .thenAnswer((_) async => const Palette()); when(() => componentRepository.getComponents(project.path)) @@ -724,7 +725,7 @@ void main() { MaterialPageRoute( builder: (_) => BlocProvider.value( value: bloc, - child: const ComponentEditorView(), + child: const ComponentEditorView(project: project), ), ), ),