From 5739d86fbaee82ef1708025acd4220ede059c2bd Mon Sep 17 00:00:00 2001 From: Saarthak Seth <52480794+CodeChamp-SS@users.noreply.github.com> Date: Tue, 1 Nov 2022 21:34:51 +0530 Subject: [PATCH] PAINTROID-440, PAINTROID-441, PAINTROID-442 Created the landing page and implemented the basic functionality (#8) PAINTROID-440, PAINTROID-441, PAINTROID-442 Created the landing page and implemented the basic functionality --- .github/workflows/main.yml | 2 +- .gitignore | 1 + analysis_options.yaml | 4 +- assets/svg/ic_edit_circle.svg | 10 + lib/data/model/project.dart | 27 ++ lib/data/project_dao.dart | 21 ++ lib/data/project_database.dart | 19 ++ .../typeconverters/date_time_converter.dart | 13 + lib/io/src/service/file_service.dart | 34 ++ lib/io/src/service/image_service.dart | 15 + lib/io/src/ui/delete_project_dialog.dart | 57 ++++ lib/io/src/ui/discard_changes_dialog.dart | 28 +- lib/io/src/ui/project_details_dialog.dart | 106 ++++++ lib/io/src/ui/save_image_dialog.dart | 40 ++- .../usecase/load_image_from_file_manager.dart | 13 +- .../src/usecase/save_as_catrobat_image.dart | 5 +- lib/main.dart | 65 ++-- lib/ui/io_handler.dart | 91 +++-- lib/ui/landing_page.dart | 319 ++++++++++++++++++ lib/ui/overflow_menu.dart | 37 +- lib/ui/pocket_paint.dart | 28 +- lib/ui/project_overflow_menu.dart | 93 +++++ pubspec.lock | 110 +++++- pubspec.yaml | 5 + test/unit/data/project_database_test.dart | 109 ++++++ test/unit/io/service/file_service_test.dart | 45 +++ test/unit/io/service/image_service_test.dart | 9 + test/widget/ui/landing_page_test.dart | 308 +++++++++++++++++ 28 files changed, 1515 insertions(+), 99 deletions(-) create mode 100644 assets/svg/ic_edit_circle.svg create mode 100644 lib/data/model/project.dart create mode 100644 lib/data/project_dao.dart create mode 100644 lib/data/project_database.dart create mode 100644 lib/data/typeconverters/date_time_converter.dart create mode 100644 lib/io/src/ui/delete_project_dialog.dart create mode 100644 lib/io/src/ui/project_details_dialog.dart create mode 100644 lib/ui/landing_page.dart create mode 100644 lib/ui/project_overflow_menu.dart create mode 100644 test/unit/data/project_database_test.dart create mode 100644 test/unit/io/service/file_service_test.dart create mode 100644 test/widget/ui/landing_page_test.dart diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d36eb34c..5adedeb1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ jobs: - name: Setup run: | flutter pub get - flutter pub run build_runner build + flutter pub run build_runner build --delete-conflicting-outputs dart pub global activate protoc_plugin chmod +x generate_protos.sh ./generate_protos.sh diff --git a/.gitignore b/.gitignore index 078e9b5b..76a9f8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ migrate_working_dir/ coverage *.mocks.dart *.pb*.dart +*.g.dart # Web related lib/generated_plugin_registrant.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 26a82f14..bec54837 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,4 +4,6 @@ analyzer: missing_enum_constant_in_switch: error exhaustive_cases: error exclude: - - lib/**.pb*.dart \ No newline at end of file + - lib/**.pb*.dart + - lib/data/*.g.dart + - test/** \ No newline at end of file diff --git a/assets/svg/ic_edit_circle.svg b/assets/svg/ic_edit_circle.svg new file mode 100644 index 00000000..f1ccb714 --- /dev/null +++ b/assets/svg/ic_edit_circle.svg @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/lib/data/model/project.dart b/lib/data/model/project.dart new file mode 100644 index 00000000..ca901d2e --- /dev/null +++ b/lib/data/model/project.dart @@ -0,0 +1,27 @@ +import 'package:floor/floor.dart'; + +@entity +class Project { + String name; + String path; + DateTime lastModified; + DateTime creationDate; + String? resolution; + String? format; + int? size; + String? imagePreviewPath; + @PrimaryKey(autoGenerate: true) + final int? id; + + Project({ + required this.name, + required this.path, + required this.lastModified, + required this.creationDate, + this.resolution, + this.format, + this.size, + this.imagePreviewPath, + this.id, + }); +} diff --git a/lib/data/project_dao.dart b/lib/data/project_dao.dart new file mode 100644 index 00000000..fc62397c --- /dev/null +++ b/lib/data/project_dao.dart @@ -0,0 +1,21 @@ +import 'package:floor/floor.dart'; + +import 'model/project.dart'; + +@dao +abstract class ProjectDAO { + @Insert(onConflict: OnConflictStrategy.replace) + Future insertProject(Project project); + + @Insert(onConflict: OnConflictStrategy.replace) + Future> insertProjects(List projects); + + @Query('DELETE FROM Project WHERE id = :id') + Future deleteProject(int id); + + @delete + Future deleteProjects(List projects); + + @Query('SELECT * FROM Project order by lastModified desc') + Future> getProjects(); +} diff --git a/lib/data/project_database.dart b/lib/data/project_database.dart new file mode 100644 index 00000000..a9216033 --- /dev/null +++ b/lib/data/project_database.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:floor/floor.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/data/model/project.dart'; +import 'package:paintroid/data/project_dao.dart'; +import 'package:paintroid/data/typeconverters/date_time_converter.dart'; +import 'package:sqflite/sqflite.dart' as sqflite; + +part 'project_database.g.dart'; + +@TypeConverters([DateTimeConverter]) +@Database(version: 1, entities: [Project]) +abstract class ProjectDatabase extends FloorDatabase { + ProjectDAO get projectDAO; + + static final provider = FutureProvider((ref) => + $FloorProjectDatabase.databaseBuilder("project_database.db").build()); +} diff --git a/lib/data/typeconverters/date_time_converter.dart b/lib/data/typeconverters/date_time_converter.dart new file mode 100644 index 00000000..e66e614a --- /dev/null +++ b/lib/data/typeconverters/date_time_converter.dart @@ -0,0 +1,13 @@ +import 'package:floor/floor.dart'; + +class DateTimeConverter extends TypeConverter { + @override + DateTime decode(int databaseValue) { + return DateTime.fromMillisecondsSinceEpoch(databaseValue); + } + + @override + int encode(DateTime value) { + return value.millisecondsSinceEpoch; + } +} diff --git a/lib/io/src/service/file_service.dart b/lib/io/src/service/file_service.dart index 84445690..0a61d610 100644 --- a/lib/io/src/service/file_service.dart +++ b/lib/io/src/service/file_service.dart @@ -7,12 +7,18 @@ import 'package:oxidized/oxidized.dart'; import 'package:paintroid/core/failure.dart'; import 'package:paintroid/core/loggable_mixin.dart'; import 'package:paintroid/io/io.dart'; +import 'package:path_provider/path_provider.dart'; abstract class IFileService { Future> save(String filename, Uint8List data); + Future> saveToApplicationDirectory( + String filename, Uint8List data); + Future> pick(); + Result getFile(String path); + static final provider = Provider((ref) => FileService()); } @@ -51,4 +57,32 @@ class FileService with LoggableMixin implements IFileService { return Result.err(SaveImageFailure.unidentified); } } + + Future get _localPath async { + final directory = await getApplicationDocumentsDirectory(); + return directory.path; + } + + @override + Future> saveToApplicationDirectory( + String filename, Uint8List data) async { + try { + String saveDirectory = "${await _localPath}/$filename"; + final file = await File(saveDirectory).create(recursive: true); + return Result.ok(await file.writeAsBytes(data)); + } catch (err, stacktrace) { + logger.severe("Could not save file", err, stacktrace); + return Result.err(SaveImageFailure.unidentified); + } + } + + @override + Result getFile(String path) { + try { + return Result.ok(File(path)); + } catch (err, stacktrace) { + logger.severe("Could not load file", err, stacktrace); + return Result.err(LoadImageFailure.unidentified); + } + } } diff --git a/lib/io/src/service/image_service.dart b/lib/io/src/service/image_service.dart index d0f8a0ca..6e354dc3 100644 --- a/lib/io/src/service/image_service.dart +++ b/lib/io/src/service/image_service.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -19,6 +20,8 @@ abstract class IImageService { Future> exportAsPng(ui.Image image); + Result getProjectPreview(String? path); + static final provider = Provider((ref) => ImageService()); } @@ -61,4 +64,16 @@ class ImageService with LoggableMixin implements IImageService { return Result.err(SaveImageFailure.unidentified); } } + + @override + Result getProjectPreview(String? path) { + try { + if (path == null) throw "Unable to get the project preview"; + final file = File(path); + return Result.ok(file.readAsBytesSync()); + } catch (err, stacktrace) { + logger.severe("Could not get the project preview", err, stacktrace); + return Result.err(LoadImageFailure.invalidImage); + } + } } diff --git a/lib/io/src/ui/delete_project_dialog.dart b/lib/io/src/ui/delete_project_dialog.dart new file mode 100644 index 00000000..62730982 --- /dev/null +++ b/lib/io/src/ui/delete_project_dialog.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Returns [true] if user chose to delete the project or [null] if user +/// dismiss the dialog by tapping outside +Future showDeleteDialog(BuildContext context, String name) => + showGeneralDialog( + context: context, + pageBuilder: (_, __, ___) => DeleteProjectDialog(name: name), + barrierDismissible: true, + barrierLabel: "Show delete project dialog box"); + +class DeleteProjectDialog extends StatefulWidget { + final String name; + + const DeleteProjectDialog({Key? key, required this.name}) : super(key: key); + + @override + State createState() => _DeleteProjectDialogState(); +} + +class _DeleteProjectDialogState extends State { + @override + Widget build(BuildContext context) => AlertDialog( + title: Text("Delete ${widget.name}"), + actions: const [ + DialogElevatedButton(text: 'Cancel'), + DialogTextButton(text: 'Delete'), + ], + content: const Text("Do you really want to delete your project?"), + ); +} + +class DialogTextButton extends StatelessWidget { + final String text; + + const DialogTextButton({Key? key, required this.text}) : super(key: key); + + @override + Widget build(BuildContext context) => TextButton( + style: + ButtonStyle(foregroundColor: MaterialStateProperty.all(Colors.red)), + onPressed: () => Navigator.of(context).pop(true), + child: Text(text), + ); +} + +class DialogElevatedButton extends StatelessWidget { + final String text; + + const DialogElevatedButton({Key? key, required this.text}) : super(key: key); + + @override + Widget build(BuildContext context) => ElevatedButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(text, style: const TextStyle(color: Colors.white)), + ); +} diff --git a/lib/io/src/ui/discard_changes_dialog.dart b/lib/io/src/ui/discard_changes_dialog.dart index ba5a3d1f..cfecb7a5 100644 --- a/lib/io/src/ui/discard_changes_dialog.dart +++ b/lib/io/src/ui/discard_changes_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:paintroid/io/src/ui/delete_project_dialog.dart'; /// Returns [true] if user chose to discard changes or [null] if user /// dismissed the dialog by tapping outside @@ -19,26 +20,13 @@ class DiscardChangesDialog extends StatefulWidget { class _DiscardChangesDialogState extends State { @override Widget build(BuildContext context) { - return AlertDialog( - title: const Text("Discard changes"), - actions: [_discardButton, _saveButton], - content: const Text( - "You have not saved your last changes. They will be lost!"), - ); - } - - TextButton get _discardButton { - return TextButton( - style: TextButton.styleFrom(primary: Colors.red), - onPressed: () => Navigator.of(context).pop(true), - child: const Text("Discard"), - ); - } - - ElevatedButton get _saveButton { - return ElevatedButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text("Save", style: TextStyle(color: Colors.white)), + return const AlertDialog( + title: Text("Discard changes"), + actions: [ + DialogTextButton(text: 'Discard'), + DialogElevatedButton(text: 'Save'), + ], + content: Text("You have not saved your last changes. They will be lost!"), ); } } diff --git a/lib/io/src/ui/project_details_dialog.dart b/lib/io/src/ui/project_details_dialog.dart new file mode 100644 index 00000000..5326f53b --- /dev/null +++ b/lib/io/src/ui/project_details_dialog.dart @@ -0,0 +1,106 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_styled_toast/flutter_styled_toast.dart'; +import 'package:intl/intl.dart'; +import 'package:oxidized/oxidized.dart'; +import 'package:paintroid/io/io.dart'; +import 'package:paintroid/io/src/ui/delete_project_dialog.dart'; + +import '../../../data/model/project.dart'; +import '../../../ui/color_schemes.dart'; + +Future showDetailsDialog(BuildContext context, Project project) => + showGeneralDialog( + context: context, + pageBuilder: (_, __, ___) => ProjectDetailsDialog(project: project), + barrierDismissible: true, + barrierLabel: "Show project details dialog box"); + +class ProjectDetailsDialog extends ConsumerStatefulWidget { + final Project project; + + const ProjectDetailsDialog({Key? key, required this.project}) + : super(key: key); + + @override + ConsumerState createState() => + _ProjectDetailsDialogState(); +} + +class _ProjectDetailsDialogState extends ConsumerState { + late IImageService imageService; + late IFileService fileService; + + @override + Widget build(BuildContext context) { + imageService = ref.watch(IImageService.provider); + fileService = ref.watch(IFileService.provider); + + final DateFormat formatter = DateFormat('dd-MM-yyyy HH:mm:ss'); + + return AlertDialog( + title: Text(widget.project.name), + actions: const [DialogElevatedButton(text: 'OK')], + content: FutureBuilder( + future: _getImageDimensions(widget.project.imagePreviewPath), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + final dimensions = snapshot.data!; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text("Resolution: ${dimensions[0]} X ${dimensions[1]}"), + Text( + "Last modified: ${formatter.format(widget.project.lastModified)}"), + Text( + "Creation date: ${formatter.format(widget.project.creationDate)}"), + Text("Size: ${filesize(_getProjectSize())}"), + ], + ); + } else { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + backgroundColor: lightColorScheme.background, + ), + ], + ); + } + }, + ), + ); + } + + int _getProjectSize() => fileService.getFile(widget.project.path).when( + ok: (file) => file.lengthSync(), + err: (failure) { + showToast(failure.message); + return 0; + }, + ); + + Future> _getImageDimensions(String? path) async { + List dimensions = []; + return imageService.getProjectPreview(path).when( + ok: (img) => imageService.import(img).when( + ok: (image) { + dimensions.add(image.width); + dimensions.add(image.height); + return dimensions; + }, + err: (failure) { + showToast(failure.message); + return dimensions; + }, + ), + err: (failure) { + showToast(failure.message); + return dimensions; + }, + ); + } +} diff --git a/lib/io/src/ui/save_image_dialog.dart b/lib/io/src/ui/save_image_dialog.dart index 2104a662..b2076c85 100644 --- a/lib/io/src/ui/save_image_dialog.dart +++ b/lib/io/src/ui/save_image_dialog.dart @@ -4,26 +4,42 @@ import 'package:paintroid/io/io.dart'; part 'image_format_info.dart'; /// Returns [null] if user dismissed the dialog by tapping outside -Future showSaveImageDialog(BuildContext context) => +Future showSaveImageDialog( + BuildContext context, bool savingProject) => showGeneralDialog( context: context, - pageBuilder: (_, __, ___) => const SaveImageDialog(), + pageBuilder: (_, __, ___) => + SaveImageDialog(savingProject: savingProject), barrierDismissible: true, barrierLabel: "Dismiss save image dialog box"); class SaveImageDialog extends StatefulWidget { - const SaveImageDialog({Key? key}) : super(key: key); + final bool savingProject; + + const SaveImageDialog({Key? key, required this.savingProject}) + : super(key: key); @override State createState() => _SaveImageDialogState(); } class _SaveImageDialogState extends State { - final nameFieldController = TextEditingController(text: "image"); + late final TextEditingController nameFieldController; final formKey = GlobalKey(debugLabel: "SaveImageDialog Form"); var selectedFormat = ImageFormat.jpg; var imageQualityValue = 100; + @override + void initState() { + super.initState(); + var text = "image"; + if (widget.savingProject) { + selectedFormat = ImageFormat.catrobatImage; + text = "project"; + } + nameFieldController = TextEditingController(text: text); + } + void _dismissDialogWithData() { late ImageMetaData data; switch (selectedFormat) { @@ -42,8 +58,14 @@ class _SaveImageDialogState extends State { @override Widget build(BuildContext context) { + var dialogTitle = "Save "; + if (widget.savingProject) { + dialogTitle += "Project"; + } else { + dialogTitle += "Image"; + } return AlertDialog( - title: const Text("Save image"), + title: Text(dialogTitle), actions: [_cancelButton, _saveButton], contentTextStyle: Theme.of(context).textTheme.bodyLarge, content: Form( @@ -54,7 +76,7 @@ class _SaveImageDialogState extends State { children: [ _imageNameTextField, const Divider(height: 16), - _imageFormatDropdown, + if (!widget.savingProject) _imageFormatDropdown, const Divider(height: 8), if (selectedFormat == ImageFormat.jpg) _qualitySlider, const Divider(height: 8), @@ -111,7 +133,11 @@ class _SaveImageDialogState extends State { decoration: const InputDecoration(labelText: "Name", filled: true), validator: (text) { if (text == null || text.isEmpty) { - return 'Please specify an image name'; + var errMsg = 'Please specify an image name'; + if (widget.savingProject) { + errMsg = 'Please specify a project name'; + } + return errMsg; } return null; }, diff --git a/lib/io/src/usecase/load_image_from_file_manager.dart b/lib/io/src/usecase/load_image_from_file_manager.dart index 1c6bc65d..f6aa1eb7 100644 --- a/lib/io/src/usecase/load_image_from_file_manager.dart +++ b/lib/io/src/usecase/load_image_from_file_manager.dart @@ -33,11 +33,16 @@ class LoadImageFromFileManager with LoggableMixin { fileService, imageService, permissionService, serializer); }); - Future> call() async { - if (!(await permissionService.requestAccessToSharedFileStorage())) { - return Result.err(SaveImageFailure.permissionDenied); + Future> call( + Result? file) async { + if (file == null) { + if (!(await permissionService.requestAccessToSharedFileStorage())) { + return Result.err(SaveImageFailure.permissionDenied); + } + file = await fileService.pick(); } - return await fileService.pick().andThenAsync((file) async { + + return await file.andThenAsync((file) async { try { switch (file.extension) { case "jpg": diff --git a/lib/io/src/usecase/save_as_catrobat_image.dart b/lib/io/src/usecase/save_as_catrobat_image.dart index 0e2cdfb9..035a071f 100644 --- a/lib/io/src/usecase/save_as_catrobat_image.dart +++ b/lib/io/src/usecase/save_as_catrobat_image.dart @@ -31,13 +31,16 @@ class SaveAsCatrobatImage with LoggableMixin { }); Future> call( - CatrobatImageMetaData data, CatrobatImage image) async { + CatrobatImageMetaData data, CatrobatImage image, bool isAProject) async { if (!(await permissionService.requestAccessToSharedFileStorage())) { return Result.err(SaveImageFailure.permissionDenied); } final nameWithExt = "${data.name}.${data.format.extension}"; try { final bytes = await _catrobatImageSerializer.toBytes(image); + if (isAProject) { + return _fileService.saveToApplicationDirectory(nameWithExt, bytes); + } return _fileService.save(nameWithExt, bytes); } catch (err, stacktrace) { logger.severe( diff --git a/lib/main.dart b/lib/main.dart index 3708961a..b96c8147 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,11 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_styled_toast/flutter_styled_toast.dart'; import 'package:logging/logging.dart'; import 'package:paintroid/ui/color_schemes.dart'; +import 'package:paintroid/ui/landing_page.dart'; import 'package:paintroid/ui/loading_overlay.dart'; +import 'package:paintroid/ui/pocket_paint.dart'; import 'package:paintroid/workspace/workspace.dart'; -import 'ui/pocket_paint.dart'; - void main() async { Logger.root.onRecord.listen((record) { log(record.message, @@ -29,30 +29,45 @@ class PocketPaintApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Pocket Paint', - theme: ThemeData.from(useMaterial3: true, colorScheme: lightColorScheme), - home: StyledToast( - toastAnimation: StyledToastAnimation.fade, - reverseAnimation: StyledToastAnimation.fade, - curve: Curves.easeInOut, - reverseCurve: Curves.easeInOut, - backgroundColor: Colors.white70, - toastPositions: const StyledToastPosition(align: Alignment(0, 0.75)), - duration: const Duration(seconds: 3, milliseconds: 400), - textStyle: const TextStyle(color: Colors.black), - borderRadius: BorderRadius.circular(20), - locale: const Locale('en'), - child: Consumer( - builder: (BuildContext context, WidgetRef ref, Widget? child) { - return LoadingOverlay( - isLoading: ref.watch(WorkspaceState.provider.select( - (state) => state.isPerformingIOTask, - )), - child: child, - ); + return StyledToast( + toastAnimation: StyledToastAnimation.fade, + reverseAnimation: StyledToastAnimation.fade, + curve: Curves.easeInOut, + reverseCurve: Curves.easeInOut, + backgroundColor: Colors.white70, + toastPositions: const StyledToastPosition(align: Alignment(0, 0.75)), + duration: const Duration(seconds: 3, milliseconds: 400), + textStyle: const TextStyle(color: Colors.black), + borderRadius: BorderRadius.circular(20), + locale: const Locale('en'), + child: Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) { + return LoadingOverlay( + isLoading: ref.watch(WorkspaceState.provider.select( + (state) => state.isPerformingIOTask, + )), + child: child, + ); + }, + child: MaterialApp( + title: 'Pocket Paint', + theme: + ThemeData.from(useMaterial3: true, colorScheme: lightColorScheme), + initialRoute: "/", + onGenerateRoute: (settings) { + switch (settings.name) { + case "/": + return MaterialPageRoute( + builder: (context) => + const LandingPage(title: "Pocket Paint"), + ); + case "/PocketPaint": + return MaterialPageRoute( + builder: (context) => const PocketPaint(), + ); + } + return null; }, - child: const PocketPaint(), ), ), ); diff --git a/lib/ui/io_handler.dart b/lib/ui/io_handler.dart index 750b8ff7..860b0384 100644 --- a/lib/ui/io_handler.dart +++ b/lib/ui/io_handler.dart @@ -8,6 +8,8 @@ import 'package:paintroid/command/command.dart' show CommandManager; import 'package:paintroid/io/io.dart'; import 'package:paintroid/workspace/workspace.dart'; +import '../core/failure.dart'; + class IOHandler { final Ref ref; @@ -18,13 +20,23 @@ class IOHandler { /// Returns [true] if the image was saved successfully Future saveImage(BuildContext context) async { final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); - final imageData = await showSaveImageDialog(context); - if (imageData == null) return false; - final didSave = await workspaceStateNotifier - .performIOTask(() => _saveImageWith(imageData)); - if (!didSave) return false; + final imageMetaData = await showSaveImageDialog(context, false); + if (imageMetaData == null) { + return false; + } + final isFileSaved = await workspaceStateNotifier + .performIOTask(() => _saveImageWith(imageMetaData)); workspaceStateNotifier.updateLastSavedCommandCount(); - return true; + return isFileSaved; + } + + Future saveProject(ImageMetaData imageMetaData) async { + if (imageMetaData is! CatrobatImageMetaData) return null; + final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); + final savedFile = await workspaceStateNotifier + .performIOTask(() => _saveAsCatrobatImage(imageMetaData, true)); + if (savedFile != null) workspaceStateNotifier.updateLastSavedCommandCount(); + return savedFile; } /// Returns [true] if - @@ -44,9 +56,12 @@ class IOHandler { } /// Returns [true] if the image was loaded successfully - Future loadImage(BuildContext context, State state) async { - final shouldContinue = await handleUnsavedChanges(context, state); - if (!shouldContinue) return false; + Future loadImage( + BuildContext context, State state, bool unsavedChanges) async { + if (unsavedChanges) { + final shouldContinue = await handleUnsavedChanges(context, state); + if (!shouldContinue) return false; + } if (Platform.isIOS) { if (!state.mounted) return false; final location = await showLoadImageDialog(context); @@ -77,7 +92,7 @@ class IOHandler { case ImageLocation.photos: return _loadFromPhotos(); case ImageLocation.files: - return _loadFromFiles(); + return loadFromFiles(null); } } @@ -100,22 +115,25 @@ class IOHandler { ); } - Future _loadFromFiles() async { + Future loadFromFiles(Result? file) async { final loadImage = ref.read(LoadImageFromFileManager.provider); - final result = await loadImage(); + final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); + + final result = await loadImage(file); return result.when( ok: (imageFromFile) async { final canvasStateNotifier = ref.read(CanvasState.provider.notifier); + imageFromFile.rasterImage == null + ? canvasStateNotifier.clearBackgroundImageAndResetDimensions() + : canvasStateNotifier + .setBackgroundImage(imageFromFile.rasterImage!); if (imageFromFile.catrobatImage != null) { final commands = imageFromFile.catrobatImage!.commands; canvasStateNotifier.resetCanvasWithNewCommands(commands); } else { canvasStateNotifier.resetCanvasWithNewCommands([]); } - imageFromFile.rasterImage == null - ? canvasStateNotifier.clearBackgroundImageAndResetDimensions() - : canvasStateNotifier - .setBackgroundImage(imageFromFile.rasterImage!); + workspaceStateNotifier.updateLastSavedCommandCount(); return true; }, err: (failure) { @@ -128,12 +146,14 @@ class IOHandler { } Future _saveImageWith(ImageMetaData imageData) async { + bool isImageSaved = false; if (imageData is JpgMetaData || imageData is PngMetaData) { - return _saveAsRasterImage(imageData); + isImageSaved = await _saveAsRasterImage(imageData); } else if (imageData is CatrobatImageMetaData) { - return _saveAsCatrobatImage(imageData); + final savedFile = await _saveAsCatrobatImage(imageData, false); + isImageSaved = (savedFile != null); } - return false; + return isImageSaved; } Future _saveAsRasterImage(ImageMetaData imageData) async { @@ -152,7 +172,32 @@ class IOHandler { ); } - Future _saveAsCatrobatImage(CatrobatImageMetaData imageData) async { + Future getPreviewPath(ImageMetaData imageData) async { + final image = await ref + .read(RenderImageForExport.provider) + .call(keepTransparency: imageData.format != ImageFormat.jpg); + final fileService = ref.watch(IFileService.provider); + final pngImage = await ref.read(IImageService.provider).exportAsPng(image); + return pngImage.when( + ok: (img) async { + final previewFile = + await fileService.saveToApplicationDirectory(imageData.name, img); + return previewFile.when( + ok: (file) => file.path, + err: (failure) { + showToast(failure.message); + return null; + }, + ); + }, + err: (failure) { + showToast(failure.message); + return null; + }, + ); + } + + Future _saveAsCatrobatImage(CatrobatImageMetaData imageData, bool isAProject) async { final commands = ref.read(CommandManager.provider).history; final canvasState = ref.read(CanvasState.provider); final imgWidth = canvasState.size.width.toInt(); @@ -160,15 +205,15 @@ class IOHandler { final catrobatImage = CatrobatImage( commands, imgWidth, imgHeight, canvasState.backgroundImage); final saveAsCatrobatImage = ref.read(SaveAsCatrobatImage.provider); - final result = await saveAsCatrobatImage(imageData, catrobatImage); + final result = await saveAsCatrobatImage(imageData, catrobatImage, isAProject); return result.when( ok: (file) { showToast("Saved successfully"); - return true; + return file; }, err: (failure) { showToast(failure.message); - return false; + return null; }, ); } diff --git a/lib/ui/landing_page.dart b/lib/ui/landing_page.dart new file mode 100644 index 00000000..cd8dfaa2 --- /dev/null +++ b/lib/ui/landing_page.dart @@ -0,0 +1,319 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_styled_toast/flutter_styled_toast.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:oxidized/oxidized.dart'; +import 'package:paintroid/data/model/project.dart'; +import 'package:paintroid/data/project_database.dart'; +import 'package:intl/intl.dart'; +import 'package:paintroid/io/io.dart'; +import 'package:paintroid/ui/project_overflow_menu.dart'; + +import '../workspace/src/state/canvas_state_notifier.dart'; +import '../workspace/src/state/workspace_state_notifier.dart'; +import 'color_schemes.dart'; +import 'io_handler.dart'; + +class LandingPage extends ConsumerStatefulWidget { + final String title; + + const LandingPage({Key? key, required this.title}) : super(key: key); + + @override + ConsumerState createState() => _LandingPageState(); +} + +class _LandingPageState extends ConsumerState { + late ProjectDatabase database; + late IFileService fileService; + late IImageService imageService; + + Future> _getProjects() async => + database.projectDAO.getProjects(); + + void _navigateToPocketPaint() async { + await Navigator.pushNamed(context, '/PocketPaint'); + setState(() {}); + } + + Future _loadProject(IOHandler ioHandler, Project project) async { + project.lastModified = DateTime.now(); + await database.projectDAO.insertProject(project); + return fileService.getFile(project.path).when( + ok: (file) async { + return await ioHandler.loadFromFiles(Result.ok(file)); + }, + err: (failure) { + if (failure != LoadImageFailure.userCancelled) { + showToast(failure.message); + } + return false; + }, + ); + } + + void _clearCanvas() { + ref.read(CanvasState.provider.notifier) + ..clearBackgroundImageAndResetDimensions() + ..resetCanvasWithNewCommands([]); + ref.read(WorkspaceState.provider.notifier).updateLastSavedCommandCount(); + } + + void _openProject(Project project, IOHandler ioHandler) async { + bool loaded = await _loadProject(ioHandler, project); + if (loaded) _navigateToPocketPaint(); + } + + @override + Widget build(BuildContext context) { + final db = ref.watch(ProjectDatabase.provider); + db.when( + data: (value) => database = value, + error: (err, stacktrace) => showToast("Error: $err"), + loading: () {}, + ); + final ioHandler = ref.watch(IOHandler.provider); + final size = MediaQuery.of(context).size; + final DateFormat dateFormat = DateFormat('dd-MM-yyyy'); + Project? latestModifiedProject; + fileService = ref.watch(IFileService.provider); + imageService = ref.watch(IImageService.provider); + + return Scaffold( + backgroundColor: lightColorScheme.primary, + appBar: AppBar( + title: Text(widget.title), + ), + body: FutureBuilder( + future: _getProjects(), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + if (snapshot.data!.isNotEmpty) { + latestModifiedProject = snapshot.data![0]; + } + return Column( + children: [ + SizedBox( + height: size.height / 3, + child: Stack( + children: [ + Material( + child: InkWell( + onTap: () async { + if (latestModifiedProject != null) { + _openProject(latestModifiedProject!, ioHandler); + } + }, + child: _ImagePreview( + project: latestModifiedProject, + imageService: imageService, + color: Colors.white54, + ), + ), + ), + Center( + child: IconButton( + key: const Key('myEditIcon'), + iconSize: 264, + onPressed: () async { + if (latestModifiedProject != null) { + _openProject(latestModifiedProject!, ioHandler); + } + }, + icon: SvgPicture.asset( + "assets/svg/ic_edit_circle.svg", + height: 264, + width: 264, + ), + ), + ), + Align( + alignment: AlignmentDirectional.topEnd, + child: latestModifiedProject == null + ? null + : ProjectOverflowMenu( + key: const Key('ProjectOverflowMenu Key0'), + project: latestModifiedProject!, + ), + ), + ], + ), + ), + SizedBox( + child: Container( + color: lightColorScheme.primaryContainer, + width: size.width, + padding: const EdgeInsets.all(20), + child: const Align( + alignment: Alignment.centerLeft, + child: Text( + "My Projects", + textAlign: TextAlign.start, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + color: Color(0xFFFFFFFF)), + ), + ), + ), + ), + Flexible( + child: ListView.builder( + itemBuilder: (context, position) { + if (position != 0) { + Project project = snapshot.data![position]; + return Card( + child: ListTile( + leading: _ImagePreview( + project: project, + imageService: imageService, + width: 80, + color: Colors.white, + ), + dense: false, + title: Text( + project.name, + style: const TextStyle(color: Color(0xFFFFFFFF)), + ), + subtitle: Text( + 'last modified: ${dateFormat.format(project.lastModified)}', + style: const TextStyle(color: Color(0xFFFFFFFF)), + ), + trailing: ProjectOverflowMenu( + key: Key('ProjectOverflowMenu Key$position'), + project: project, + ), + enabled: true, + onTap: () async => _openProject(project, ioHandler), + ), + ); + } + return const Card(); + }, + itemCount: snapshot.data?.length, + scrollDirection: Axis.vertical, + shrinkWrap: true, + ), + ), + ], + ); + } else { + return Center( + child: CircularProgressIndicator( + backgroundColor: lightColorScheme.background, + ), + ); + } + }, + ), + floatingActionButton: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _LandingPageFAB( + heroTag: "import_image", + icon: Icons.file_download, + onPressed: () async { + final bool imageLoaded = + await ioHandler.loadImage(context, this, false); + if (imageLoaded && mounted) { + _navigateToPocketPaint(); + } + }, + ), + const SizedBox( + height: 10, + ), + _LandingPageFAB( + heroTag: "new_image", + icon: Icons.add, + onPressed: () async { + _clearCanvas(); + _navigateToPocketPaint(); + }, + ), + ], + ), + ); + } +} + +class _LandingPageFAB extends StatelessWidget { + final String heroTag; + final IconData icon; + final VoidCallback onPressed; + + const _LandingPageFAB({ + Key? key, + required this.heroTag, + required this.icon, + required this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + heroTag: heroTag, + backgroundColor: const Color(0xFFFFAB08), + foregroundColor: const Color(0xFFFFFFFF), + child: Icon(icon), + onPressed: () async => onPressed(), + ); + } +} + +class _ImagePreview extends StatelessWidget { + final Project? project; + final double? width; + final Color color; + final IImageService imageService; + + const _ImagePreview({ + Key? key, + this.width, + required this.color, + this.project, + required this.imageService, + }) : super(key: key); + + ImageProvider _getProjectPreviewImageProvider(Uint8List img) => + Image.memory(img, fit: BoxFit.cover).image; + + Uint8List? _getProjectPreview(String? path) => + imageService.getProjectPreview(path).when( + ok: (preview) => preview, + err: (failure) { + showToast(failure.message); + return null; + }, + ); + + @override + Widget build(BuildContext context) { + Uint8List? img; + if (project != null) { + img = _getProjectPreview(project!.imagePreviewPath); + } + var imgPreview = BoxDecoration(color: color); + if (img != null) { + imgPreview = BoxDecoration( + color: color, + image: DecorationImage( + image: _getProjectPreviewImageProvider(img), + ), + ); + } + if (width != null) { + return Container( + width: width!, + decoration: imgPreview, + ); + } else { + return Container( + decoration: imgPreview, + ); + } + } +} diff --git a/lib/ui/overflow_menu.dart b/lib/ui/overflow_menu.dart index 28fa4be6..7d6a540d 100644 --- a/lib/ui/overflow_menu.dart +++ b/lib/ui/overflow_menu.dart @@ -1,11 +1,18 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:paintroid/ui/io_handler.dart'; import 'package:paintroid/workspace/workspace.dart'; +import '../data/model/project.dart'; +import '../data/project_database.dart'; +import '../io/src/ui/save_image_dialog.dart'; + enum OverflowMenuOption { fullscreen("Fullscreen"), saveImage("Save Image"), + saveProject("Save Project"), loadImage("Load Image"), newImage("New Image"); @@ -50,8 +57,11 @@ class _OverflowMenuState extends ConsumerState { case OverflowMenuOption.saveImage: ioHandler.saveImage(context); break; + case OverflowMenuOption.saveProject: + _saveProject(); + break; case OverflowMenuOption.loadImage: - ioHandler.loadImage(context, this); + ioHandler.loadImage(context, this, true); break; case OverflowMenuOption.newImage: ioHandler.newImage(context, this); @@ -61,4 +71,29 @@ class _OverflowMenuState extends ConsumerState { void _enterFullscreen() => ref.read(WorkspaceState.provider.notifier).toggleFullscreen(true); + + void _saveProject() async { + File? savedProject; + final imageData = await showSaveImageDialog(context, true); + + if (imageData != null && mounted) { + savedProject = await ioHandler.saveProject(imageData); + if (savedProject != null) { + String? imagePreview = await ioHandler.getPreviewPath(imageData); + Project project = Project( + name: imageData.name, + path: savedProject.path, + lastModified: DateTime.now(), + creationDate: DateTime.now(), + resolution: "", + format: imageData.format.name, + size: await savedProject.length(), + imagePreviewPath: imagePreview, + ); + + final db = await ref.read(ProjectDatabase.provider.future); + await db.projectDAO.insertProject(project); + } + } + } } diff --git a/lib/ui/pocket_paint.dart b/lib/ui/pocket_paint.dart index 25289c2a..d52b4842 100644 --- a/lib/ui/pocket_paint.dart +++ b/lib/ui/pocket_paint.dart @@ -1,15 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/io/io.dart'; import 'package:paintroid/ui/top_app_bar.dart'; import 'package:paintroid/workspace/workspace.dart'; import 'bottom_control_navigation_bar.dart'; import 'exit_fullscreen_button.dart'; +import 'io_handler.dart'; -class PocketPaint extends ConsumerWidget { +class PocketPaint extends ConsumerStatefulWidget { const PocketPaint({Key? key}) : super(key: key); + @override + ConsumerState createState() => _PocketPaintState(); +} + +class _PocketPaintState extends ConsumerState { void _toggleStatusBar(bool isFullscreen) { SystemChrome.setEnabledSystemUIMode( isFullscreen ? SystemUiMode.immersiveSticky : SystemUiMode.manual, @@ -18,7 +25,7 @@ class PocketPaint extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final isFullscreen = ref.watch( WorkspaceState.provider.select((state) => state.isFullscreen), ); @@ -26,11 +33,26 @@ class PocketPaint extends ConsumerWidget { WorkspaceState.provider.select((state) => state.isFullscreen), (_, isFullscreen) => _toggleStatusBar(isFullscreen), ); + final ioHandler = ref.watch(IOHandler.provider); + return WillPopScope( onWillPop: () async { - final willPop = !isFullscreen; + var willPop = !isFullscreen; if (isFullscreen) { ref.read(WorkspaceState.provider.notifier).toggleFullscreen(false); + } else { + final workspaceStateNotifier = ref.watch(WorkspaceState.provider.notifier); + if (!workspaceStateNotifier.hasSavedLastWork) { + final shouldDiscard = await showDiscardChangesDialog(context); + if (shouldDiscard != null) { + if (!shouldDiscard && mounted) { + ioHandler.saveImage(context); + } + willPop = shouldDiscard; + } else { + willPop = false; + } + } } return willPop; }, diff --git a/lib/ui/project_overflow_menu.dart b/lib/ui/project_overflow_menu.dart new file mode 100644 index 00000000..39e38996 --- /dev/null +++ b/lib/ui/project_overflow_menu.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_styled_toast/flutter_styled_toast.dart'; +import 'package:paintroid/data/project_database.dart'; +import 'package:paintroid/io/src/ui/delete_project_dialog.dart'; +import 'package:paintroid/io/src/ui/project_details_dialog.dart'; + +import '../data/model/project.dart'; + +enum ProjectOverflowMenuOption { + deleteProject("Delete"), + getDetails("Details"); + + const ProjectOverflowMenuOption(this.label); + + final String label; +} + +class ProjectOverflowMenu extends ConsumerStatefulWidget { + final Project project; + + const ProjectOverflowMenu({Key? key, required this.project}) + : super(key: key); + + @override + ConsumerState createState() => + _ProjectOverFlowMenuState(); +} + +class _ProjectOverFlowMenuState extends ConsumerState { + late ProjectDatabase database; + + @override + Widget build(BuildContext context) { + final db = ref.watch(ProjectDatabase.provider); + db.when( + data: (value) => database = value, + error: (err, stacktrace) => showToast("Error: $err"), + loading: () {}, + ); + + return PopupMenuButton( + color: Theme.of(context).colorScheme.background, + icon: const Icon(Icons.more_vert), + shape: RoundedRectangleBorder( + side: const BorderSide(), + borderRadius: BorderRadius.circular(20), + ), + onSelected: _handleSelectedOption, + itemBuilder: (BuildContext context) => ProjectOverflowMenuOption.values + .map((option) => + PopupMenuItem(value: option, child: Text(option.label))) + .toList(), + ); + } + + void _handleSelectedOption(ProjectOverflowMenuOption option) { + switch (option) { + case ProjectOverflowMenuOption.deleteProject: + _deleteProject(); + break; + case ProjectOverflowMenuOption.getDetails: + _showProjectDetails(); + break; + } + } + + void _deleteProject() async { + bool? shouldDelete = await showDeleteDialog(context, widget.project.name); + if (shouldDelete != null && shouldDelete) { + try { + final projectFile = File(widget.project.path); + await projectFile.delete(); + if (widget.project.imagePreviewPath != null) { + final previewFile = File(widget.project.imagePreviewPath!); + await previewFile.delete(); + } + } catch (err) { + showToast(err.toString()); + } + if (widget.project.id != null) { + await database.projectDAO.deleteProject(widget.project.id!); + ref.refresh(ProjectDatabase.provider); + } + } + } + + void _showProjectDetails() async { + await showDetailsDialog(context, widget.project); + } +} diff --git a/pubspec.lock b/pubspec.lock index d04ce4f2..e5abfae5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "40.0.0" + version: "43.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "4.1.0" + version: "4.3.1" archive: dependency: transitive description: @@ -98,7 +98,7 @@ packages: name: built_value url: "https://pub.dartlang.org" source: hosted - version: "8.3.3" + version: "8.4.0" characters: dependency: transitive description: @@ -133,7 +133,7 @@ packages: name: code_builder url: "https://pub.dartlang.org" source: hosted - version: "4.1.0" + version: "4.2.0" collection: dependency: transitive description: @@ -204,6 +204,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.6.1" + filesize: + dependency: "direct main" + description: + name: filesize + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" fixnum: dependency: transitive description: @@ -211,6 +218,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + floor: + dependency: "direct main" + description: + name: floor + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + floor_annotation: + dependency: transitive + description: + name: floor_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + floor_generator: + dependency: "direct dev" + description: + name: floor_generator + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" flutter: dependency: "direct main" description: flutter @@ -246,7 +274,7 @@ packages: name: flutter_plugin_android_lifecycle url: "https://pub.dartlang.org" source: hosted - version: "2.0.6" + version: "2.0.7" flutter_riverpod: dependency: "direct main" description: @@ -267,7 +295,7 @@ packages: name: flutter_svg url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1+1" flutter_test: dependency: "direct dev" description: flutter @@ -366,14 +394,14 @@ packages: name: image_picker_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.5.0" + version: "2.6.0" integration_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl url: "https://pub.dartlang.org" @@ -399,7 +427,7 @@ packages: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "4.5.0" + version: "4.6.0" lints: dependency: transitive description: @@ -407,6 +435,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + lists: + dependency: transitive + description: + name: lists + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" logging: dependency: "direct main" description: @@ -448,7 +483,7 @@ packages: name: mockito url: "https://pub.dartlang.org" source: hosted - version: "5.2.0" + version: "5.3.0" oxidized: dependency: "direct main" description: @@ -497,14 +532,14 @@ packages: name: path_provider_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.16" + version: "2.0.17" path_provider_ios: dependency: transitive description: name: path_provider_ios url: "https://pub.dartlang.org" source: hosted - version: "2.0.10" + version: "2.0.11" path_provider_linux: dependency: transitive description: @@ -637,7 +672,7 @@ packages: name: shelf url: "https://pub.dartlang.org" source: hosted - version: "1.3.1" + version: "1.3.2" shelf_web_socket: dependency: transitive description: @@ -664,6 +699,34 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1+1" + sqflite_common_ffi: + dependency: transitive + description: + name: sqflite_common_ffi + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1+1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.2" stack_trace: dependency: transitive description: @@ -699,6 +762,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + strings: + dependency: transitive + description: + name: strings + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.2" sync_http: dependency: transitive description: @@ -706,6 +776,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.0" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+2" term_glyph: dependency: transitive description: @@ -734,6 +811,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + unicode: + dependency: transitive + description: + name: unicode + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.1" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 24966145..8075ab4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,10 @@ dependencies: logging: ^1.0.2 file_picker: ^4.6.1 flutter_styled_toast: ^2.1.3 + floor: ^1.2.0 + intl: ^0.17.0 + filesize: ^2.0.1 + sqflite: permission_handler: ^10.0.0 dev_dependencies: @@ -34,6 +38,7 @@ dev_dependencies: build_runner: ^2.2.0 flutter_launcher_icons: ^0.9.3 flutter_lints: ^2.0.1 + floor_generator: ^1.2.0 flutter: uses-material-design: true diff --git a/test/unit/data/project_database_test.dart b/test/unit/data/project_database_test.dart new file mode 100644 index 00000000..30e0a56e --- /dev/null +++ b/test/unit/data/project_database_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:paintroid/data/model/project.dart'; +import 'package:paintroid/data/project_database.dart'; +import 'package:paintroid/data/typeconverters/date_time_converter.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + late final ProjectDatabase database; + late final List projectList; + late final DateTime date; + + setUpAll(() async { + database = + await $FloorProjectDatabase.databaseBuilder("test_database.db").build(); + projectList = []; + date = + DateTimeConverter().decode(DateTimeConverter().encode(DateTime.now())); + }); + + Project _createProject(String name) => Project( + name: name, + path: "testPath", + lastModified: date, + creationDate: date, + ); + + test('Should provide valid Database', () async { + final container = ProviderContainer(); + container + .read(ProjectDatabase.provider) + .whenData((db) => expect(db, isA)); + }); + + test('Should insert the project to the database', () async { + Project project = _createProject("testProject"); + projectList.add(project); + final result = await database.projectDAO.insertProject(project); + expect(result, isA()); + }); + + test('Should insert the projects to the database', () async { + List projects = []; + for (int i = 1; i <= 5; i++) { + final project = _createProject("test$i"); + projects.add(project); + projectList.add(project); + } + final result = await database.projectDAO.insertProjects(projects); + expect(result, isA>()); + }); + + test('Should fetch the saved projects from the database', () async { + final projects = await database.projectDAO.getProjects(); + final len = projects.length; + + expect(len, projectList.length); + for (int i = 0; i < len; i++) { + final actualProject = projects[i]; + final expectedProject = projectList[i]; + expect(actualProject.name, expectedProject.name); + expect(actualProject.path, expectedProject.path); + expect(actualProject.lastModified, expectedProject.lastModified); + expect(actualProject.creationDate, expectedProject.creationDate); + expect(actualProject.resolution, expectedProject.resolution); + expect(actualProject.format, expectedProject.format); + expect(actualProject.size, expectedProject.size); + expect(actualProject.imagePreviewPath, expectedProject.imagePreviewPath); + expect(actualProject.id, isA()); + } + }); + + test('Should delete the project from the database', () async { + List projects = await database.projectDAO.getProjects(); + final project = projects[3]; + projectList.removeAt(3); + await database.projectDAO.deleteProject(project.id!); + + projects = await database.projectDAO.getProjects(); + final len = projects.length; + expect(len, projectList.length); + + for (int i = 0; i < len; i++) { + final actualProject = projects[i]; + final expectedProject = projectList[i]; + expect(actualProject.name, expectedProject.name); + expect(actualProject.path, expectedProject.path); + expect(actualProject.lastModified, expectedProject.lastModified); + expect(actualProject.creationDate, expectedProject.creationDate); + expect(actualProject.resolution, expectedProject.resolution); + expect(actualProject.format, expectedProject.format); + expect(actualProject.size, expectedProject.size); + expect(actualProject.imagePreviewPath, expectedProject.imagePreviewPath); + expect(actualProject.id, isA()); + } + }); + + test('Should delete all the projects from the database', () async { + var projects = await database.projectDAO.getProjects(); + await database.projectDAO.deleteProjects(projects); + projects = await database.projectDAO.getProjects(); + expect(projects.length, 0); + }); + + tearDownAll(() async { + projectList.clear(); + }); +} diff --git a/test/unit/io/service/file_service_test.dart b/test/unit/io/service/file_service_test.dart new file mode 100644 index 00000000..a4a2615b --- /dev/null +++ b/test/unit/io/service/file_service_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:paintroid/io/src/service/file_service.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FileService sut; + const path = 'test/fixture/image/test.png'; + final testPngFile = await rootBundle.load(path); + const testDirectory = './test/fixture/image'; + const channel = MethodChannel( + 'plugins.flutter.io/path_provider_macos', + ); + channel + .setMockMethodCallHandler((MethodCall methodCall) async => testDirectory); + + setUp(() async { + sut = FileService(); + }); + + test('Should provide valid FileService', () async { + final container = ProviderContainer(); + final imageService = container.read(IFileService.provider); + expect(imageService, isA()); + }); + + test('Should return file', () { + final result = sut.getFile(path); + final file = result.unwrapOrElse((failure) => fail(failure.message)); + expect(file, isA()); + }); + + test('Should save file to Application directory', () async { + final result = await sut.saveToApplicationDirectory( + "test1.png", + testPngFile.buffer.asUint8List(), + ); + final file = result.unwrapOrElse((failure) => fail(failure.message)); + expect(file, isA()); + }); +} diff --git a/test/unit/io/service/image_service_test.dart b/test/unit/io/service/image_service_test.dart index 3a39e65c..00bcfc45 100644 --- a/test/unit/io/service/image_service_test.dart +++ b/test/unit/io/service/image_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,4 +36,11 @@ void main() async { expect(img.height, equals(50)); }); }); + + test('Should return project preview', () { + const path = 'test/fixture/image/test.png'; + final result = sut.getProjectPreview(path); + final imgPreview = result.unwrapOrElse((failure) => fail(failure.message)); + expect(imgPreview, isA()); + }); } diff --git a/test/widget/ui/landing_page_test.dart b/test/widget/ui/landing_page_test.dart new file mode 100644 index 00000000..97ec8f65 --- /dev/null +++ b/test/widget/ui/landing_page_test.dart @@ -0,0 +1,308 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:oxidized/oxidized.dart'; +import 'package:paintroid/data/model/project.dart'; +import 'package:paintroid/data/project_dao.dart'; +import 'package:paintroid/data/project_database.dart'; +import 'package:paintroid/io/io.dart'; +import 'package:paintroid/io/src/ui/delete_project_dialog.dart'; +import 'package:paintroid/io/src/ui/project_details_dialog.dart'; +import 'package:paintroid/main.dart'; +import 'package:paintroid/ui/overflow_menu.dart'; +import 'package:paintroid/ui/project_overflow_menu.dart'; +import 'package:paintroid/ui/top_app_bar.dart'; + +import 'landing_page_test.mocks.dart'; + +@GenerateMocks([ProjectDatabase, ProjectDAO, IImageService, IFileService]) +void main() { + late Widget sut; + late ProjectDatabase database; + late ProjectDAO dao; + late IImageService imageService; + late IFileService fileService; + late List projects; + final date = DateTime.now(); + const filePath = 'test/fixture/image/test.jpg'; + final testFile = File(filePath); + late ui.Image dummyImage; + final DateFormat formatter = DateFormat('dd-MM-yyyy HH:mm:ss'); + + Project _createProject(String name) => Project( + name: name, + path: filePath, + imagePreviewPath: filePath, + lastModified: date, + creationDate: date, + ); + + setUp(() async { + database = MockProjectDatabase(); + dao = MockProjectDAO(); + imageService = MockIImageService(); + fileService = MockIFileService(); + sut = ProviderScope( + overrides: [ + ProjectDatabase.provider.overrideWithValue(AsyncData(database)), + IImageService.provider.overrideWithValue(imageService), + IFileService.provider.overrideWithValue(fileService), + ], + child: const PocketPaintApp(), + ); + projects = List.generate(5, (index) => _createProject('project$index')); + dummyImage = await createTestImage(width: 1080, height: 1920); + }); + + testWidgets('Should have a top app bar', (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + expect(find.byType(AppBar), findsOneWidget); + }); + + testWidgets( + 'Should have the title "Pocket Paint" in app bar', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + final titleFinder = find.widgetWithText(AppBar, "Pocket Paint"); + expect(titleFinder, findsOneWidget); + }, + ); + + testWidgets( + 'Should have the two FABs', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + expect( + find.widgetWithIcon(FloatingActionButton, Icons.add), + findsOneWidget, + ); + expect( + find.widgetWithIcon(FloatingActionButton, Icons.file_download), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'Should have "My Projects" section', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + expect(find.text('My Projects'), findsOneWidget); + }, + ); + + testWidgets( + 'Should not show ProjectOverflowMenu', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + expect(find.byType(ProjectOverflowMenu), findsNothing); + }, + ); + + testWidgets( + 'Should show projects in the list', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value(projects)); + when(imageService.getProjectPreview(filePath)) + .thenReturn(Result.ok(testFile.readAsBytesSync())); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + verify(imageService.getProjectPreview(filePath)).called(5); + expect(find.byType(ProjectOverflowMenu), findsNWidgets(5)); + final DateFormat dateFormat = DateFormat('dd-MM-yyyy'); + expect(find.text('last modified: ${dateFormat.format(date)}'), + findsNWidgets(4)); + for (int i = 1; i < 5; i++) { + expect(find.text(projects[i].name), findsOneWidget); + } + }, + ); + + testWidgets( + 'Should have "Delete" and "Details" options in ProjectOverflowMenu', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value(projects)); + when(imageService.getProjectPreview(filePath)) + .thenReturn(Result.ok(testFile.readAsBytesSync())); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + verify(imageService.getProjectPreview(filePath)).called(5); + + expect(find.byType(ProjectOverflowMenu), findsNWidgets(5)); + + const position = 1; + final overflowMenu = + find.byKey(const Key('ProjectOverflowMenu Key$position')); + expect(overflowMenu, findsOneWidget); + await tester.tap(overflowMenu); + await tester.pumpAndSettle(); + + expect(find.text('Delete'), findsOneWidget); + expect(find.text('Details'), findsOneWidget); + }, + ); + + testWidgets( + 'Should show ProjectDetailsDialog', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value(projects)); + when(imageService.getProjectPreview(filePath)) + .thenReturn(Result.ok(testFile.readAsBytesSync())); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + verify(imageService.getProjectPreview(filePath)).called(5); + + const position = 1; + final overflowMenu = + find.byKey(const Key('ProjectOverflowMenu Key$position')); + expect(overflowMenu, findsOneWidget); + await tester.tap(overflowMenu); + await tester.pumpAndSettle(); + + final detailsOption = find.text('Details'); + expect(detailsOption, findsOneWidget); + + when(imageService.getProjectPreview(filePath)) + .thenReturn(Result.ok(testFile.readAsBytesSync())); + when(fileService.getFile(filePath)).thenReturn(Result.ok(testFile)); + when(imageService.import(testFile.readAsBytesSync())) + .thenAnswer((_) => Future.value(Result.ok(dummyImage))); + await tester.tap(detailsOption); + await tester.pumpAndSettle(); + verify(imageService.getProjectPreview(filePath)); + verify(fileService.getFile(filePath)); + verify(imageService.import(testFile.readAsBytesSync())); + + expect(find.widgetWithText(ProjectDetailsDialog, 'project$position'), + findsOneWidget); + expect(find.text('Resolution: 1080 X 1920'), findsOneWidget); + expect(find.text('Last modified: ${formatter.format(date)}'), + findsOneWidget); + expect(find.text('Creation date: ${formatter.format(date)}'), + findsOneWidget); + expect(find.text('Size: ${filesize(testFile.lengthSync())}'), + findsOneWidget); + + final okButton = find.widgetWithText(ElevatedButton, 'OK'); + expect(okButton, findsOneWidget); + await tester.tap(okButton); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ProjectDetailsDialog, 'project$position'), + findsNothing); + }, + ); + + testWidgets( + 'Should show DeleteProjectDialog', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value(projects)); + when(imageService.getProjectPreview(filePath)) + .thenReturn(Result.ok(testFile.readAsBytesSync())); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + verify(imageService.getProjectPreview(filePath)).called(5); + + const position = 1; + final overflowMenu = + find.byKey(const Key('ProjectOverflowMenu Key$position')); + expect(overflowMenu, findsOneWidget); + await tester.tap(overflowMenu); + await tester.pumpAndSettle(); + + final deleteOption = find.text('Delete'); + expect(deleteOption, findsOneWidget); + + await tester.tap(deleteOption); + await tester.pumpAndSettle(); + + final deleteProjectDialog = + find.widgetWithText(DeleteProjectDialog, 'Delete project$position'); + expect(deleteProjectDialog, findsOneWidget); + expect(find.text('Do you really want to delete your project?'), + findsOneWidget); + final cancelButton = find.widgetWithText(ElevatedButton, 'Cancel'); + final deleteButton = find.widgetWithText(TextButton, 'Delete'); + expect(cancelButton, findsOneWidget); + expect(deleteButton, findsOneWidget); + await tester.tap(cancelButton); + await tester.pumpAndSettle(); + expect(deleteProjectDialog, findsNothing); + }, + ); + + testWidgets( + 'Should open PocketPaint widget and return back to Landing page', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + + final addButton = find.widgetWithIcon(FloatingActionButton, Icons.add); + await tester.tap(addButton); + await tester.pumpAndSettle(); + + expect(find.byType(TopAppBar), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + + final titleFinder = find.widgetWithText(TopAppBar, "Pocket Paint"); + expect(titleFinder, findsOneWidget); + + final overflowMenuButtonFinder = find.widgetWithIcon( + PopupMenuButton, + Icons.more_vert, + ); + expect(overflowMenuButtonFinder, findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text('My Projects'), findsOneWidget); + }, + ); +}