diff --git a/lib/core/providers/object/io_handler.dart b/lib/core/providers/object/io_handler.dart index 710391a9..ad178b14 100644 --- a/lib/core/providers/object/io_handler.dart +++ b/lib/core/providers/object/io_handler.dart @@ -22,6 +22,7 @@ import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; import 'package:paintroid/core/providers/state/workspace_state_notifier.dart'; import 'package:paintroid/core/utils/failure.dart'; import 'package:paintroid/core/utils/load_image_failure.dart'; +import 'package:paintroid/core/utils/name_generator.dart'; import 'package:paintroid/ui/shared/dialogs/discard_changes_dialog.dart'; import 'package:paintroid/ui/shared/dialogs/load_image_dialog.dart'; import 'package:paintroid/ui/shared/dialogs/save_image_dialog.dart'; @@ -37,7 +38,12 @@ class IOHandler { /// Returns [true] if the image was saved successfully Future saveImage(BuildContext context) async { final workspaceStateNotifier = ref.read(workspaceStateProvider.notifier); - final imageMetaData = await showSaveImageDialog(context, false); + final defaultName = await NameGenerator.getNextImageName(); + final imageMetaData = await showSaveImageDialog( + context, + false, + defaultName: defaultName, + ); if (imageMetaData == null) { return false; } diff --git a/lib/core/utils/name_generator.dart b/lib/core/utils/name_generator.dart new file mode 100644 index 00000000..8bda3cb9 --- /dev/null +++ b/lib/core/utils/name_generator.dart @@ -0,0 +1,47 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:paintroid/core/database/project_database.dart'; + +class NameGenerator { + static const String _imageCounterKey = 'last_image_number'; + + /// Finds the next available project name in format "project[number]" + /// by querying the database for existing projects + static Future getNextProjectName(ProjectDatabase database) async { + final projects = await database.projectDAO.getProjects(); + return _findNextAvailableName( + projects.map((p) => p.name).toList(), + 'project', + ); + } + + /// Finds the next available image name in format "image[number]" + /// Uses SharedPreferences to track the counter since images are saved + /// to the photo library where we can't query filenames + static Future getNextImageName() async { + final prefs = await SharedPreferences.getInstance(); + final lastNumber = prefs.getInt(_imageCounterKey) ?? 0; + final nextNumber = lastNumber + 1; + await prefs.setInt(_imageCounterKey, nextNumber); + return 'image$nextNumber'; + } + + /// Finds the next available name by parsing existing names with the given prefix + /// Returns prefix + (maxNumber + 1) + static String _findNextAvailableName(List existingNames, String prefix) { + final pattern = RegExp('^$prefix(\\d+)\$'); + int maxNumber = 0; + + for (final name in existingNames) { + final match = pattern.firstMatch(name); + if (match != null) { + final number = int.tryParse(match.group(1)!); + if (number != null && number > maxNumber) { + maxNumber = number; + } + } + } + + return '$prefix${maxNumber + 1}'; + } +} 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..ffcba9e2 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 @@ -11,6 +11,7 @@ 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/core/utils/name_generator.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'; @@ -141,7 +142,13 @@ class _OverflowMenuState extends ConsumerState { } Future _saveProject() async { - final imageData = await showSaveImageDialog(context, true); + final db = await ref.read(ProjectDatabase.provider.future); + final defaultName = await NameGenerator.getNextProjectName(db); + final imageData = await showSaveImageDialog( + context, + true, + defaultName: defaultName, + ); if (imageData == null) { return; @@ -149,8 +156,6 @@ class _OverflowMenuState extends ConsumerState { final catrobatImageData = imageData as CatrobatImageMetaData; - final db = await ref.read(ProjectDatabase.provider.future); - if (!await _checkIfFileExistsAndConfirmOverwrite(catrobatImageData, db)) { return; } diff --git a/lib/ui/shared/dialogs/save_image_dialog.dart b/lib/ui/shared/dialogs/save_image_dialog.dart index 3e6280bf..2f369210 100644 --- a/lib/ui/shared/dialogs/save_image_dialog.dart +++ b/lib/ui/shared/dialogs/save_image_dialog.dart @@ -6,18 +6,28 @@ import 'package:paintroid/ui/shared/image_format_info.dart'; import 'package:paintroid/ui/theme/theme.dart'; Future showSaveImageDialog( - BuildContext context, bool savingProject) => + BuildContext context, + bool savingProject, { + String? defaultName, +}) => showGeneralDialog( context: context, - pageBuilder: (_, __, ___) => - SaveImageDialog(savingProject: savingProject), + pageBuilder: (_, __, ___) => SaveImageDialog( + savingProject: savingProject, + defaultName: defaultName, + ), barrierDismissible: true, barrierLabel: 'Dismiss save image dialog box'); class SaveImageDialog extends StatefulWidget { final bool savingProject; + final String? defaultName; - const SaveImageDialog({super.key, required this.savingProject}); + const SaveImageDialog({ + super.key, + required this.savingProject, + this.defaultName, + }); @override State createState() => _SaveImageDialogState(); @@ -36,6 +46,10 @@ class _SaveImageDialogState extends State { if (widget.savingProject) { selectedFormat = ImageFormat.catrobatImage; } + + if (widget.defaultName != null) { + nameFieldController.text = widget.defaultName!; + } } void _dismissDialogWithData() { diff --git a/test/unit/utils/name_generator_test.dart b/test/unit/utils/name_generator_test.dart new file mode 100644 index 00000000..56dee24f --- /dev/null +++ b/test/unit/utils/name_generator_test.dart @@ -0,0 +1,230 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:paintroid/core/database/project_dao.dart'; +import 'package:paintroid/core/database/project_database.dart'; +import 'package:paintroid/core/models/database/project.dart'; +import 'package:paintroid/core/utils/name_generator.dart'; + +import 'name_generator_test.mocks.dart'; + +@GenerateMocks([ProjectDatabase, ProjectDAO]) +void main() { + group('NameGenerator', () { + late MockProjectDatabase mockDatabase; + late MockProjectDAO mockProjectDAO; + + setUp(() { + mockDatabase = MockProjectDatabase(); + mockProjectDAO = MockProjectDAO(); + when(mockDatabase.projectDAO).thenReturn(mockProjectDAO); + }); + + group('getNextProjectName', () { + test('Should return "project1" when no projects exist', () async { + when(mockProjectDAO.getProjects()).thenAnswer((_) async => []); + + final result = await NameGenerator.getNextProjectName(mockDatabase); + + expect(result, 'project1'); + verify(mockProjectDAO.getProjects()).called(1); + }); + + test('Should return "project2" when "project1" exists', () async { + final existingProject = Project( + name: 'project1', + path: '/path/to/project1', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ); + when(mockProjectDAO.getProjects()) + .thenAnswer((_) async => [existingProject]); + + final result = await NameGenerator.getNextProjectName(mockDatabase); + + expect(result, 'project2'); + verify(mockProjectDAO.getProjects()).called(1); + }); + + test('Should return "project5" when projects 1-4 exist', () async { + final projects = [ + Project( + name: 'project1', + path: '/path/to/project1', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'project2', + path: '/path/to/project2', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'project3', + path: '/path/to/project3', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'project4', + path: '/path/to/project4', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + ]; + when(mockProjectDAO.getProjects()).thenAnswer((_) async => projects); + + final result = await NameGenerator.getNextProjectName(mockDatabase); + + expect(result, 'project5'); + }); + + test('Should find max number when projects are not sequential', () async { + final projects = [ + Project( + name: 'project1', + path: '/path/to/project1', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'project5', + path: '/path/to/project5', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'project3', + path: '/path/to/project3', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + ]; + when(mockProjectDAO.getProjects()).thenAnswer((_) async => projects); + + final result = await NameGenerator.getNextProjectName(mockDatabase); + + expect(result, 'project6'); + }); + + test('Should ignore projects with different naming patterns', () async { + final projects = [ + Project( + name: 'project1', + path: '/path/to/project1', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'myProject', + path: '/path/to/myProject', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'project3', + path: '/path/to/project3', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'test', + path: '/path/to/test', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + ]; + when(mockProjectDAO.getProjects()).thenAnswer((_) async => projects); + + final result = await NameGenerator.getNextProjectName(mockDatabase); + + expect(result, 'project4'); + }); + + test('Should handle large project numbers correctly', () async { + final projects = [ + Project( + name: 'project99', + path: '/path/to/project99', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + Project( + name: 'project100', + path: '/path/to/project100', + lastModified: DateTime.now(), + creationDate: DateTime.now(), + ), + ]; + when(mockProjectDAO.getProjects()).thenAnswer((_) async => projects); + + final result = await NameGenerator.getNextProjectName(mockDatabase); + + expect(result, 'project101'); + }); + }); + + group('getNextImageName', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('Should return "image1" on first call', () async { + final result = await NameGenerator.getNextImageName(); + + expect(result, 'image1'); + }); + + test('Should increment counter with each call', () async { + final first = await NameGenerator.getNextImageName(); + expect(first, 'image1'); + + final second = await NameGenerator.getNextImageName(); + expect(second, 'image2'); + + final third = await NameGenerator.getNextImageName(); + expect(third, 'image3'); + }); + + test('Should persist counter across instances', () async { + await NameGenerator.getNextImageName(); + await NameGenerator.getNextImageName(); + + final prefs = await SharedPreferences.getInstance(); + final savedValue = prefs.getInt('last_image_number'); + + expect(savedValue, 2); + }); + + test('Should resume from stored counter value', () async { + SharedPreferences.setMockInitialValues({'last_image_number': 42}); + + final result = await NameGenerator.getNextImageName(); + + expect(result, 'image43'); + }); + + test('Should handle large numbers correctly', () async { + SharedPreferences.setMockInitialValues({'last_image_number': 999}); + + final result = await NameGenerator.getNextImageName(); + + expect(result, 'image1000'); + }); + + test('Should save incremented value to SharedPreferences', () async { + SharedPreferences.setMockInitialValues({'last_image_number': 5}); + + await NameGenerator.getNextImageName(); + + final prefs = await SharedPreferences.getInstance(); + final savedValue = prefs.getInt('last_image_number'); + + expect(savedValue, 6); + }); + }); + }); +} diff --git a/test/unit/utils/name_generator_test.mocks.dart b/test/unit/utils/name_generator_test.mocks.dart new file mode 100644 index 00000000..2967ac98 --- /dev/null +++ b/test/unit/utils/name_generator_test.mocks.dart @@ -0,0 +1,191 @@ +// Mocks generated by Mockito 5.4.5 from annotations +// in paintroid/test/unit/utils/name_generator_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:paintroid/core/database/project_dao.dart' as _i2; +import 'package:paintroid/core/database/project_database.dart' as _i5; +import 'package:paintroid/core/models/database/project.dart' as _i6; +import 'package:sqflite/sqflite.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeProjectDAO_0 extends _i1.SmartFake implements _i2.ProjectDAO { + _FakeProjectDAO_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeStreamController_1 extends _i1.SmartFake + implements _i3.StreamController { + _FakeStreamController_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeDatabaseExecutor_2 extends _i1.SmartFake + implements _i4.DatabaseExecutor { + _FakeDatabaseExecutor_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [ProjectDatabase]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockProjectDatabase extends _i1.Mock implements _i5.ProjectDatabase { + MockProjectDatabase() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.ProjectDAO get projectDAO => (super.noSuchMethod( + Invocation.getter(#projectDAO), + returnValue: _FakeProjectDAO_0( + this, + Invocation.getter(#projectDAO), + ), + ) as _i2.ProjectDAO); + + @override + _i3.StreamController get changeListener => (super.noSuchMethod( + Invocation.getter(#changeListener), + returnValue: _FakeStreamController_1( + this, + Invocation.getter(#changeListener), + ), + ) as _i3.StreamController); + + @override + set changeListener(_i3.StreamController? _changeListener) => + super.noSuchMethod( + Invocation.setter( + #changeListener, + _changeListener, + ), + returnValueForMissingStub: null, + ); + + @override + _i4.DatabaseExecutor get database => (super.noSuchMethod( + Invocation.getter(#database), + returnValue: _FakeDatabaseExecutor_2( + this, + Invocation.getter(#database), + ), + ) as _i4.DatabaseExecutor); + + @override + set database(_i4.DatabaseExecutor? _database) => super.noSuchMethod( + Invocation.setter( + #database, + _database, + ), + returnValueForMissingStub: null, + ); + + @override + _i3.Future close() => (super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); +} + +/// A class which mocks [ProjectDAO]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockProjectDAO extends _i1.Mock implements _i2.ProjectDAO { + MockProjectDAO() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future insertProject(_i6.Project? project) => (super.noSuchMethod( + Invocation.method( + #insertProject, + [project], + ), + returnValue: _i3.Future.value(0), + ) as _i3.Future); + + @override + _i3.Future> insertProjects(List<_i6.Project>? projects) => + (super.noSuchMethod( + Invocation.method( + #insertProjects, + [projects], + ), + returnValue: _i3.Future>.value([]), + ) as _i3.Future>); + + @override + _i3.Future deleteProject(int? id) => (super.noSuchMethod( + Invocation.method( + #deleteProject, + [id], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + + @override + _i3.Future deleteProjects(List<_i6.Project>? projects) => + (super.noSuchMethod( + Invocation.method( + #deleteProjects, + [projects], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + + @override + _i3.Future> getProjects() => (super.noSuchMethod( + Invocation.method( + #getProjects, + [], + ), + returnValue: _i3.Future>.value(<_i6.Project>[]), + ) as _i3.Future>); + + @override + _i3.Future<_i6.Project?> getProjectByName(String? name) => + (super.noSuchMethod( + Invocation.method( + #getProjectByName, + [name], + ), + returnValue: _i3.Future<_i6.Project?>.value(), + ) as _i3.Future<_i6.Project?>); +}