Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/core/providers/object/io_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,7 +38,12 @@ class IOHandler {
/// Returns [true] if the image was saved successfully
Future<bool> 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;
}
Expand Down
47 changes: 47 additions & 0 deletions lib/core/utils/name_generator.dart
Original file line number Diff line number Diff line change
@@ -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]"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plerase remove comments, should be clear enough :)

/// by querying the database for existing projects
static Future<String> 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<String> 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<String> 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}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,16 +142,20 @@ class _OverflowMenuState extends ConsumerState<OverflowMenu> {
}

Future<void> _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;
}

final catrobatImageData = imageData as CatrobatImageMetaData;

final db = await ref.read(ProjectDatabase.provider.future);

if (!await _checkIfFileExistsAndConfirmOverwrite(catrobatImageData, db)) {
return;
}
Expand Down
22 changes: 18 additions & 4 deletions lib/ui/shared/dialogs/save_image_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,28 @@ import 'package:paintroid/ui/shared/image_format_info.dart';
import 'package:paintroid/ui/theme/theme.dart';

Future<ImageMetaData?> showSaveImageDialog(
BuildContext context, bool savingProject) =>
BuildContext context,
bool savingProject, {
String? defaultName,
}) =>
showGeneralDialog<ImageMetaData?>(
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<SaveImageDialog> createState() => _SaveImageDialogState();
Expand All @@ -36,6 +46,10 @@ class _SaveImageDialogState extends State<SaveImageDialog> {
if (widget.savingProject) {
selectedFormat = ImageFormat.catrobatImage;
}

if (widget.defaultName != null) {
nameFieldController.text = widget.defaultName!;
}
}

void _dismissDialogWithData() {
Expand Down
230 changes: 230 additions & 0 deletions test/unit/utils/name_generator_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
}
Loading
Loading