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