From fd10a28d51a2b0d69e3a12f26fd8081f3cb0e283 Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Wed, 17 Aug 2022 15:55:17 +0530 Subject: [PATCH 01/12] PAINTROID-442 implemented basic functionality of the landing page --- assets/svg/ic_edit_circle.svg | 10 + lib/data/model/project.dart | 29 ++ lib/data/project_dao.dart | 21 ++ lib/data/project_database.dart | 20 ++ lib/data/project_database.g.dart | 182 +++++++++++ .../typeconverters/date_time_converter.dart | 13 + lib/io/src/service/file_service.dart | 22 ++ lib/io/src/ui/delete_project_dialog.dart | 39 +++ lib/io/src/ui/project_details_dialog.dart | 59 ++++ lib/io/src/ui/save_image_dialog.dart | 40 ++- .../usecase/load_image_from_file_manager.dart | 13 +- lib/main.dart | 65 ++-- lib/ui/io_handler.dart | 102 ++++-- lib/ui/landing_page.dart | 291 ++++++++++++++++++ lib/ui/overflow_menu.dart | 43 ++- lib/ui/pocket_paint.dart | 16 +- lib/ui/project_overflow_menu.dart | 83 +++++ pubspec.lock | 103 ++++++- pubspec.yaml | 4 + 19 files changed, 1067 insertions(+), 88 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/project_database.g.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 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..2caff99a --- /dev/null +++ b/lib/data/model/project.dart @@ -0,0 +1,29 @@ +import 'dart:typed_data'; + +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..5bf10ce4 --- /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); + + @delete + Future deleteProject(Project project); + + @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..bc9d1b2d --- /dev/null +++ b/lib/data/project_database.dart @@ -0,0 +1,20 @@ +import 'dart:async'; +import 'dart:typed_data'; + +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/project_database.g.dart b/lib/data/project_database.g.dart new file mode 100644 index 00000000..3a6084f4 --- /dev/null +++ b/lib/data/project_database.g.dart @@ -0,0 +1,182 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'project_database.dart'; + +// ************************************************************************** +// FloorGenerator +// ************************************************************************** + +// ignore: avoid_classes_with_only_static_members +class $FloorProjectDatabase { + /// Creates a database builder for a persistent database. + /// Once a database is built, you should keep a reference to it and re-use it. + static _$ProjectDatabaseBuilder databaseBuilder(String name) => + _$ProjectDatabaseBuilder(name); + + /// Creates a database builder for an in memory database. + /// Information stored in an in memory database disappears when the process is killed. + /// Once a database is built, you should keep a reference to it and re-use it. + static _$ProjectDatabaseBuilder inMemoryDatabaseBuilder() => + _$ProjectDatabaseBuilder(null); +} + +class _$ProjectDatabaseBuilder { + _$ProjectDatabaseBuilder(this.name); + + final String? name; + + final List _migrations = []; + + Callback? _callback; + + /// Adds migrations to the builder. + _$ProjectDatabaseBuilder addMigrations(List migrations) { + _migrations.addAll(migrations); + return this; + } + + /// Adds a database [Callback] to the builder. + _$ProjectDatabaseBuilder addCallback(Callback callback) { + _callback = callback; + return this; + } + + /// Creates the database and initializes it. + Future build() async { + final path = name != null + ? await sqfliteDatabaseFactory.getDatabasePath(name!) + : ':memory:'; + final database = _$ProjectDatabase(); + database.database = await database.open( + path, + _migrations, + _callback, + ); + return database; + } +} + +class _$ProjectDatabase extends ProjectDatabase { + _$ProjectDatabase([StreamController? listener]) { + changeListener = listener ?? StreamController.broadcast(); + } + + ProjectDAO? _projectDAOInstance; + + Future open(String path, List migrations, + [Callback? callback]) async { + final databaseOptions = sqflite.OpenDatabaseOptions( + version: 1, + onConfigure: (database) async { + await database.execute('PRAGMA foreign_keys = ON'); + await callback?.onConfigure?.call(database); + }, + onOpen: (database) async { + await callback?.onOpen?.call(database); + }, + onUpgrade: (database, startVersion, endVersion) async { + await MigrationAdapter.runMigrations( + database, startVersion, endVersion, migrations); + + await callback?.onUpgrade?.call(database, startVersion, endVersion); + }, + onCreate: (database, version) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `Project` (`name` TEXT NOT NULL, `path` TEXT NOT NULL, `lastModified` INTEGER NOT NULL, `creationDate` INTEGER NOT NULL, `resolution` TEXT, `format` TEXT, `size` INTEGER, `imagePreviewPath` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT)'); + + await callback?.onCreate?.call(database, version); + }, + ); + return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); + } + + @override + ProjectDAO get projectDAO { + return _projectDAOInstance ??= _$ProjectDAO(database, changeListener); + } +} + +class _$ProjectDAO extends ProjectDAO { + _$ProjectDAO(this.database, this.changeListener) + : _queryAdapter = QueryAdapter(database), + _projectInsertionAdapter = InsertionAdapter( + database, + 'Project', + (Project item) => { + 'name': item.name, + 'path': item.path, + 'lastModified': _dateTimeConverter.encode(item.lastModified), + 'creationDate': _dateTimeConverter.encode(item.creationDate), + 'resolution': item.resolution, + 'format': item.format, + 'size': item.size, + 'imagePreviewPath': item.imagePreviewPath, + 'id': item.id + }), + _projectDeletionAdapter = DeletionAdapter( + database, + 'Project', + ['id'], + (Project item) => { + 'name': item.name, + 'path': item.path, + 'lastModified': _dateTimeConverter.encode(item.lastModified), + 'creationDate': _dateTimeConverter.encode(item.creationDate), + 'resolution': item.resolution, + 'format': item.format, + 'size': item.size, + 'imagePreviewPath': item.imagePreviewPath, + 'id': item.id + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _projectInsertionAdapter; + + final DeletionAdapter _projectDeletionAdapter; + + @override + Future> getProjects() async { + return _queryAdapter.queryList( + 'SELECT * FROM Project order by lastModified desc', + mapper: (Map row) => Project( + name: row['name'] as String, + path: row['path'] as String, + lastModified: _dateTimeConverter.decode(row['lastModified'] as int), + creationDate: _dateTimeConverter.decode(row['creationDate'] as int), + resolution: row['resolution'] as String?, + format: row['format'] as String?, + size: row['size'] as int?, + imagePreviewPath: row['imagePreviewPath'] as String?, + id: row['id'] as int?)); + } + + @override + Future insertProject(Project project) { + return _projectInsertionAdapter.insertAndReturnId( + project, OnConflictStrategy.replace); + } + + @override + Future> insertProjects(List projects) { + return _projectInsertionAdapter.insertListAndReturnIds( + projects, OnConflictStrategy.replace); + } + + @override + Future deleteProject(Project project) async { + await _projectDeletionAdapter.delete(project); + } + + @override + Future deleteProjects(List projects) async { + await _projectDeletionAdapter.deleteList(projects); + } +} + +// ignore_for_file: unused_element +final _dateTimeConverter = DateTimeConverter(); 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..2a71ee51 100644 --- a/lib/io/src/service/file_service.dart +++ b/lib/io/src/service/file_service.dart @@ -7,10 +7,14 @@ 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(); static final provider = Provider((ref) => FileService()); @@ -51,4 +55,22 @@ 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); + } + } } 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..cc5d7615 --- /dev/null +++ b/lib/io/src/ui/delete_project_dialog.dart @@ -0,0 +1,39 @@ +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: [_discardButton, _deleteButton], + content: const Text("Do you really want to delete your project?"), + ); + + TextButton get _deleteButton => TextButton( + style: TextButton.styleFrom(primary: Colors.red), + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Delete"), + ); + + ElevatedButton get _discardButton => ElevatedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Cancel", style: TextStyle(color: Colors.white)), + ); +} 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..f8172f91 --- /dev/null +++ b/lib/io/src/ui/project_details_dialog.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_styled_toast/flutter_styled_toast.dart'; + +import '../../../data/model/project.dart'; + +/// Returns [true] if user chose to delete the project or [null] if user +/// dismiss the dialog by tapping outside +Future showDetailsDialog(BuildContext context, Project project) => + showGeneralDialog( + context: context, + pageBuilder: (_, __, ___) => ProjectDetailsDialog(project: project), + barrierDismissible: true, + barrierLabel: "Show project details dialog box"); + +class ProjectDetailsDialog extends StatefulWidget { + final Project project; + + const ProjectDetailsDialog({Key? key, required this.project}) + : super(key: key); + + @override + State createState() => _ProjectDetailsDialogState(); +} + +class _ProjectDetailsDialogState extends State { + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.project.name), + actions: [_okButton], + content: Column( + children: [ + Text("resolution: 1080 X 1920"), + Text("last edited: ${widget.project.lastModified}"), + Text("creation date: ${widget.project.creationDate}"), + Text("size: ${_getFileSize()!} B"), + ], + ), + ); + } + + ElevatedButton get _okButton => ElevatedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("OK", style: TextStyle(color: Colors.white)), + ); + + int? _getFileSize() { + int? size; + try { + final file = File(widget.project.path); + size = file.lengthSync(); + } catch (err, stacktrace) { + showToast(err.toString()); + } + return size; + } +} 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/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..4652f85e 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; @@ -16,37 +18,43 @@ class IOHandler { static final provider = Provider((ref) => IOHandler(ref)); /// Returns [true] if the image was saved successfully - Future saveImage(BuildContext context) async { + Future saveImage( + BuildContext context, ImageMetaData? imageMetaData) 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; + if (imageMetaData == null) { + imageMetaData = await showSaveImageDialog(context, false); + if (imageMetaData == null) return null; + } + final savedFile = await workspaceStateNotifier + .performIOTask(() => _saveImageWith(imageMetaData!)); + // todo: fix this condition workspaceStateNotifier.updateLastSavedCommandCount(); - return true; + return savedFile; } /// Returns [true] if - /// - There was no unsaved work, or /// - The unsaved work was saved successfully - Future handleUnsavedChanges(BuildContext context, State state) async { + Future handleUnsavedChanges(BuildContext context, State state) async { final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); if (!workspaceStateNotifier.hasSavedLastWork) { final shouldDiscard = await showDiscardChangesDialog(context); - if (shouldDiscard == null || !state.mounted) return false; + if (shouldDiscard == null || !state.mounted) return null; if (!shouldDiscard) { - final didSave = await saveImage(context); - if (!didSave) return false; + final didSave = await saveImage(context, null); + return didSave; } } - return true; + return null; } /// 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 != null) return false; + } if (Platform.isIOS) { if (!state.mounted) return false; final location = await showLoadImageDialog(context); @@ -64,7 +72,7 @@ class IOHandler { /// Returns [true] if a new image canvas was created successfully Future newImage(BuildContext context, State state) async { final shouldContinue = await handleUnsavedChanges(context, state); - if (!shouldContinue) return false; + if (shouldContinue == null) return false; ref.read(CanvasState.provider.notifier) ..clearBackgroundImageAndResetDimensions() ..resetCanvasWithNewCommands([]); @@ -77,7 +85,7 @@ class IOHandler { case ImageLocation.photos: return _loadFromPhotos(); case ImageLocation.files: - return _loadFromFiles(); + return loadFromFiles(null); } } @@ -100,22 +108,22 @@ class IOHandler { ); } - Future _loadFromFiles() async { + Future loadFromFiles(Result? file) async { final loadImage = ref.read(LoadImageFromFileManager.provider); - final result = await loadImage(); + 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!); return true; }, err: (failure) { @@ -127,13 +135,14 @@ class IOHandler { ); } - Future _saveImageWith(ImageMetaData imageData) async { + Future _saveImageWith(ImageMetaData imageData) async { + File? savedFile; if (imageData is JpgMetaData || imageData is PngMetaData) { - return _saveAsRasterImage(imageData); + await _saveAsRasterImage(imageData); } else if (imageData is CatrobatImageMetaData) { - return _saveAsCatrobatImage(imageData); + savedFile = await _saveAsCatrobatImage(imageData); } - return false; + return savedFile; } Future _saveAsRasterImage(ImageMetaData imageData) async { @@ -152,7 +161,33 @@ 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); + final img = 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; + }, + ); + return img; + } + + Future _saveAsCatrobatImage(CatrobatImageMetaData imageData) async { final commands = ref.read(CommandManager.provider).history; final canvasState = ref.read(CanvasState.provider); final imgWidth = canvasState.size.width.toInt(); @@ -161,15 +196,14 @@ class IOHandler { commands, imgWidth, imgHeight, canvasState.backgroundImage); final saveAsCatrobatImage = ref.read(SaveAsCatrobatImage.provider); final result = await saveAsCatrobatImage(imageData, catrobatImage); - return result.when( + File? savedFile; + result.when( ok: (file) { showToast("Saved successfully"); - return true; - }, - err: (failure) { - showToast(failure.message); - return false; + savedFile = file; }, + err: (failure) => showToast(failure.message), ); + return savedFile; } } diff --git a/lib/ui/landing_page.dart b/lib/ui/landing_page.dart new file mode 100644 index 00000000..f0b30565 --- /dev/null +++ b/lib/ui/landing_page.dart @@ -0,0 +1,291 @@ +import 'dart:io'; +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/core/loggable_mixin.dart'; +import 'package:paintroid/data/model/project.dart'; +import 'package:paintroid/data/project_database.dart'; +import 'package:intl/intl.dart'; +import 'package:paintroid/ui/project_overflow_menu.dart'; + +import '../core/failure.dart'; +import '../io/src/failure/load_image_failure.dart'; +import '../io/src/ui/delete_project_dialog.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 with LoggableMixin { + late ProjectDatabase database; + + Future> _getProjects() async { + return await database.projectDAO.getProjects(); + } + + void _navigateToPocketPaint() async { + await Navigator.pushNamed(context, '/PocketPaint'); + setState(() {}); + } + + 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); + } + } + + Future _loadProject(IOHandler ioHandler, Project project) async { + project.lastModified = DateTime.now(); + await database.projectDAO.insertProject(project); + return getFile(project.path).when( + ok: (file) async { + print(file.lengthSync()); + return await ioHandler.loadFromFiles(Result.ok(file)); + }, + err: (failure) { + if (failure != LoadImageFailure.userCancelled) { + showToast(failure.message); + } + return false; + }, + ); + } + + Uint8List? _getProjectPreview(String? path) { + if (path != null) { + try { + File file = File(path); + return file.readAsBytesSync(); + } catch (err, stacktrace) { + showToast(stacktrace.toString()); + } + } + return null; + } + + ImageProvider _getProjectPreviewImageProvider(Uint8List img) => Image.memory( + img, + fit: BoxFit.cover, + ).image; + + @override + Widget build(BuildContext context) { + final db = ref.watch(ProjectDatabase.provider); + db.whenData((value) => database = value); + final ioHandler = ref.watch(IOHandler.provider); + final size = MediaQuery.of(context).size; + Project? latestModifiedProject; + + return Scaffold( + backgroundColor: lightColorScheme.primary, + appBar: AppBar( + title: Text(widget.title), + ), + body: FutureBuilder( + future: _getProjects(), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasData) { + BoxDecoration bigImg; + if (snapshot.data!.isNotEmpty) { + latestModifiedProject = snapshot.data![0]; + bigImg = BoxDecoration( + color: Colors.white54, + image: DecorationImage( + image: _getProjectPreviewImageProvider( + _getProjectPreview( + latestModifiedProject!.imagePreviewPath!)!, + ), + ), + ); + } else { + bigImg = const BoxDecoration(color: Colors.white54); + } + return Column( + children: [ + SizedBox( + height: size.height / 3, + child: Stack( + children: [ + Material( + child: InkWell( + onTap: () async { + if (latestModifiedProject != null) { + bool loaded = await _loadProject( + ioHandler, + latestModifiedProject!, + ); + if (loaded) _navigateToPocketPaint(); + } + }, + child: Container( + decoration: bigImg, + ), + ), + ), + Center( + child: IconButton( + iconSize: 264, + onPressed: () async { + if (latestModifiedProject != null) { + bool loaded = await _loadProject( + ioHandler, + latestModifiedProject!, + ); + if (loaded) _navigateToPocketPaint(); + } + }, + icon: SvgPicture.asset( + "assets/svg/ic_edit_circle.svg", + height: 264, + width: 264, + ), + ), + ) + ], + ), + ), + 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) { + Project project = snapshot.data![position]; + if (project != latestModifiedProject) { + BoxDecoration imagePreview; + Uint8List? img = + _getProjectPreview(project.imagePreviewPath); + if (img != null) { + // decodeImageFromList(img).then((value) => + // print("${value.height} X ${value.width}")); + imagePreview = BoxDecoration( + color: Colors.white, + image: DecorationImage( + image: _getProjectPreviewImageProvider(img), + ), + ); + } else { + imagePreview = + const BoxDecoration(color: Colors.white); + } + final DateFormat formatter = DateFormat('dd-MM-yyyy'); + final String lastModified = + formatter.format(project.lastModified); + + return Card( + // margin: const EdgeInsets.all(5), + child: ListTile( + leading: Container( + width: 80, + decoration: imagePreview, + ), + dense: false, + title: Text( + project.name, + style: const TextStyle(color: Color(0xFFFFFFFF)), + ), + subtitle: Text( + 'last modified: $lastModified', + style: const TextStyle(color: Color(0xFFFFFFFF)), + ), + trailing: ProjectOverflowMenu( + project: project, + ), + enabled: true, + onTap: () async { + bool loaded = + await _loadProject(ioHandler, project); + if (loaded) _navigateToPocketPaint(); + }, + ), + ); + } else { + 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: [ + FloatingActionButton( + heroTag: "btn1", + backgroundColor: const Color(0xFFFFAB08), + foregroundColor: const Color(0xFFFFFFFF), + child: const Icon(Icons.file_download), + onPressed: () async { + final bool imageLoaded = + await ioHandler.loadImage(context, this, false); + if (imageLoaded && mounted) { + _navigateToPocketPaint(); + } + }, + ), + const SizedBox( + height: 10, + ), + FloatingActionButton( + heroTag: "btn2", + backgroundColor: const Color(0xFFFFAB08), + foregroundColor: const Color(0xFFFFFFFF), + child: const Icon(Icons.add), + onPressed: () async { + ref.read(CanvasState.provider.notifier) + ..clearBackgroundImageAndResetDimensions() + ..resetCanvasWithNewCommands([]); + ref + .read(WorkspaceState.provider.notifier) + .updateLastSavedCommandCount(); + _navigateToPocketPaint(); + }, + ), + ], + ), + ); + } +} diff --git a/lib/ui/overflow_menu.dart b/lib/ui/overflow_menu.dart index 28fa4be6..c9cff618 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"); @@ -48,10 +55,13 @@ class _OverflowMenuState extends ConsumerState { _enterFullscreen(); break; case OverflowMenuOption.saveImage: - ioHandler.saveImage(context); + ioHandler.saveImage(context, null); + 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,33 @@ class _OverflowMenuState extends ConsumerState { void _enterFullscreen() => ref.read(WorkspaceState.provider.notifier).toggleFullscreen(true); + + Future _saveProject() async { + File? savedProject; + final imageData = await showSaveImageDialog(context, true); + + if (imageData != null && mounted) { + savedProject = await ioHandler.saveImage(context, imageData); + String? imagePreview = await ioHandler.getPreviewPath(imageData); + if (savedProject != null) { + 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); + // $FloorProjectDatabase + // .databaseBuilder("project_database.db") + // .build() + // .then((db) => db.projectDAO.insertProject(project)); + } + } + } } diff --git a/lib/ui/pocket_paint.dart b/lib/ui/pocket_paint.dart index 25289c2a..0ea62ab9 100644 --- a/lib/ui/pocket_paint.dart +++ b/lib/ui/pocket_paint.dart @@ -1,15 +1,21 @@ 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'; -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 +24,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 +32,15 @@ class PocketPaint extends ConsumerWidget { WorkspaceState.provider.select((state) => state.isFullscreen), (_, isFullscreen) => _toggleStatusBar(isFullscreen), ); + return WillPopScope( onWillPop: () async { - final willPop = !isFullscreen; + var willPop = !isFullscreen; if (isFullscreen) { ref.read(WorkspaceState.provider.notifier).toggleFullscreen(false); + } else { + var b = await showDiscardChangesDialog(context); + willPop = b!; } return willPop; }, diff --git a/lib/ui/project_overflow_menu.dart b/lib/ui/project_overflow_menu.dart new file mode 100644 index 00000000..6c3fdf41 --- /dev/null +++ b/lib/ui/project_overflow_menu.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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: (database) {this.database = database;}, error: error, loading: loading) + db.whenData((value) => database = value); + + 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 file = File(widget.project.path); + await file.delete(); + } catch (err, stacktrace) { + print("$err + $stacktrace.toString()"); + } + await database.projectDAO.deleteProject(widget.project); + ref.refresh(ProjectDatabase.provider); + } + } + + void _showProjectDetails() async { + bool? showDetails = await showDetailsDialog(context, widget.project); + } +} diff --git a/pubspec.lock b/pubspec.lock index d04ce4f2..0781b39a 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: @@ -211,6 +211,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 +267,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 +288,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 +387,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 +420,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 +428,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 +476,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 +525,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 +665,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 +692,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 +755,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 +769,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 +804,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..f2a4939f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,9 @@ dependencies: logging: ^1.0.2 file_picker: ^4.6.1 flutter_styled_toast: ^2.1.3 + floor: ^1.2.0 + intl: ^0.17.0 + sqflite: permission_handler: ^10.0.0 dev_dependencies: @@ -34,6 +37,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 From f12c09a411d6ffb31d1e7d4930a8989f468e5ee5 Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Wed, 17 Aug 2022 16:01:04 +0530 Subject: [PATCH 02/12] fix --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 819c4b93954ff012a3edc049a012896e85845c03 Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Wed, 17 Aug 2022 23:41:04 +0530 Subject: [PATCH 03/12] refactoring the details dialog --- lib/data/model/project.dart | 2 - lib/data/project_database.dart | 1 - lib/io/src/service/file_service.dart | 12 +++ lib/io/src/service/image_service.dart | 15 ++++ lib/io/src/ui/project_details_dialog.dart | 89 +++++++++++++++++------ lib/ui/landing_page.dart | 63 +++++++--------- pubspec.lock | 7 ++ pubspec.yaml | 1 + 8 files changed, 130 insertions(+), 60 deletions(-) diff --git a/lib/data/model/project.dart b/lib/data/model/project.dart index 2caff99a..ca901d2e 100644 --- a/lib/data/model/project.dart +++ b/lib/data/model/project.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:floor/floor.dart'; @entity diff --git a/lib/data/project_database.dart b/lib/data/project_database.dart index bc9d1b2d..a9216033 100644 --- a/lib/data/project_database.dart +++ b/lib/data/project_database.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:floor/floor.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/lib/io/src/service/file_service.dart b/lib/io/src/service/file_service.dart index 2a71ee51..0a61d610 100644 --- a/lib/io/src/service/file_service.dart +++ b/lib/io/src/service/file_service.dart @@ -17,6 +17,8 @@ abstract class IFileService { Future> pick(); + Result getFile(String path); + static final provider = Provider((ref) => FileService()); } @@ -73,4 +75,14 @@ class FileService with LoggableMixin implements IFileService { 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/project_details_dialog.dart b/lib/io/src/ui/project_details_dialog.dart index f8172f91..1683e12f 100644 --- a/lib/io/src/ui/project_details_dialog.dart +++ b/lib/io/src/ui/project_details_dialog.dart @@ -1,12 +1,15 @@ import 'dart:io'; +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:paintroid/io/io.dart'; import '../../../data/model/project.dart'; +import '../../../ui/color_schemes.dart'; +import '../service/image_service.dart'; -/// Returns [true] if user chose to delete the project or [null] if user -/// dismiss the dialog by tapping outside Future showDetailsDialog(BuildContext context, Project project) => showGeneralDialog( context: context, @@ -14,29 +17,58 @@ Future showDetailsDialog(BuildContext context, Project project) => barrierDismissible: true, barrierLabel: "Show project details dialog box"); -class ProjectDetailsDialog extends StatefulWidget { +class ProjectDetailsDialog extends ConsumerStatefulWidget { final Project project; const ProjectDetailsDialog({Key? key, required this.project}) : super(key: key); @override - State createState() => _ProjectDetailsDialogState(); + ConsumerState createState() => + _ProjectDetailsDialogState(); } -class _ProjectDetailsDialogState extends State { +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); + + _getImageDimenstions(widget.project.imagePreviewPath); + return AlertDialog( title: Text(widget.project.name), actions: [_okButton], - content: Column( - children: [ - Text("resolution: 1080 X 1920"), - Text("last edited: ${widget.project.lastModified}"), - Text("creation date: ${widget.project.creationDate}"), - Text("size: ${_getFileSize()!} B"), - ], + content: FutureBuilder( + future: _getImageDimenstions(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 edited: ${widget.project.lastModified}"), + Text("Creation date: ${widget.project.creationDate}"), + Text("Size: ${filesize(_getProjectSize())}"), + ], + ); + } else { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + backgroundColor: lightColorScheme.background, + ), + ], + ); + } + }, ), ); } @@ -46,14 +78,29 @@ class _ProjectDetailsDialogState extends State { child: const Text("OK", style: TextStyle(color: Colors.white)), ); - int? _getFileSize() { - int? size; - try { - final file = File(widget.project.path); - size = file.lengthSync(); - } catch (err, stacktrace) { - showToast(err.toString()); - } - return size; + int _getProjectSize() { + return fileService.getFile(widget.project.path).when( + ok: (file) => file.lengthSync(), + err: (failure) { + showToast(failure.message); + return 0; + }, + ); + } + + Future> _getImageDimenstions(String? path) async { + List dimensions = []; + return imageService.getProjectPreview(path).when( + ok: (img) async { + final image = await decodeImageFromList(img); + dimensions.add(image.width); + dimensions.add(image.height); + return dimensions; + }, + err: (failure) { + showToast(failure.message); + return dimensions; + }, + ); } } diff --git a/lib/ui/landing_page.dart b/lib/ui/landing_page.dart index f0b30565..d54d8938 100644 --- a/lib/ui/landing_page.dart +++ b/lib/ui/landing_page.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -6,15 +5,12 @@ 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/core/loggable_mixin.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 '../core/failure.dart'; -import '../io/src/failure/load_image_failure.dart'; -import '../io/src/ui/delete_project_dialog.dart'; import '../workspace/src/state/canvas_state_notifier.dart'; import '../workspace/src/state/workspace_state_notifier.dart'; import 'color_schemes.dart'; @@ -29,8 +25,10 @@ class LandingPage extends ConsumerStatefulWidget { ConsumerState createState() => _LandingPageState(); } -class _LandingPageState extends ConsumerState with LoggableMixin { +class _LandingPageState extends ConsumerState { late ProjectDatabase database; + late IFileService fileService; + late IImageService imageService; Future> _getProjects() async { return await database.projectDAO.getProjects(); @@ -41,21 +39,11 @@ class _LandingPageState extends ConsumerState with LoggableMixin { setState(() {}); } - 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); - } - } - Future _loadProject(IOHandler ioHandler, Project project) async { project.lastModified = DateTime.now(); await database.projectDAO.insertProject(project); - return getFile(project.path).when( + return fileService.getFile(project.path).when( ok: (file) async { - print(file.lengthSync()); return await ioHandler.loadFromFiles(Result.ok(file)); }, err: (failure) { @@ -68,15 +56,13 @@ class _LandingPageState extends ConsumerState with LoggableMixin { } Uint8List? _getProjectPreview(String? path) { - if (path != null) { - try { - File file = File(path); - return file.readAsBytesSync(); - } catch (err, stacktrace) { - showToast(stacktrace.toString()); - } - } - return null; + return imageService.getProjectPreview(path).when( + ok: (preview) => preview, + err: (failure) { + showToast(failure.message); + return null; + }, + ); } ImageProvider _getProjectPreviewImageProvider(Uint8List img) => Image.memory( @@ -89,6 +75,8 @@ class _LandingPageState extends ConsumerState with LoggableMixin { final db = ref.watch(ProjectDatabase.provider); db.whenData((value) => database = value); final ioHandler = ref.watch(IOHandler.provider); + fileService = ref.watch(IFileService.provider); + imageService = ref.watch(IImageService.provider); final size = MediaQuery.of(context).size; Project? latestModifiedProject; @@ -104,15 +92,18 @@ class _LandingPageState extends ConsumerState with LoggableMixin { BoxDecoration bigImg; if (snapshot.data!.isNotEmpty) { latestModifiedProject = snapshot.data![0]; - bigImg = BoxDecoration( - color: Colors.white54, - image: DecorationImage( - image: _getProjectPreviewImageProvider( - _getProjectPreview( - latestModifiedProject!.imagePreviewPath!)!, + Uint8List? img = + _getProjectPreview(latestModifiedProject!.imagePreviewPath); + if (img != null) { + bigImg = BoxDecoration( + color: Colors.white54, + image: DecorationImage( + image: _getProjectPreviewImageProvider(img), ), - ), - ); + ); + } else { + bigImg = const BoxDecoration(color: Colors.white54); + } } else { bigImg = const BoxDecoration(color: Colors.white54); } @@ -187,8 +178,8 @@ class _LandingPageState extends ConsumerState with LoggableMixin { Uint8List? img = _getProjectPreview(project.imagePreviewPath); if (img != null) { - // decodeImageFromList(img).then((value) => - // print("${value.height} X ${value.width}")); + decodeImageFromList(img).then((value) => + print("${value.height} X ${value.width}")); imagePreview = BoxDecoration( color: Colors.white, image: DecorationImage( diff --git a/pubspec.lock b/pubspec.lock index 0781b39a..e5abfae5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index f2a4939f..8075ab4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: flutter_styled_toast: ^2.1.3 floor: ^1.2.0 intl: ^0.17.0 + filesize: ^2.0.1 sqflite: permission_handler: ^10.0.0 From fb3c2efe35a2c574d89a591b916009315a5360cf Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Mon, 22 Aug 2022 21:34:52 +0530 Subject: [PATCH 04/12] rafactoring and added unit tests --- lib/io/src/ui/project_details_dialog.dart | 34 +++++++------- lib/ui/landing_page.dart | 19 ++++---- lib/ui/overflow_menu.dart | 2 +- test/unit/io/service/file_service_test.dart | 48 ++++++++++++++++++++ test/unit/io/service/image_service_test.dart | 9 ++++ 5 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 test/unit/io/service/file_service_test.dart diff --git a/lib/io/src/ui/project_details_dialog.dart b/lib/io/src/ui/project_details_dialog.dart index 1683e12f..7ef6e51f 100644 --- a/lib/io/src/ui/project_details_dialog.dart +++ b/lib/io/src/ui/project_details_dialog.dart @@ -1,14 +1,12 @@ -import 'dart:io'; - 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:paintroid/io/io.dart'; import '../../../data/model/project.dart'; import '../../../ui/color_schemes.dart'; -import '../service/image_service.dart'; Future showDetailsDialog(BuildContext context, Project project) => showGeneralDialog( @@ -37,13 +35,15 @@ class _ProjectDetailsDialogState extends ConsumerState { imageService = ref.watch(IImageService.provider); fileService = ref.watch(IFileService.provider); - _getImageDimenstions(widget.project.imagePreviewPath); + _getImageDimensions(widget.project.imagePreviewPath); + + final DateFormat formatter = DateFormat('dd-MM-yyyy HH:mm:ss'); return AlertDialog( title: Text(widget.project.name), actions: [_okButton], content: FutureBuilder( - future: _getImageDimenstions(widget.project.imagePreviewPath), + future: _getImageDimensions(widget.project.imagePreviewPath), builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { final dimensions = snapshot.data!; @@ -53,8 +53,10 @@ class _ProjectDetailsDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text("Resolution: ${dimensions[0]} X ${dimensions[1]}"), - Text("Last edited: ${widget.project.lastModified}"), - Text("Creation date: ${widget.project.creationDate}"), + Text( + "Last modified: ${formatter.format(widget.project.lastModified)}"), + Text( + "Creation date: ${formatter.format(widget.project.creationDate)}"), Text("Size: ${filesize(_getProjectSize())}"), ], ); @@ -78,17 +80,15 @@ class _ProjectDetailsDialogState extends ConsumerState { child: const Text("OK", style: TextStyle(color: Colors.white)), ); - int _getProjectSize() { - return fileService.getFile(widget.project.path).when( - ok: (file) => file.lengthSync(), - err: (failure) { - showToast(failure.message); - return 0; - }, - ); - } + int _getProjectSize() => fileService.getFile(widget.project.path).when( + ok: (file) => file.lengthSync(), + err: (failure) { + showToast(failure.message); + return 0; + }, + ); - Future> _getImageDimenstions(String? path) async { + Future> _getImageDimensions(String? path) async { List dimensions = []; return imageService.getProjectPreview(path).when( ok: (img) async { diff --git a/lib/ui/landing_page.dart b/lib/ui/landing_page.dart index d54d8938..ba518e76 100644 --- a/lib/ui/landing_page.dart +++ b/lib/ui/landing_page.dart @@ -55,15 +55,14 @@ class _LandingPageState extends ConsumerState { ); } - Uint8List? _getProjectPreview(String? path) { - return imageService.getProjectPreview(path).when( - ok: (preview) => preview, - err: (failure) { - showToast(failure.message); - return null; - }, - ); - } + Uint8List? _getProjectPreview(String? path) => + imageService.getProjectPreview(path).when( + ok: (preview) => preview, + err: (failure) { + showToast(failure.message); + return null; + }, + ); ImageProvider _getProjectPreviewImageProvider(Uint8List img) => Image.memory( img, @@ -178,8 +177,6 @@ class _LandingPageState extends ConsumerState { Uint8List? img = _getProjectPreview(project.imagePreviewPath); if (img != null) { - decodeImageFromList(img).then((value) => - print("${value.height} X ${value.width}")); imagePreview = BoxDecoration( color: Colors.white, image: DecorationImage( diff --git a/lib/ui/overflow_menu.dart b/lib/ui/overflow_menu.dart index c9cff618..827d6fdd 100644 --- a/lib/ui/overflow_menu.dart +++ b/lib/ui/overflow_menu.dart @@ -72,7 +72,7 @@ class _OverflowMenuState extends ConsumerState { void _enterFullscreen() => ref.read(WorkspaceState.provider.notifier).toggleFullscreen(true); - Future _saveProject() async { + void _saveProject() async { File? savedProject; final imageData = await showSaveImageDialog(context, true); 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..e23aadf7 --- /dev/null +++ b/test/unit/io/service/file_service_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:paintroid/io/src/service/file_service.dart'; + +class MockFileService extends Mock implements FileService {} + +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', + ); + + setUp(() async { + channel.setMockMethodCallHandler( + (MethodCall methodCall) async => testDirectory); + 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()); + }); } From 32cf7675e2d4279853b84a7047f6fbaff05bdb51 Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Wed, 24 Aug 2022 23:59:02 +0530 Subject: [PATCH 05/12] added some unit tests and some minor bug fixes --- lib/data/project_dao.dart | 4 +- lib/data/project_database.g.dart | 11 +- lib/ui/io_handler.dart | 58 ++++++----- lib/ui/overflow_menu.dart | 10 +- lib/ui/pocket_paint.dart | 9 +- lib/ui/project_overflow_menu.dart | 6 +- test/unit/data/project_database_test.dart | 109 ++++++++++++++++++++ test/unit/io/service/file_service_test.dart | 7 +- 8 files changed, 166 insertions(+), 48 deletions(-) create mode 100644 test/unit/data/project_database_test.dart diff --git a/lib/data/project_dao.dart b/lib/data/project_dao.dart index 5bf10ce4..fc62397c 100644 --- a/lib/data/project_dao.dart +++ b/lib/data/project_dao.dart @@ -10,8 +10,8 @@ abstract class ProjectDAO { @Insert(onConflict: OnConflictStrategy.replace) Future> insertProjects(List projects); - @delete - Future deleteProject(Project project); + @Query('DELETE FROM Project WHERE id = :id') + Future deleteProject(int id); @delete Future deleteProjects(List projects); diff --git a/lib/data/project_database.g.dart b/lib/data/project_database.g.dart index 3a6084f4..bcdfbf3d 100644 --- a/lib/data/project_database.g.dart +++ b/lib/data/project_database.g.dart @@ -139,6 +139,12 @@ class _$ProjectDAO extends ProjectDAO { final DeletionAdapter _projectDeletionAdapter; + @override + Future deleteProject(int id) async { + await _queryAdapter + .queryNoReturn('DELETE FROM Project WHERE id = ?1', arguments: [id]); + } + @override Future> getProjects() async { return _queryAdapter.queryList( @@ -167,11 +173,6 @@ class _$ProjectDAO extends ProjectDAO { projects, OnConflictStrategy.replace); } - @override - Future deleteProject(Project project) async { - await _projectDeletionAdapter.delete(project); - } - @override Future deleteProjects(List projects) async { await _projectDeletionAdapter.deleteList(projects); diff --git a/lib/ui/io_handler.dart b/lib/ui/io_handler.dart index 4652f85e..acade8d0 100644 --- a/lib/ui/io_handler.dart +++ b/lib/ui/io_handler.dart @@ -18,16 +18,23 @@ class IOHandler { static final provider = Provider((ref) => IOHandler(ref)); /// Returns [true] if the image was saved successfully - Future saveImage( - BuildContext context, ImageMetaData? imageMetaData) async { + Future saveImage(BuildContext context) async { final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); + final imageMetaData = await showSaveImageDialog(context, false); if (imageMetaData == null) { - imageMetaData = await showSaveImageDialog(context, false); - if (imageMetaData == null) return null; + return false; } + final isFileSaved = await workspaceStateNotifier + .performIOTask(() => _saveImageWith(imageMetaData)); + workspaceStateNotifier.updateLastSavedCommandCount(); + 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(() => _saveImageWith(imageMetaData!)); - // todo: fix this condition + .performIOTask(() => _saveAsCatrobatImage(imageMetaData)); workspaceStateNotifier.updateLastSavedCommandCount(); return savedFile; } @@ -35,17 +42,17 @@ class IOHandler { /// Returns [true] if - /// - There was no unsaved work, or /// - The unsaved work was saved successfully - Future handleUnsavedChanges(BuildContext context, State state) async { + Future handleUnsavedChanges(BuildContext context, State state) async { final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); if (!workspaceStateNotifier.hasSavedLastWork) { final shouldDiscard = await showDiscardChangesDialog(context); - if (shouldDiscard == null || !state.mounted) return null; + if (shouldDiscard == null || !state.mounted) return false; if (!shouldDiscard) { - final didSave = await saveImage(context, null); - return didSave; + final didSave = await saveImage(context); + if (!didSave) return false; } } - return null; + return true; } /// Returns [true] if the image was loaded successfully @@ -53,7 +60,7 @@ class IOHandler { BuildContext context, State state, bool unsavedChanges) async { if (unsavedChanges) { final shouldContinue = await handleUnsavedChanges(context, state); - if (shouldContinue != null) return false; + if (!shouldContinue) return false; } if (Platform.isIOS) { if (!state.mounted) return false; @@ -72,7 +79,7 @@ class IOHandler { /// Returns [true] if a new image canvas was created successfully Future newImage(BuildContext context, State state) async { final shouldContinue = await handleUnsavedChanges(context, state); - if (shouldContinue == null) return false; + if (!shouldContinue) return false; ref.read(CanvasState.provider.notifier) ..clearBackgroundImageAndResetDimensions() ..resetCanvasWithNewCommands([]); @@ -135,14 +142,15 @@ class IOHandler { ); } - Future _saveImageWith(ImageMetaData imageData) async { - File? savedFile; + Future _saveImageWith(ImageMetaData imageData) async { + bool isImageSaved = false; if (imageData is JpgMetaData || imageData is PngMetaData) { - await _saveAsRasterImage(imageData); + isImageSaved = await _saveAsRasterImage(imageData); } else if (imageData is CatrobatImageMetaData) { - savedFile = await _saveAsCatrobatImage(imageData); + final savedFile = await _saveAsCatrobatImage(imageData); + isImageSaved = (savedFile != null); } - return savedFile; + return isImageSaved; } Future _saveAsRasterImage(ImageMetaData imageData) async { @@ -167,7 +175,7 @@ class IOHandler { .call(keepTransparency: imageData.format != ImageFormat.jpg); final fileService = ref.watch(IFileService.provider); final pngImage = await ref.read(IImageService.provider).exportAsPng(image); - final img = pngImage.when( + return pngImage.when( ok: (img) async { final previewFile = await fileService.saveToApplicationDirectory(imageData.name, img); @@ -184,7 +192,6 @@ class IOHandler { return null; }, ); - return img; } Future _saveAsCatrobatImage(CatrobatImageMetaData imageData) async { @@ -196,14 +203,15 @@ class IOHandler { commands, imgWidth, imgHeight, canvasState.backgroundImage); final saveAsCatrobatImage = ref.read(SaveAsCatrobatImage.provider); final result = await saveAsCatrobatImage(imageData, catrobatImage); - File? savedFile; - result.when( + return result.when( ok: (file) { showToast("Saved successfully"); - savedFile = file; + return file; + }, + err: (failure) { + showToast(failure.message); + return null; }, - err: (failure) => showToast(failure.message), ); - return savedFile; } } diff --git a/lib/ui/overflow_menu.dart b/lib/ui/overflow_menu.dart index 827d6fdd..7d6a540d 100644 --- a/lib/ui/overflow_menu.dart +++ b/lib/ui/overflow_menu.dart @@ -55,7 +55,7 @@ class _OverflowMenuState extends ConsumerState { _enterFullscreen(); break; case OverflowMenuOption.saveImage: - ioHandler.saveImage(context, null); + ioHandler.saveImage(context); break; case OverflowMenuOption.saveProject: _saveProject(); @@ -77,9 +77,9 @@ class _OverflowMenuState extends ConsumerState { final imageData = await showSaveImageDialog(context, true); if (imageData != null && mounted) { - savedProject = await ioHandler.saveImage(context, imageData); - String? imagePreview = await ioHandler.getPreviewPath(imageData); + savedProject = await ioHandler.saveProject(imageData); if (savedProject != null) { + String? imagePreview = await ioHandler.getPreviewPath(imageData); Project project = Project( name: imageData.name, path: savedProject.path, @@ -93,10 +93,6 @@ class _OverflowMenuState extends ConsumerState { final db = await ref.read(ProjectDatabase.provider.future); await db.projectDAO.insertProject(project); - // $FloorProjectDatabase - // .databaseBuilder("project_database.db") - // .build() - // .then((db) => db.projectDAO.insertProject(project)); } } } diff --git a/lib/ui/pocket_paint.dart b/lib/ui/pocket_paint.dart index 0ea62ab9..00b9a1c0 100644 --- a/lib/ui/pocket_paint.dart +++ b/lib/ui/pocket_paint.dart @@ -7,6 +7,7 @@ import 'package:paintroid/workspace/workspace.dart'; import 'bottom_control_navigation_bar.dart'; import 'exit_fullscreen_button.dart'; +import 'io_handler.dart'; class PocketPaint extends ConsumerStatefulWidget { const PocketPaint({Key? key}) : super(key: key); @@ -32,6 +33,7 @@ class _PocketPaintState extends ConsumerState { WorkspaceState.provider.select((state) => state.isFullscreen), (_, isFullscreen) => _toggleStatusBar(isFullscreen), ); + final ioHandler = ref.watch(IOHandler.provider); return WillPopScope( onWillPop: () async { @@ -39,8 +41,11 @@ class _PocketPaintState extends ConsumerState { if (isFullscreen) { ref.read(WorkspaceState.provider.notifier).toggleFullscreen(false); } else { - var b = await showDiscardChangesDialog(context); - willPop = b!; + final shouldDiscard = await showDiscardChangesDialog(context); + if (shouldDiscard != null && !shouldDiscard && mounted) { + ioHandler.saveImage(context); + } + willPop = shouldDiscard!; } return willPop; }, diff --git a/lib/ui/project_overflow_menu.dart b/lib/ui/project_overflow_menu.dart index 6c3fdf41..6dfe5424 100644 --- a/lib/ui/project_overflow_menu.dart +++ b/lib/ui/project_overflow_menu.dart @@ -72,8 +72,10 @@ class _ProjectOverFlowMenuState extends ConsumerState { } catch (err, stacktrace) { print("$err + $stacktrace.toString()"); } - await database.projectDAO.deleteProject(widget.project); - ref.refresh(ProjectDatabase.provider); + if (widget.project.id != null) { + await database.projectDAO.deleteProject(widget.project.id!); + ref.refresh(ProjectDatabase.provider); + } } } 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 index e23aadf7..a4a2615b 100644 --- a/test/unit/io/service/file_service_test.dart +++ b/test/unit/io/service/file_service_test.dart @@ -3,11 +3,8 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; import 'package:paintroid/io/src/service/file_service.dart'; -class MockFileService extends Mock implements FileService {} - void main() async { TestWidgetsFlutterBinding.ensureInitialized(); @@ -18,10 +15,10 @@ void main() async { const channel = MethodChannel( 'plugins.flutter.io/path_provider_macos', ); + channel + .setMockMethodCallHandler((MethodCall methodCall) async => testDirectory); setUp(() async { - channel.setMockMethodCallHandler( - (MethodCall methodCall) async => testDirectory); sut = FileService(); }); From b0cd8bb98577cbce56e9e3cd8e5fb9cdab6ed693 Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Mon, 5 Sep 2022 10:17:54 +0530 Subject: [PATCH 06/12] added widget test, refactored code --- lib/io/src/ui/project_details_dialog.dart | 30 ++- lib/ui/io_handler.dart | 5 +- lib/ui/landing_page.dart | 85 +++--- lib/ui/pocket_paint.dart | 15 +- lib/ui/project_overflow_menu.dart | 13 +- test/widget/ui/landing_page_test.dart | 308 ++++++++++++++++++++++ 6 files changed, 400 insertions(+), 56 deletions(-) create mode 100644 test/widget/ui/landing_page_test.dart diff --git a/lib/io/src/ui/project_details_dialog.dart b/lib/io/src/ui/project_details_dialog.dart index 7ef6e51f..2e94ff75 100644 --- a/lib/io/src/ui/project_details_dialog.dart +++ b/lib/io/src/ui/project_details_dialog.dart @@ -3,6 +3,7 @@ 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 '../../../data/model/project.dart'; @@ -35,8 +36,6 @@ class _ProjectDetailsDialogState extends ConsumerState { imageService = ref.watch(IImageService.provider); fileService = ref.watch(IFileService.provider); - _getImageDimensions(widget.project.imagePreviewPath); - final DateFormat formatter = DateFormat('dd-MM-yyyy HH:mm:ss'); return AlertDialog( @@ -91,16 +90,21 @@ class _ProjectDetailsDialogState extends ConsumerState { Future> _getImageDimensions(String? path) async { List dimensions = []; return imageService.getProjectPreview(path).when( - ok: (img) async { - final image = await decodeImageFromList(img); - dimensions.add(image.width); - dimensions.add(image.height); - return dimensions; - }, - err: (failure) { - showToast(failure.message); - return dimensions; - }, - ); + 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/ui/io_handler.dart b/lib/ui/io_handler.dart index acade8d0..8bc95c5e 100644 --- a/lib/ui/io_handler.dart +++ b/lib/ui/io_handler.dart @@ -35,7 +35,7 @@ class IOHandler { final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); final savedFile = await workspaceStateNotifier .performIOTask(() => _saveAsCatrobatImage(imageMetaData)); - workspaceStateNotifier.updateLastSavedCommandCount(); + if (savedFile != null) workspaceStateNotifier.updateLastSavedCommandCount(); return savedFile; } @@ -117,6 +117,8 @@ class IOHandler { Future loadFromFiles(Result? file) async { final loadImage = ref.read(LoadImageFromFileManager.provider); + final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); + final result = await loadImage(file); return result.when( ok: (imageFromFile) async { @@ -131,6 +133,7 @@ class IOHandler { } else { canvasStateNotifier.resetCanvasWithNewCommands([]); } + workspaceStateNotifier.updateLastSavedCommandCount(); return true; }, err: (failure) { diff --git a/lib/ui/landing_page.dart b/lib/ui/landing_page.dart index ba518e76..b2a03421 100644 --- a/lib/ui/landing_page.dart +++ b/lib/ui/landing_page.dart @@ -30,9 +30,8 @@ class _LandingPageState extends ConsumerState { late IFileService fileService; late IImageService imageService; - Future> _getProjects() async { - return await database.projectDAO.getProjects(); - } + Future> _getProjects() async => + database.projectDAO.getProjects(); void _navigateToPocketPaint() async { await Navigator.pushNamed(context, '/PocketPaint'); @@ -69,15 +68,40 @@ class _LandingPageState extends ConsumerState { fit: BoxFit.cover, ).image; + BoxDecoration _getPreviewForLatestModifiedProject(Project project) { + Uint8List? img = _getProjectPreview(project.imagePreviewPath); + if (img != null) { + return BoxDecoration( + color: Colors.white54, + image: DecorationImage( + image: _getProjectPreviewImageProvider(img), + ), + ); + } + return const BoxDecoration(color: Colors.white54); + } + + void _clearCanvas() { + ref.read(CanvasState.provider.notifier) + ..clearBackgroundImageAndResetDimensions() + ..resetCanvasWithNewCommands([]); + ref.read(WorkspaceState.provider.notifier).updateLastSavedCommandCount(); + } + @override Widget build(BuildContext context) { final db = ref.watch(ProjectDatabase.provider); - db.whenData((value) => database = value); + db.when( + data: (value) => database = value, + error: (err, stacktrace) => showToast("Error: $err"), + loading: () {}, + ); final ioHandler = ref.watch(IOHandler.provider); - fileService = ref.watch(IFileService.provider); - imageService = ref.watch(IImageService.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, @@ -87,22 +111,13 @@ class _LandingPageState extends ConsumerState { body: FutureBuilder( future: _getProjects(), builder: (BuildContext context, AsyncSnapshot> snapshot) { - if (snapshot.hasData) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { BoxDecoration bigImg; if (snapshot.data!.isNotEmpty) { latestModifiedProject = snapshot.data![0]; - Uint8List? img = - _getProjectPreview(latestModifiedProject!.imagePreviewPath); - if (img != null) { - bigImg = BoxDecoration( - color: Colors.white54, - image: DecorationImage( - image: _getProjectPreviewImageProvider(img), - ), - ); - } else { - bigImg = const BoxDecoration(color: Colors.white54); - } + bigImg = + _getPreviewForLatestModifiedProject(latestModifiedProject!); } else { bigImg = const BoxDecoration(color: Colors.white54); } @@ -130,6 +145,7 @@ class _LandingPageState extends ConsumerState { ), Center( child: IconButton( + key: const Key('myEditIcon'), iconSize: 264, onPressed: () async { if (latestModifiedProject != null) { @@ -146,7 +162,16 @@ class _LandingPageState extends ConsumerState { width: 264, ), ), - ) + ), + Align( + alignment: AlignmentDirectional.topEnd, + child: latestModifiedProject == null + ? null + : ProjectOverflowMenu( + key: const Key('ProjectOverflowMenu Key0'), + project: latestModifiedProject!, + ), + ), ], ), ), @@ -171,8 +196,8 @@ class _LandingPageState extends ConsumerState { Flexible( child: ListView.builder( itemBuilder: (context, position) { - Project project = snapshot.data![position]; - if (project != latestModifiedProject) { + if (position != 0) { + Project project = snapshot.data![position]; BoxDecoration imagePreview; Uint8List? img = _getProjectPreview(project.imagePreviewPath); @@ -187,9 +212,6 @@ class _LandingPageState extends ConsumerState { imagePreview = const BoxDecoration(color: Colors.white); } - final DateFormat formatter = DateFormat('dd-MM-yyyy'); - final String lastModified = - formatter.format(project.lastModified); return Card( // margin: const EdgeInsets.all(5), @@ -204,10 +226,11 @@ class _LandingPageState extends ConsumerState { style: const TextStyle(color: Color(0xFFFFFFFF)), ), subtitle: Text( - 'last modified: $lastModified', + 'last modified: ${dateFormat.format(project.lastModified)}', style: const TextStyle(color: Color(0xFFFFFFFF)), ), trailing: ProjectOverflowMenu( + key: Key('ProjectOverflowMenu Key$position'), project: project, ), enabled: true, @@ -218,9 +241,8 @@ class _LandingPageState extends ConsumerState { }, ), ); - } else { - return const Card(); } + return const Card(); }, itemCount: snapshot.data?.length, scrollDirection: Axis.vertical, @@ -263,12 +285,7 @@ class _LandingPageState extends ConsumerState { foregroundColor: const Color(0xFFFFFFFF), child: const Icon(Icons.add), onPressed: () async { - ref.read(CanvasState.provider.notifier) - ..clearBackgroundImageAndResetDimensions() - ..resetCanvasWithNewCommands([]); - ref - .read(WorkspaceState.provider.notifier) - .updateLastSavedCommandCount(); + _clearCanvas(); _navigateToPocketPaint(); }, ), diff --git a/lib/ui/pocket_paint.dart b/lib/ui/pocket_paint.dart index 00b9a1c0..d52b4842 100644 --- a/lib/ui/pocket_paint.dart +++ b/lib/ui/pocket_paint.dart @@ -41,11 +41,18 @@ class _PocketPaintState extends ConsumerState { if (isFullscreen) { ref.read(WorkspaceState.provider.notifier).toggleFullscreen(false); } else { - final shouldDiscard = await showDiscardChangesDialog(context); - if (shouldDiscard != null && !shouldDiscard && mounted) { - ioHandler.saveImage(context); + 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; + } } - willPop = shouldDiscard!; } return willPop; }, diff --git a/lib/ui/project_overflow_menu.dart b/lib/ui/project_overflow_menu.dart index 6dfe5424..6d9897bc 100644 --- a/lib/ui/project_overflow_menu.dart +++ b/lib/ui/project_overflow_menu.dart @@ -2,6 +2,7 @@ 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'; @@ -67,10 +68,14 @@ class _ProjectOverFlowMenuState extends ConsumerState { bool? shouldDelete = await showDeleteDialog(context, widget.project.name); if (shouldDelete != null && shouldDelete) { try { - final file = File(widget.project.path); - await file.delete(); + 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, stacktrace) { - print("$err + $stacktrace.toString()"); + showToast(stacktrace.toString()); } if (widget.project.id != null) { await database.projectDAO.deleteProject(widget.project.id!); @@ -80,6 +85,6 @@ class _ProjectOverFlowMenuState extends ConsumerState { } void _showProjectDetails() async { - bool? showDetails = await showDetailsDialog(context, widget.project); + await showDetailsDialog(context, widget.project); } } 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); + }, + ); +} From 6cdfc4284a12962a748e2455c118e1b630465c3f Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Mon, 5 Sep 2022 10:32:08 +0530 Subject: [PATCH 07/12] added generated database file to gitignore --- .gitignore | 1 + lib/data/project_database.g.dart | 183 ------------------------------- 2 files changed, 1 insertion(+), 183 deletions(-) delete mode 100644 lib/data/project_database.g.dart 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/lib/data/project_database.g.dart b/lib/data/project_database.g.dart deleted file mode 100644 index bcdfbf3d..00000000 --- a/lib/data/project_database.g.dart +++ /dev/null @@ -1,183 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'project_database.dart'; - -// ************************************************************************** -// FloorGenerator -// ************************************************************************** - -// ignore: avoid_classes_with_only_static_members -class $FloorProjectDatabase { - /// Creates a database builder for a persistent database. - /// Once a database is built, you should keep a reference to it and re-use it. - static _$ProjectDatabaseBuilder databaseBuilder(String name) => - _$ProjectDatabaseBuilder(name); - - /// Creates a database builder for an in memory database. - /// Information stored in an in memory database disappears when the process is killed. - /// Once a database is built, you should keep a reference to it and re-use it. - static _$ProjectDatabaseBuilder inMemoryDatabaseBuilder() => - _$ProjectDatabaseBuilder(null); -} - -class _$ProjectDatabaseBuilder { - _$ProjectDatabaseBuilder(this.name); - - final String? name; - - final List _migrations = []; - - Callback? _callback; - - /// Adds migrations to the builder. - _$ProjectDatabaseBuilder addMigrations(List migrations) { - _migrations.addAll(migrations); - return this; - } - - /// Adds a database [Callback] to the builder. - _$ProjectDatabaseBuilder addCallback(Callback callback) { - _callback = callback; - return this; - } - - /// Creates the database and initializes it. - Future build() async { - final path = name != null - ? await sqfliteDatabaseFactory.getDatabasePath(name!) - : ':memory:'; - final database = _$ProjectDatabase(); - database.database = await database.open( - path, - _migrations, - _callback, - ); - return database; - } -} - -class _$ProjectDatabase extends ProjectDatabase { - _$ProjectDatabase([StreamController? listener]) { - changeListener = listener ?? StreamController.broadcast(); - } - - ProjectDAO? _projectDAOInstance; - - Future open(String path, List migrations, - [Callback? callback]) async { - final databaseOptions = sqflite.OpenDatabaseOptions( - version: 1, - onConfigure: (database) async { - await database.execute('PRAGMA foreign_keys = ON'); - await callback?.onConfigure?.call(database); - }, - onOpen: (database) async { - await callback?.onOpen?.call(database); - }, - onUpgrade: (database, startVersion, endVersion) async { - await MigrationAdapter.runMigrations( - database, startVersion, endVersion, migrations); - - await callback?.onUpgrade?.call(database, startVersion, endVersion); - }, - onCreate: (database, version) async { - await database.execute( - 'CREATE TABLE IF NOT EXISTS `Project` (`name` TEXT NOT NULL, `path` TEXT NOT NULL, `lastModified` INTEGER NOT NULL, `creationDate` INTEGER NOT NULL, `resolution` TEXT, `format` TEXT, `size` INTEGER, `imagePreviewPath` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT)'); - - await callback?.onCreate?.call(database, version); - }, - ); - return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); - } - - @override - ProjectDAO get projectDAO { - return _projectDAOInstance ??= _$ProjectDAO(database, changeListener); - } -} - -class _$ProjectDAO extends ProjectDAO { - _$ProjectDAO(this.database, this.changeListener) - : _queryAdapter = QueryAdapter(database), - _projectInsertionAdapter = InsertionAdapter( - database, - 'Project', - (Project item) => { - 'name': item.name, - 'path': item.path, - 'lastModified': _dateTimeConverter.encode(item.lastModified), - 'creationDate': _dateTimeConverter.encode(item.creationDate), - 'resolution': item.resolution, - 'format': item.format, - 'size': item.size, - 'imagePreviewPath': item.imagePreviewPath, - 'id': item.id - }), - _projectDeletionAdapter = DeletionAdapter( - database, - 'Project', - ['id'], - (Project item) => { - 'name': item.name, - 'path': item.path, - 'lastModified': _dateTimeConverter.encode(item.lastModified), - 'creationDate': _dateTimeConverter.encode(item.creationDate), - 'resolution': item.resolution, - 'format': item.format, - 'size': item.size, - 'imagePreviewPath': item.imagePreviewPath, - 'id': item.id - }); - - final sqflite.DatabaseExecutor database; - - final StreamController changeListener; - - final QueryAdapter _queryAdapter; - - final InsertionAdapter _projectInsertionAdapter; - - final DeletionAdapter _projectDeletionAdapter; - - @override - Future deleteProject(int id) async { - await _queryAdapter - .queryNoReturn('DELETE FROM Project WHERE id = ?1', arguments: [id]); - } - - @override - Future> getProjects() async { - return _queryAdapter.queryList( - 'SELECT * FROM Project order by lastModified desc', - mapper: (Map row) => Project( - name: row['name'] as String, - path: row['path'] as String, - lastModified: _dateTimeConverter.decode(row['lastModified'] as int), - creationDate: _dateTimeConverter.decode(row['creationDate'] as int), - resolution: row['resolution'] as String?, - format: row['format'] as String?, - size: row['size'] as int?, - imagePreviewPath: row['imagePreviewPath'] as String?, - id: row['id'] as int?)); - } - - @override - Future insertProject(Project project) { - return _projectInsertionAdapter.insertAndReturnId( - project, OnConflictStrategy.replace); - } - - @override - Future> insertProjects(List projects) { - return _projectInsertionAdapter.insertListAndReturnIds( - projects, OnConflictStrategy.replace); - } - - @override - Future deleteProjects(List projects) async { - await _projectDeletionAdapter.deleteList(projects); - } -} - -// ignore_for_file: unused_element -final _dateTimeConverter = DateTimeConverter(); From 0b10157c011c77357b42656abd1d30e398df5fad Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Mon, 5 Sep 2022 13:25:08 +0530 Subject: [PATCH 08/12] excluding database generated file and test files from static analysis check --- analysis_options.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 1bfc4d4718df0c6914765b0de2d089fd0342f9fd Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Wed, 12 Oct 2022 16:32:59 +0530 Subject: [PATCH 09/12] saving projects in applicationDocumentsDirectory --- lib/io/src/usecase/save_as_catrobat_image.dart | 5 ++++- lib/ui/io_handler.dart | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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/ui/io_handler.dart b/lib/ui/io_handler.dart index 8bc95c5e..860b0384 100644 --- a/lib/ui/io_handler.dart +++ b/lib/ui/io_handler.dart @@ -34,7 +34,7 @@ class IOHandler { if (imageMetaData is! CatrobatImageMetaData) return null; final workspaceStateNotifier = ref.read(WorkspaceState.provider.notifier); final savedFile = await workspaceStateNotifier - .performIOTask(() => _saveAsCatrobatImage(imageMetaData)); + .performIOTask(() => _saveAsCatrobatImage(imageMetaData, true)); if (savedFile != null) workspaceStateNotifier.updateLastSavedCommandCount(); return savedFile; } @@ -150,7 +150,7 @@ class IOHandler { if (imageData is JpgMetaData || imageData is PngMetaData) { isImageSaved = await _saveAsRasterImage(imageData); } else if (imageData is CatrobatImageMetaData) { - final savedFile = await _saveAsCatrobatImage(imageData); + final savedFile = await _saveAsCatrobatImage(imageData, false); isImageSaved = (savedFile != null); } return isImageSaved; @@ -197,7 +197,7 @@ class IOHandler { ); } - Future _saveAsCatrobatImage(CatrobatImageMetaData imageData) async { + 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(); @@ -205,7 +205,7 @@ 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"); From 4d6e24f301537be9f9ccd756e5076c058ba7923f Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Thu, 27 Oct 2022 15:29:20 +0530 Subject: [PATCH 10/12] refactoring --- lib/io/src/ui/delete_project_dialog.dart | 18 ++- lib/io/src/ui/discard_changes_dialog.dart | 3 +- lib/ui/landing_page.dart | 145 +++++++++++++--------- lib/ui/project_overflow_menu.dart | 11 +- 4 files changed, 104 insertions(+), 73 deletions(-) diff --git a/lib/io/src/ui/delete_project_dialog.dart b/lib/io/src/ui/delete_project_dialog.dart index cc5d7615..3c6df68f 100644 --- a/lib/io/src/ui/delete_project_dialog.dart +++ b/lib/io/src/ui/delete_project_dialog.dart @@ -26,14 +26,20 @@ class _DeleteProjectDialogState extends State { content: const Text("Do you really want to delete your project?"), ); - TextButton get _deleteButton => TextButton( - style: TextButton.styleFrom(primary: Colors.red), - onPressed: () => Navigator.of(context).pop(true), - child: const Text("Delete"), - ); + TextButton get _deleteButton { + return TextButton( + style: + ButtonStyle(foregroundColor: MaterialStateProperty.all(Colors.red)), + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Delete"), + ); + } ElevatedButton get _discardButton => ElevatedButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text("Cancel", style: TextStyle(color: Colors.white)), + child: const Text( + "Cancel", + style: 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..9f6256e6 100644 --- a/lib/io/src/ui/discard_changes_dialog.dart +++ b/lib/io/src/ui/discard_changes_dialog.dart @@ -29,7 +29,8 @@ class _DiscardChangesDialogState extends State { TextButton get _discardButton { return TextButton( - style: TextButton.styleFrom(primary: Colors.red), + style: + ButtonStyle(foregroundColor: MaterialStateProperty.all(Colors.red)), onPressed: () => Navigator.of(context).pop(true), child: const Text("Discard"), ); diff --git a/lib/ui/landing_page.dart b/lib/ui/landing_page.dart index b2a03421..83bf4a86 100644 --- a/lib/ui/landing_page.dart +++ b/lib/ui/landing_page.dart @@ -63,22 +63,12 @@ class _LandingPageState extends ConsumerState { }, ); - ImageProvider _getProjectPreviewImageProvider(Uint8List img) => Image.memory( - img, - fit: BoxFit.cover, - ).image; - - BoxDecoration _getPreviewForLatestModifiedProject(Project project) { - Uint8List? img = _getProjectPreview(project.imagePreviewPath); - if (img != null) { - return BoxDecoration( - color: Colors.white54, - image: DecorationImage( - image: _getProjectPreviewImageProvider(img), - ), - ); + Widget _getPreviewForLatestModifiedProject(Project? project) { + Uint8List? img; + if (project != null) { + img = _getProjectPreview(project.imagePreviewPath); } - return const BoxDecoration(color: Colors.white54); + return _ImagePreview(img: img, color: Colors.white54); } void _clearCanvas() { @@ -88,6 +78,11 @@ class _LandingPageState extends ConsumerState { 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); @@ -113,13 +108,8 @@ class _LandingPageState extends ConsumerState { builder: (BuildContext context, AsyncSnapshot> snapshot) { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { - BoxDecoration bigImg; if (snapshot.data!.isNotEmpty) { latestModifiedProject = snapshot.data![0]; - bigImg = - _getPreviewForLatestModifiedProject(latestModifiedProject!); - } else { - bigImg = const BoxDecoration(color: Colors.white54); } return Column( children: [ @@ -131,15 +121,11 @@ class _LandingPageState extends ConsumerState { child: InkWell( onTap: () async { if (latestModifiedProject != null) { - bool loaded = await _loadProject( - ioHandler, - latestModifiedProject!, - ); - if (loaded) _navigateToPocketPaint(); + _openProject(latestModifiedProject!, ioHandler); } }, - child: Container( - decoration: bigImg, + child: _getPreviewForLatestModifiedProject( + latestModifiedProject, ), ), ), @@ -149,11 +135,7 @@ class _LandingPageState extends ConsumerState { iconSize: 264, onPressed: () async { if (latestModifiedProject != null) { - bool loaded = await _loadProject( - ioHandler, - latestModifiedProject!, - ); - if (loaded) _navigateToPocketPaint(); + _openProject(latestModifiedProject!, ioHandler); } }, icon: SvgPicture.asset( @@ -198,27 +180,15 @@ class _LandingPageState extends ConsumerState { itemBuilder: (context, position) { if (position != 0) { Project project = snapshot.data![position]; - BoxDecoration imagePreview; Uint8List? img = _getProjectPreview(project.imagePreviewPath); - if (img != null) { - imagePreview = BoxDecoration( - color: Colors.white, - image: DecorationImage( - image: _getProjectPreviewImageProvider(img), - ), - ); - } else { - imagePreview = - const BoxDecoration(color: Colors.white); - } - return Card( // margin: const EdgeInsets.all(5), child: ListTile( - leading: Container( + leading: _ImagePreview( + img: img, width: 80, - decoration: imagePreview, + color: Colors.white, ), dense: false, title: Text( @@ -234,11 +204,7 @@ class _LandingPageState extends ConsumerState { project: project, ), enabled: true, - onTap: () async { - bool loaded = - await _loadProject(ioHandler, project); - if (loaded) _navigateToPocketPaint(); - }, + onTap: () async => _openProject(project, ioHandler), ), ); } @@ -263,11 +229,9 @@ class _LandingPageState extends ConsumerState { floatingActionButton: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - FloatingActionButton( - heroTag: "btn1", - backgroundColor: const Color(0xFFFFAB08), - foregroundColor: const Color(0xFFFFFFFF), - child: const Icon(Icons.file_download), + _LandingPageFAB( + heroTag: "import_image", + icon: Icons.file_download, onPressed: () async { final bool imageLoaded = await ioHandler.loadImage(context, this, false); @@ -279,11 +243,9 @@ class _LandingPageState extends ConsumerState { const SizedBox( height: 10, ), - FloatingActionButton( - heroTag: "btn2", - backgroundColor: const Color(0xFFFFAB08), - foregroundColor: const Color(0xFFFFFFFF), - child: const Icon(Icons.add), + _LandingPageFAB( + heroTag: "new_image", + icon: Icons.add, onPressed: () async { _clearCanvas(); _navigateToPocketPaint(); @@ -294,3 +256,62 @@ class _LandingPageState extends ConsumerState { ); } } + +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 Uint8List? img; + final double? width; + final Color color; + + const _ImagePreview({Key? key, this.img, this.width, required this.color}) + : super(key: key); + + ImageProvider _getProjectPreviewImageProvider(Uint8List img) => + Image.memory(img, fit: BoxFit.cover).image; + + @override + Widget build(BuildContext context) { + 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/project_overflow_menu.dart b/lib/ui/project_overflow_menu.dart index 6d9897bc..39e38996 100644 --- a/lib/ui/project_overflow_menu.dart +++ b/lib/ui/project_overflow_menu.dart @@ -35,8 +35,11 @@ class _ProjectOverFlowMenuState extends ConsumerState { @override Widget build(BuildContext context) { final db = ref.watch(ProjectDatabase.provider); - // db.when(data: (database) {this.database = database;}, error: error, loading: loading) - db.whenData((value) => database = value); + db.when( + data: (value) => database = value, + error: (err, stacktrace) => showToast("Error: $err"), + loading: () {}, + ); return PopupMenuButton( color: Theme.of(context).colorScheme.background, @@ -74,8 +77,8 @@ class _ProjectOverFlowMenuState extends ConsumerState { final previewFile = File(widget.project.imagePreviewPath!); await previewFile.delete(); } - } catch (err, stacktrace) { - showToast(stacktrace.toString()); + } catch (err) { + showToast(err.toString()); } if (widget.project.id != null) { await database.projectDAO.deleteProject(widget.project.id!); From e59a8478dd38d78d5040111dce5a8c5c02ffd13c Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Mon, 31 Oct 2022 14:06:49 +0530 Subject: [PATCH 11/12] refactoring --- lib/io/src/ui/delete_project_dialog.dart | 40 ++++++++++------ lib/io/src/ui/discard_changes_dialog.dart | 29 ++++-------- lib/ui/landing_page.dart | 56 ++++++++++++----------- 3 files changed, 63 insertions(+), 62 deletions(-) diff --git a/lib/io/src/ui/delete_project_dialog.dart b/lib/io/src/ui/delete_project_dialog.dart index 3c6df68f..62730982 100644 --- a/lib/io/src/ui/delete_project_dialog.dart +++ b/lib/io/src/ui/delete_project_dialog.dart @@ -22,24 +22,36 @@ class _DeleteProjectDialogState extends State { @override Widget build(BuildContext context) => AlertDialog( title: Text("Delete ${widget.name}"), - actions: [_discardButton, _deleteButton], + 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; - TextButton get _deleteButton { - return TextButton( - style: - ButtonStyle(foregroundColor: MaterialStateProperty.all(Colors.red)), - onPressed: () => Navigator.of(context).pop(true), - child: const Text("Delete"), - ); - } + const DialogTextButton({Key? key, required this.text}) : super(key: key); - ElevatedButton get _discardButton => ElevatedButton( + @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: const Text( - "Cancel", - style: TextStyle(color: Colors.white), - ), + 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 9f6256e6..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,27 +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: - ButtonStyle(foregroundColor: MaterialStateProperty.all(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/ui/landing_page.dart b/lib/ui/landing_page.dart index 83bf4a86..cd8dfaa2 100644 --- a/lib/ui/landing_page.dart +++ b/lib/ui/landing_page.dart @@ -54,23 +54,6 @@ class _LandingPageState extends ConsumerState { ); } - Uint8List? _getProjectPreview(String? path) => - imageService.getProjectPreview(path).when( - ok: (preview) => preview, - err: (failure) { - showToast(failure.message); - return null; - }, - ); - - Widget _getPreviewForLatestModifiedProject(Project? project) { - Uint8List? img; - if (project != null) { - img = _getProjectPreview(project.imagePreviewPath); - } - return _ImagePreview(img: img, color: Colors.white54); - } - void _clearCanvas() { ref.read(CanvasState.provider.notifier) ..clearBackgroundImageAndResetDimensions() @@ -124,8 +107,10 @@ class _LandingPageState extends ConsumerState { _openProject(latestModifiedProject!, ioHandler); } }, - child: _getPreviewForLatestModifiedProject( - latestModifiedProject, + child: _ImagePreview( + project: latestModifiedProject, + imageService: imageService, + color: Colors.white54, ), ), ), @@ -180,13 +165,11 @@ class _LandingPageState extends ConsumerState { itemBuilder: (context, position) { if (position != 0) { Project project = snapshot.data![position]; - Uint8List? img = - _getProjectPreview(project.imagePreviewPath); return Card( - // margin: const EdgeInsets.all(5), child: ListTile( leading: _ImagePreview( - img: img, + project: project, + imageService: imageService, width: 80, color: Colors.white, ), @@ -282,24 +265,43 @@ class _LandingPageFAB extends StatelessWidget { } class _ImagePreview extends StatelessWidget { - final Uint8List? img; + final Project? project; final double? width; final Color color; + final IImageService imageService; - const _ImagePreview({Key? key, this.img, this.width, required this.color}) - : super(key: key); + 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!), + image: _getProjectPreviewImageProvider(img), ), ); } From f47665c6c0c5c93f14beef679008fa07af42eac9 Mon Sep 17 00:00:00 2001 From: Saarthak Seth Date: Mon, 31 Oct 2022 14:11:54 +0530 Subject: [PATCH 12/12] refactoring --- lib/io/src/ui/project_details_dialog.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/io/src/ui/project_details_dialog.dart b/lib/io/src/ui/project_details_dialog.dart index 2e94ff75..5326f53b 100644 --- a/lib/io/src/ui/project_details_dialog.dart +++ b/lib/io/src/ui/project_details_dialog.dart @@ -5,6 +5,7 @@ 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'; @@ -40,7 +41,7 @@ class _ProjectDetailsDialogState extends ConsumerState { return AlertDialog( title: Text(widget.project.name), - actions: [_okButton], + actions: const [DialogElevatedButton(text: 'OK')], content: FutureBuilder( future: _getImageDimensions(widget.project.imagePreviewPath), builder: (BuildContext context, AsyncSnapshot snapshot) { @@ -74,11 +75,6 @@ class _ProjectDetailsDialogState extends ConsumerState { ); } - ElevatedButton get _okButton => ElevatedButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text("OK", style: TextStyle(color: Colors.white)), - ); - int _getProjectSize() => fileService.getFile(widget.project.path).when( ok: (file) => file.lengthSync(), err: (failure) {