From 19555f17564f542a22e2ff14849a4f9f765cf9e3 Mon Sep 17 00:00:00 2001 From: Stefan Knipfer Date: Tue, 7 Jul 2026 13:50:03 +0200 Subject: [PATCH 1/3] [PAINTROID-811] Save project on back --- lib/core/providers/object/io_handler.dart | 122 ++++++++++++++++-- lib/core/providers/state/workspace_state.dart | 2 + .../state/workspace_state.freezed.dart | 35 ++++- .../state/workspace_state_notifier.dart | 5 + lib/ui/pages/landing_page/landing_page.dart | 10 +- .../components/top_bar/overflow_menu.dart | 99 +------------- .../pages/workspace_page/workspace_page.dart | 17 +-- test/integration/landing_page_test.dart | 64 +++++++++ 8 files changed, 221 insertions(+), 133 deletions(-) diff --git a/lib/core/providers/object/io_handler.dart b/lib/core/providers/object/io_handler.dart index 83f873d1..718597d8 100644 --- a/lib/core/providers/object/io_handler.dart +++ b/lib/core/providers/object/io_handler.dart @@ -27,6 +27,11 @@ import 'package:paintroid/ui/shared/dialogs/load_image_dialog.dart'; import 'package:paintroid/ui/shared/dialogs/save_image_dialog.dart'; import 'package:paintroid/ui/utils/toast_utils.dart'; +import 'package:toast/toast.dart'; +import 'package:paintroid/core/database/project_database.dart'; +import 'package:paintroid/ui/shared/dialogs/overwrite_dialog.dart'; +import 'package:paintroid/core/models/database/project.dart'; + class IOHandler { final Ref ref; @@ -44,22 +49,60 @@ class IOHandler { final isFileSaved = await workspaceStateNotifier .performIOTask(() => _saveImageWith(imageMetaData)); - if (!isFileSaved) { - workspaceStateNotifier.markUnsavedChanges(); + return isFileSaved; + } + + Future saveProject(BuildContext context) async { + ImageMetaData? imageData; + + final project = ref.read( + workspaceStateProvider.select((state) => state.loadedProject), + ); + + + if (project == null || project.name == '') { + imageData = await showSaveImageDialog(context, true); } else { - workspaceStateNotifier.updateLastSavedCommandCount(); + imageData = CatrobatImageMetaData(project.name); } - return isFileSaved; - } + if (imageData == null) { + return false; + } + + final catrobatImageData = imageData as CatrobatImageMetaData; + + final db = await ref.read(ProjectDatabase.provider.future); + if (!context.mounted) return false; + + if ((project == null || project.name == '') && !await _checkIfFileExistsAndConfirmOverwrite(context, catrobatImageData, db)) { + return false; + } - Future saveProject(ImageMetaData imageMetaData) async { - if (imageMetaData is! CatrobatImageMetaData) return null; final workspaceStateNotifier = ref.read(workspaceStateProvider.notifier); - final savedFile = await workspaceStateNotifier - .performIOTask(() => _saveAsCatrobatImage(imageMetaData, true)); - if (savedFile != null) workspaceStateNotifier.updateLastSavedCommandCount(); - return savedFile; + final savedProject = await workspaceStateNotifier + .performIOTask(() => _saveAsCatrobatImage(catrobatImageData, true)); + if (savedProject != null) { + String? imagePreview = + await getPreviewPath(catrobatImageData); + if (project == null || project.name == '') { + Project projectNew = Project( + name: catrobatImageData.name, + path: savedProject.path, + lastModified: DateTime.now(), + creationDate: DateTime.now(), + resolution: '', + format: catrobatImageData.format.name, + size: await savedProject.length(), + imagePreviewPath: imagePreview, + ); + + await db.projectDAO.insertProject(projectNew); + } + workspaceStateNotifier.updateLastSavedCommandCount(); + return true; + } + return false; } /// Returns [true] if - @@ -72,7 +115,8 @@ class IOHandler { if (shouldDiscard == null || !state.mounted) return false; if (!shouldDiscard) { if (!context.mounted) return false; - final didSave = await saveImage(context); + + final didSave = await saveProject(context); if (!didSave) return false; } } @@ -81,7 +125,7 @@ class IOHandler { /// Returns [true] if the image was loaded successfully Future loadImage( - BuildContext context, State state, bool unsavedChanges) async { + BuildContext context, State state, { bool unsavedChanges = true }) async { if (unsavedChanges) { final shouldContinue = await handleUnsavedChanges(context, state); if (!shouldContinue) return false; @@ -105,6 +149,7 @@ class IOHandler { Future newImage(BuildContext context, State state) async { final shouldContinue = await handleUnsavedChanges(context, state); if (!shouldContinue) return false; + ref.read(workspaceStateProvider.notifier).updateProject(); ref.read(canvasStateProvider.notifier) ..clearBackgroundImageAndResetDimensions() ..resetCanvasWithNewCommands([]); @@ -254,4 +299,55 @@ class IOHandler { }, ); } + + Future _showOverwriteDialog(BuildContext context) async { + return await showOverwriteDialog(context) ?? false; + } + + Future _deleteFileAndAssociatedProject(CatrobatImageMetaData imageData, + ProjectDatabase db, IFileService fileService) async { + final fileName = '${imageData.name}.${imageData.format.extension}'; + + final result = await fileService.deleteFileInApplicationDirectory(fileName); + if (result is Err) { + Toast.show( + 'Could not delete the file while overwriting!', + duration: Toast.lengthShort, + gravity: Toast.bottom, + ); + return false; + } + + final oldProject = await db.projectDAO.getProjectByName(imageData.name); + final oldProjectId = oldProject?.id; + if (oldProject != null && oldProjectId != null) { + await db.projectDAO.deleteProject(oldProjectId); + ref.invalidate(ProjectDatabase.provider); + } + + return true; + } + + Future _checkIfFileExistsAndConfirmOverwrite( + BuildContext context, CatrobatImageMetaData imageData, ProjectDatabase db) async { + final fileService = ref.watch(IFileService.provider); + final fileName = '${imageData.name}.${imageData.format.extension}'; + final fileExists = + await fileService.checkIfFileExistsInApplicationDirectory(fileName); + + if (context.mounted && fileExists) { + final overWriteCanceled = await _showOverwriteDialog(context); + if (overWriteCanceled) { + Toast.show( + 'Project not saved!', + duration: Toast.lengthShort, + gravity: Toast.bottom, + ); + return false; + } + return await _deleteFileAndAssociatedProject(imageData, db, fileService); + } + + return true; + } } diff --git a/lib/core/providers/state/workspace_state.dart b/lib/core/providers/state/workspace_state.dart index d80a09ea..826a2db7 100644 --- a/lib/core/providers/state/workspace_state.dart +++ b/lib/core/providers/state/workspace_state.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:paintroid/core/models/database/project.dart'; part 'workspace_state.freezed.dart'; @@ -10,5 +11,6 @@ class WorkspaceState with _$WorkspaceState { required bool isPerformingIOTask, required bool hasUnsavedChanges, required int commandCountWhenLastSaved, + Project? loadedProject }) = _WorkspaceState; } diff --git a/lib/core/providers/state/workspace_state.freezed.dart b/lib/core/providers/state/workspace_state.freezed.dart index 34a1f646..4938b38c 100644 --- a/lib/core/providers/state/workspace_state.freezed.dart +++ b/lib/core/providers/state/workspace_state.freezed.dart @@ -20,6 +20,7 @@ mixin _$WorkspaceState { bool get isPerformingIOTask => throw _privateConstructorUsedError; bool get hasUnsavedChanges => throw _privateConstructorUsedError; int get commandCountWhenLastSaved => throw _privateConstructorUsedError; + Project? get loadedProject => throw _privateConstructorUsedError; @JsonKey(ignore: true) $WorkspaceStateCopyWith get copyWith => @@ -36,7 +37,8 @@ abstract class $WorkspaceStateCopyWith<$Res> { {bool isFullscreen, bool isPerformingIOTask, bool hasUnsavedChanges, - int commandCountWhenLastSaved}); + int commandCountWhenLastSaved, + Project? loadedProject}); } /// @nodoc @@ -56,6 +58,7 @@ class _$WorkspaceStateCopyWithImpl<$Res, $Val extends WorkspaceState> Object? isPerformingIOTask = null, Object? hasUnsavedChanges = null, Object? commandCountWhenLastSaved = null, + Object? loadedProject = freezed, }) { return _then(_value.copyWith( isFullscreen: null == isFullscreen @@ -74,6 +77,10 @@ class _$WorkspaceStateCopyWithImpl<$Res, $Val extends WorkspaceState> ? _value.commandCountWhenLastSaved : commandCountWhenLastSaved // ignore: cast_nullable_to_non_nullable as int, + loadedProject: freezed == loadedProject + ? _value.loadedProject + : loadedProject // ignore: cast_nullable_to_non_nullable + as Project?, ) as $Val); } } @@ -90,7 +97,8 @@ abstract class _$$WorkspaceStateImplCopyWith<$Res> {bool isFullscreen, bool isPerformingIOTask, bool hasUnsavedChanges, - int commandCountWhenLastSaved}); + int commandCountWhenLastSaved, + Project? loadedProject}); } /// @nodoc @@ -108,6 +116,7 @@ class __$$WorkspaceStateImplCopyWithImpl<$Res> Object? isPerformingIOTask = null, Object? hasUnsavedChanges = null, Object? commandCountWhenLastSaved = null, + Object? loadedProject = freezed, }) { return _then(_$WorkspaceStateImpl( isFullscreen: null == isFullscreen @@ -126,6 +135,10 @@ class __$$WorkspaceStateImplCopyWithImpl<$Res> ? _value.commandCountWhenLastSaved : commandCountWhenLastSaved // ignore: cast_nullable_to_non_nullable as int, + loadedProject: freezed == loadedProject + ? _value.loadedProject + : loadedProject // ignore: cast_nullable_to_non_nullable + as Project?, )); } } @@ -137,7 +150,8 @@ class _$WorkspaceStateImpl implements _WorkspaceState { {required this.isFullscreen, required this.isPerformingIOTask, required this.hasUnsavedChanges, - required this.commandCountWhenLastSaved}); + required this.commandCountWhenLastSaved, + this.loadedProject}); @override final bool isFullscreen; @@ -147,10 +161,12 @@ class _$WorkspaceStateImpl implements _WorkspaceState { final bool hasUnsavedChanges; @override final int commandCountWhenLastSaved; + @override + final Project? loadedProject; @override String toString() { - return 'WorkspaceState(isFullscreen: $isFullscreen, isPerformingIOTask: $isPerformingIOTask, hasUnsavedChanges: $hasUnsavedChanges, commandCountWhenLastSaved: $commandCountWhenLastSaved)'; + return 'WorkspaceState(isFullscreen: $isFullscreen, isPerformingIOTask: $isPerformingIOTask, hasUnsavedChanges: $hasUnsavedChanges, commandCountWhenLastSaved: $commandCountWhenLastSaved, loadedProject: $loadedProject)'; } @override @@ -166,12 +182,14 @@ class _$WorkspaceStateImpl implements _WorkspaceState { other.hasUnsavedChanges == hasUnsavedChanges) && (identical(other.commandCountWhenLastSaved, commandCountWhenLastSaved) || - other.commandCountWhenLastSaved == commandCountWhenLastSaved)); + other.commandCountWhenLastSaved == commandCountWhenLastSaved) && + (identical(other.loadedProject, loadedProject) || + other.loadedProject == loadedProject)); } @override int get hashCode => Object.hash(runtimeType, isFullscreen, isPerformingIOTask, - hasUnsavedChanges, commandCountWhenLastSaved); + hasUnsavedChanges, commandCountWhenLastSaved, loadedProject); @JsonKey(ignore: true) @override @@ -186,7 +204,8 @@ abstract class _WorkspaceState implements WorkspaceState { {required final bool isFullscreen, required final bool isPerformingIOTask, required final bool hasUnsavedChanges, - required final int commandCountWhenLastSaved}) = _$WorkspaceStateImpl; + required final int commandCountWhenLastSaved, + final Project? loadedProject}) = _$WorkspaceStateImpl; @override bool get isFullscreen; @@ -197,6 +216,8 @@ abstract class _WorkspaceState implements WorkspaceState { @override int get commandCountWhenLastSaved; @override + Project? get loadedProject; + @override @JsonKey(ignore: true) _$$WorkspaceStateImplCopyWith<_$WorkspaceStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/core/providers/state/workspace_state_notifier.dart b/lib/core/providers/state/workspace_state_notifier.dart index b44c4a0f..88f84d83 100644 --- a/lib/core/providers/state/workspace_state_notifier.dart +++ b/lib/core/providers/state/workspace_state_notifier.dart @@ -1,6 +1,7 @@ import 'package:paintroid/core/commands/command_manager/command_manager_provider.dart'; import 'package:paintroid/core/providers/state/workspace_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:paintroid/core/models/database/project.dart'; part 'workspace_state_notifier.g.dart'; @@ -26,6 +27,10 @@ class WorkspaceStateProvider extends _$WorkspaceStateProvider { state = state.copyWith(hasUnsavedChanges: true); } + void updateProject({ Project? loadProject }) { + state = state.copyWith(loadedProject: loadProject); + } + void updateLastSavedCommandCount() { state = state.copyWith(hasUnsavedChanges: false); state = state.copyWith( diff --git a/lib/ui/pages/landing_page/landing_page.dart b/lib/ui/pages/landing_page/landing_page.dart index 55845989..2af28c13 100644 --- a/lib/ui/pages/landing_page/landing_page.dart +++ b/lib/ui/pages/landing_page/landing_page.dart @@ -91,6 +91,7 @@ class _LandingPageState extends ConsumerState { } }); } + Future> _getProjects() async { return database.projectDAO.getProjects(); } @@ -117,6 +118,7 @@ class _LandingPageState extends ConsumerState { } void _clearCanvas() { + ref.read(workspaceStateProvider.notifier).updateProject(); ref.read(canvasStateProvider.notifier) ..clearBackgroundImageAndResetDimensions() ..resetCanvasWithNewCommands([]); @@ -128,7 +130,10 @@ class _LandingPageState extends ConsumerState { ref.read(workspaceStateProvider.notifier).performIOTask(() async { await ref.read(IDeviceService.sizeProvider.future); bool loaded = await _loadProject(ioHandler, project); - if (loaded) _navigateToPocketPaint(); + if (loaded) { + ref.read(workspaceStateProvider.notifier).updateProject(loadProject: project); + _navigateToPocketPaint(); + } }); } } @@ -235,8 +240,9 @@ class _LandingPageState extends ConsumerState { icon: Icons.file_download, hint: 'Load image', onPressed: () async { + _clearCanvas(); final bool imageLoaded = - await ioHandler.loadImage(context, this, false); + await ioHandler.loadImage(context, this, unsavedChanges: false); if (imageLoaded && mounted) { _navigateToPocketPaint(); } diff --git a/lib/ui/pages/workspace_page/components/top_bar/overflow_menu.dart b/lib/ui/pages/workspace_page/components/top_bar/overflow_menu.dart index ef2e7eab..d2a8e767 100644 --- a/lib/ui/pages/workspace_page/components/top_bar/overflow_menu.dart +++ b/lib/ui/pages/workspace_page/components/top_bar/overflow_menu.dart @@ -1,18 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:oxidized/oxidized.dart'; -import 'package:toast/toast.dart'; - -import 'package:paintroid/core/database/project_database.dart'; import 'package:paintroid/core/localization/app_localizations.dart'; -import 'package:paintroid/core/models/database/project.dart'; -import 'package:paintroid/core/models/image_meta_data.dart'; -import 'package:paintroid/core/providers/object/file_service.dart'; import 'package:paintroid/core/providers/object/io_handler.dart'; import 'package:paintroid/core/providers/state/workspace_state_notifier.dart'; -import 'package:paintroid/ui/shared/dialogs/overwrite_dialog.dart'; -import 'package:paintroid/ui/shared/dialogs/save_image_dialog.dart'; import 'package:paintroid/ui/shared/pop_menu_button.dart'; import 'package:paintroid/ui/theme/theme.dart'; @@ -75,10 +66,10 @@ class _OverflowMenuState extends ConsumerState { ioHandler.saveImage(context); break; case OverflowMenuOption.saveProject: - _saveProject(); + ioHandler.saveProject(context); break; case OverflowMenuOption.loadImage: - ioHandler.loadImage(context, this, true); + ioHandler.loadImage(context, this); break; case OverflowMenuOption.newImage: ioHandler.newImage(context, this); @@ -89,90 +80,4 @@ class _OverflowMenuState extends ConsumerState { void _enterFullscreen() => ref.read(workspaceStateProvider.notifier).toggleFullscreen(true); - Future _showOverwriteDialog() async { - return await showOverwriteDialog(context) ?? false; - } - - Future _deleteFileAndAssociatedProject(CatrobatImageMetaData imageData, - ProjectDatabase db, IFileService fileService) async { - final fileName = '${imageData.name}.${imageData.format.extension}'; - - final result = await fileService.deleteFileInApplicationDirectory(fileName); - if (result is Err) { - Toast.show( - 'Could not delete the file while overwriting!', - duration: Toast.lengthShort, - gravity: Toast.bottom, - ); - return false; - } - - final oldProject = await db.projectDAO.getProjectByName(imageData.name); - final oldProjectId = oldProject?.id; - if (oldProject != null && oldProjectId != null) { - await db.projectDAO.deleteProject(oldProjectId); - ref.invalidate(ProjectDatabase.provider); - } - - return true; - } - - Future _checkIfFileExistsAndConfirmOverwrite( - CatrobatImageMetaData imageData, ProjectDatabase db) async { - final fileService = ref.watch(IFileService.provider); - final fileName = '${imageData.name}.${imageData.format.extension}'; - final fileExists = - await fileService.checkIfFileExistsInApplicationDirectory(fileName); - - if (fileExists) { - final overWriteCanceled = await _showOverwriteDialog(); - if (overWriteCanceled) { - Toast.show( - 'Project not saved!', - duration: Toast.lengthShort, - gravity: Toast.bottom, - ); - return false; - } - return await _deleteFileAndAssociatedProject(imageData, db, fileService); - } - - return true; - } - - Future _saveProject() async { - final imageData = await showSaveImageDialog(context, true); - - if (imageData == null) { - return; - } - - final catrobatImageData = imageData as CatrobatImageMetaData; - - final db = await ref.read(ProjectDatabase.provider.future); - - if (!await _checkIfFileExistsAndConfirmOverwrite(catrobatImageData, db)) { - return; - } - - if (mounted) { - final savedProject = await ioHandler.saveProject(catrobatImageData); - if (savedProject != null) { - String? imagePreview = - await ioHandler.getPreviewPath(catrobatImageData); - Project projectNew = Project( - name: catrobatImageData.name, - path: savedProject.path, - lastModified: DateTime.now(), - creationDate: DateTime.now(), - resolution: '', - format: catrobatImageData.format.name, - size: await savedProject.length(), - imagePreviewPath: imagePreview, - ); - - await db.projectDAO.insertProject(projectNew); - } - } - } } diff --git a/lib/ui/pages/workspace_page/workspace_page.dart b/lib/ui/pages/workspace_page/workspace_page.dart index 376b451a..97ab0c75 100644 --- a/lib/ui/pages/workspace_page/workspace_page.dart +++ b/lib/ui/pages/workspace_page/workspace_page.dart @@ -8,7 +8,6 @@ import 'package:paintroid/ui/pages/workspace_page/components/bottom_bar/tool_opt import 'package:paintroid/ui/pages/workspace_page/components/drawing_surface/drawing_canvas.dart'; import 'package:paintroid/ui/pages/workspace_page/components/drawing_surface/exit_fullscreen_button.dart'; import 'package:paintroid/ui/pages/workspace_page/components/top_bar/top_app_bar.dart'; -import 'package:paintroid/ui/shared/dialogs/discard_changes_dialog.dart'; import 'package:toast/toast.dart'; class WorkspacePage extends ConsumerStatefulWidget { @@ -49,21 +48,11 @@ class _WorkspaceScreenState extends ConsumerState { workspaceStateNotifier.toggleFullscreen(false); return; } - if (!workspaceStateNotifier.hasSavedLastWork) { - final shouldDiscard = await showDiscardChangesDialog(context); - if (shouldDiscard == null) { - return; - } + final shouldContinue = await ioHandler.handleUnsavedChanges(context, this); - if (!shouldDiscard && context.mounted) { - bool savedImage = await ioHandler.saveImage(context); - if (!savedImage) { - return; - } - } - } - if (!context.mounted) return; + + if (!context.mounted || !shouldContinue) return; Navigator.pop(context); }, child: Scaffold( diff --git a/test/integration/landing_page_test.dart b/test/integration/landing_page_test.dart index 404f3543..cb1444f8 100644 --- a/test/integration/landing_page_test.dart +++ b/test/integration/landing_page_test.dart @@ -26,8 +26,12 @@ import 'package:paintroid/ui/pages/workspace_page/components/top_bar/top_app_bar import 'package:paintroid/ui/shared/dialogs/about_dialog.dart'; import 'package:paintroid/ui/shared/dialogs/generic_dialog.dart'; import 'package:paintroid/ui/shared/dialogs/project_details_dialog.dart'; +import 'package:paintroid/ui/shared/dialogs/save_image_dialog.dart'; import 'landing_page_test.mocks.dart'; +import '../utils/ui_interaction.dart'; +import '../utils/canvas_positions.dart'; +import 'package:paintroid/core/tools/tool_data.dart'; @GenerateMocks( [ProjectDatabase, ProjectDAO, IImageService, IFileService, IDeviceService]) @@ -481,4 +485,64 @@ void main() { expect(find.byType(Scaffold), findsOneWidget); }); } + + if (testID == -1 || testID == 15) { + testWidgets( + '[LANDING_PAGE]: Should show save project with new project and unsaved changes on return', + (WidgetTester tester) async { + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await tester.pumpAndSettle(); + + await tester.pumpAndSettle(); + await UIInteraction.selectTool(ToolData.BRUSH.name); + await UIInteraction.tapAt(CanvasPosition.center); + await tester.pumpAndSettle(); + + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Save')); + await tester.pumpAndSettle(); + + expect(find.byType(SaveImageDialog), findsOneWidget); + }); + } + + if (testID == -1 || testID == 16) { + testWidgets( + '[LANDING_PAGE]: Should show overwrite confirmation for existing project name on return', + (WidgetTester tester) async { + const projectName = 'existing-project'; + final existingProject = createTestProject('$projectName.catrobat-image'); + + when(mockDao.getProjects()) + .thenAnswer((_) => Future.value([existingProject])); + when(mockFileService.checkIfFileExistsInApplicationDirectory( + '$projectName.catrobat-image')) + .thenAnswer((_) => Future.value(true)); + + UIInteraction.initialize(tester); + await tester.pumpWidget(sut); + await UIInteraction.createNewImage(); + await tester.pumpAndSettle(); + + await UIInteraction.selectTool(ToolData.BRUSH.name); + await UIInteraction.tapAt(CanvasPosition.center); + await tester.pumpAndSettle(); + + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Save')); + await tester.pumpAndSettle(); + + final projectNameField = + find.widgetWithText(TextFormField, 'Project name'); + await tester.enterText(projectNameField, projectName); + await tester.tap(find.widgetWithText(TextButton, 'Save')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(GenericDialog, 'Overwrite'), findsOneWidget); + }); + } } From c19b93663dc3ffef77dd7c9c8458070daca72bf1 Mon Sep 17 00:00:00 2001 From: Stefan Knipfer Date: Thu, 23 Jul 2026 12:44:04 +0200 Subject: [PATCH 2/3] [PAINTROID-812] Extended diff check with existing variable --- lib/core/commands/command_manager/command_manager.dart | 9 +++++++-- .../command_manager/command_manager_provider.dart | 7 ++++++- lib/core/providers/object/io_handler.dart | 2 +- test/integration/app_workflow_test.dart | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/core/commands/command_manager/command_manager.dart b/lib/core/commands/command_manager/command_manager.dart index dc3a00e5..32e82308 100644 --- a/lib/core/commands/command_manager/command_manager.dart +++ b/lib/core/commands/command_manager/command_manager.dart @@ -19,11 +19,15 @@ import 'package:paintroid/core/commands/command_implementation/graphic/color_cha enum ActionType { UNDO, REDO } class CommandManager { - CommandManager(); - + CommandManager({ + this.onUndo, + }); + final List _undoStack = []; final List _redoStack = []; + final VoidCallback? onUndo; + void addGraphicCommand(GraphicCommand command) { _undoStack.add(command); } @@ -92,6 +96,7 @@ class CommandManager { void undo() { final lastCommand = _undoStack.removeLast(); _redoStack.add(lastCommand); + onUndo?.call(); } List get redoStack => _redoStack; diff --git a/lib/core/commands/command_manager/command_manager_provider.dart b/lib/core/commands/command_manager/command_manager_provider.dart index ca249703..ca0df4c7 100644 --- a/lib/core/commands/command_manager/command_manager_provider.dart +++ b/lib/core/commands/command_manager/command_manager_provider.dart @@ -2,6 +2,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:paintroid/core/commands/command_manager/command_manager.dart'; +import 'package:paintroid/core/providers/state/workspace_state_notifier.dart'; part 'command_manager_provider.g.dart'; @@ -9,6 +10,10 @@ part 'command_manager_provider.g.dart'; class CommandManagerProvider extends _$CommandManagerProvider { @override CommandManager build() { - return CommandManager(); + return CommandManager( + onUndo: () { + ref.read(workspaceStateProvider.notifier).markUnsavedChanges(); + }, + ); } } diff --git a/lib/core/providers/object/io_handler.dart b/lib/core/providers/object/io_handler.dart index 718597d8..4caf7017 100644 --- a/lib/core/providers/object/io_handler.dart +++ b/lib/core/providers/object/io_handler.dart @@ -110,7 +110,7 @@ class IOHandler { /// - The unsaved work was saved successfully Future handleUnsavedChanges(BuildContext context, State state) async { final workspaceStateNotifier = ref.read(workspaceStateProvider.notifier); - if (!workspaceStateNotifier.hasSavedLastWork) { + if (!workspaceStateNotifier.hasSavedLastWork || workspaceStateNotifier.hasUnsavedChanges) { final shouldDiscard = await showDiscardChangesDialog(context); if (shouldDiscard == null || !state.mounted) return false; if (!shouldDiscard) { diff --git a/test/integration/app_workflow_test.dart b/test/integration/app_workflow_test.dart index 294d9766..c495b8ab 100644 --- a/test/integration/app_workflow_test.dart +++ b/test/integration/app_workflow_test.dart @@ -70,6 +70,7 @@ void main() { expect(redoStackLength, 1); await UIInteraction.clickBackButton(); + await UIInteraction.clickDiscard(); await UIInteraction.createNewImage(); redoStackLength = UIInteraction.getRedoStackLength(); From c81a2f4fbc81cd7710898fb38c110c04ab666743 Mon Sep 17 00:00:00 2001 From: Stefan Knipfer Date: Mon, 10 Aug 2026 11:30:00 +0200 Subject: [PATCH 3/3] [PAINTROID-811] Fix spray tool test --- test/integration/spray_tool_test.dart | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/test/integration/spray_tool_test.dart b/test/integration/spray_tool_test.dart index a9f38b33..03810127 100644 --- a/test/integration/spray_tool_test.dart +++ b/test/integration/spray_tool_test.dart @@ -33,13 +33,14 @@ void main() { await UIInteraction.selectTool(ToolData.SPRAY.name); const radius = 50.0; + const checkRadius = 2 * radius; (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); var color = await UIInteraction.getPixelColor( CanvasPosition.centerX, CanvasPosition.centerY, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, Colors.transparent); @@ -48,7 +49,7 @@ void main() { color = await UIInteraction.getPixelColor( CanvasPosition.centerX, CanvasPosition.centerY, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, isNot(Colors.transparent)); @@ -64,13 +65,14 @@ void main() { await UIInteraction.selectTool(ToolData.SPRAY.name); const radius = 50.0; + const checkRadius = 2 * radius; (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); var color = await UIInteraction.getPixelColor( CanvasPosition.left, CanvasPosition.top, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, Colors.transparent); @@ -79,7 +81,7 @@ void main() { color = await UIInteraction.getPixelColor( CanvasPosition.left, CanvasPosition.top, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, isNot(Colors.transparent)); @@ -95,13 +97,14 @@ void main() { await UIInteraction.selectTool(ToolData.SPRAY.name); const radius = 50.0; + const checkRadius = 2 * radius; (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); var color = await UIInteraction.getPixelColor( CanvasPosition.right, CanvasPosition.bottom, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, Colors.transparent); @@ -110,7 +113,7 @@ void main() { color = await UIInteraction.getPixelColor( CanvasPosition.right, CanvasPosition.bottom, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, isNot(Colors.transparent)); @@ -126,13 +129,14 @@ void main() { await UIInteraction.selectTool(ToolData.SPRAY.name); const radius = 50.0; + const checkRadius = 2 * radius; (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); var color = await UIInteraction.getPixelColor( CanvasPosition.centerX, CanvasPosition.centerY, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, Colors.transparent); @@ -145,7 +149,7 @@ void main() { color = await UIInteraction.getPixelColor( CanvasPosition.centerX, CanvasPosition.centerY, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, isNot(Colors.transparent)); @@ -161,6 +165,7 @@ void main() { await UIInteraction.selectTool(ToolData.SPRAY.name); const radius = 50.0; + const checkRadius = 2 * radius; (UIInteraction.getCurrentTool() as SprayTool).updateSprayRadius(radius); @@ -169,7 +174,7 @@ void main() { var color = await UIInteraction.getPixelColor( CanvasPosition.centerX, CanvasPosition.centerY, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, isNot(Colors.transparent)); @@ -178,7 +183,7 @@ void main() { color = await UIInteraction.getPixelColor( CanvasPosition.centerX, CanvasPosition.centerY, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, Colors.transparent); @@ -187,7 +192,7 @@ void main() { color = await UIInteraction.getPixelColor( CanvasPosition.centerX, CanvasPosition.centerY, - radius: radius.toInt(), + radius: checkRadius.toInt(), ); expect(color, isNot(Colors.transparent)); });