From 8adff2343f62a72e1595d7ee91b26af6ae544371 Mon Sep 17 00:00:00 2001 From: Sanketh B K <51091231+SankethBK@users.noreply.github.com> Date: Sun, 12 Jan 2025 20:32:24 +0530 Subject: [PATCH] Export Selected Notes (#278) * Add export button * Add export selected notes --- assets/logo/file_export.svg | 1 + lib/core/widgets/home_page_app_bar.dart | 176 ++++++++++++++++-- .../local data sources/local_data_source.dart | 24 ++- .../local_data_source_template.dart | 3 +- .../repositories/export_notes_repository.dart | 88 +++++---- .../data/repositories/notes_repository.dart | 6 +- .../domain/repositories/notes_repository.dart | 3 +- pubspec.lock | 2 +- pubspec.yaml | 10 +- 9 files changed, 245 insertions(+), 68 deletions(-) create mode 100644 assets/logo/file_export.svg diff --git a/assets/logo/file_export.svg b/assets/logo/file_export.svg new file mode 100644 index 00000000..9def0bcb --- /dev/null +++ b/assets/logo/file_export.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/core/widgets/home_page_app_bar.dart b/lib/core/widgets/home_page_app_bar.dart index 4cd3ea29..a18dad6a 100644 --- a/lib/core/widgets/home_page_app_bar.dart +++ b/lib/core/widgets/home_page_app_bar.dart @@ -1,19 +1,28 @@ +import 'dart:io'; + import 'package:dairy_app/app/themes/theme_extensions/appbar_theme_extensions.dart'; import 'package:dairy_app/app/themes/theme_extensions/popup_theme_extensions.dart'; +import 'package:dairy_app/core/dependency_injection/injection_container.dart'; import 'package:dairy_app/core/pages/settings_page.dart'; import 'package:dairy_app/core/utils/utils.dart'; import 'package:dairy_app/core/widgets/cancel_button.dart'; import 'package:dairy_app/core/widgets/date_input_field.dart'; import 'package:dairy_app/core/widgets/glass_dialog.dart'; import 'package:dairy_app/core/widgets/glassmorphism_cover.dart'; +import 'package:dairy_app/core/widgets/settings_tile.dart'; import 'package:dairy_app/core/widgets/submit_button.dart'; import 'package:dairy_app/features/auth/data/models/user_config_model.dart'; +import 'package:dairy_app/features/notes/domain/repositories/export_notes_repository.dart'; import 'package:dairy_app/features/notes/presentation/bloc/notes/notes_bloc.dart'; import 'package:dairy_app/features/notes/presentation/bloc/notes_fetch/notes_fetch_cubit.dart'; import 'package:dairy_app/features/notes/presentation/bloc/selectable_list/selectable_list_cubit.dart'; import 'package:dairy_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; class HomePageAppBar extends StatefulWidget implements PreferredSizeWidget { const HomePageAppBar({ @@ -195,8 +204,12 @@ class Action extends StatelessWidget { } else if (selectableListState is SelectableListEnabled) { return Row( children: [ - DeletionCount( - deletionCount: selectableListCubit.state.selectedItems.length, + SelectionCount( + selectionCount: selectableListCubit.state.selectedItems.length, + ), + ExportIcon( + exportCount: selectableListCubit.state.selectedItems.length, + disableSelectedList: selectableListCubit.disableSelectableList, ), DeleteIcon( deletionCount: selectableListCubit.state.selectedItems.length, @@ -446,9 +459,9 @@ class Title extends StatelessWidget { } class _DeleteButton extends StatelessWidget { - final int deleteCount; - - const _DeleteButton({Key? key, required this.deleteCount}) : super(key: key); + const _DeleteButton({ + Key? key, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -554,15 +567,13 @@ class DeleteIcon extends StatelessWidget { ), ), const SizedBox(height: 15), - Row( + const Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - const _CancelButton(), - const SizedBox(width: 10), - _DeleteButton( - deleteCount: deletionCount, - ), + _CancelButton(), + SizedBox(width: 10), + _DeleteButton(), ], ), ], @@ -588,9 +599,144 @@ class DeleteIcon extends StatelessWidget { } } -class DeletionCount extends StatelessWidget { - final int deletionCount; - const DeletionCount({Key? key, required this.deletionCount}) +class ExportIcon extends StatelessWidget { + final int exportCount; + final Function() disableSelectedList; + + const ExportIcon( + {Key? key, required this.exportCount, required this.disableSelectedList}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 0.0), + child: IconButton( + icon: SvgPicture.asset( + 'assets/logo/file_export.svg', + height: 25.0, + width: 25.0, + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + ), + onPressed: () async { + if (exportCount == 0) { + disableSelectedList(); + return; + } + + final mainTextColor = Theme.of(context) + .extension()! + .mainTextColor; + + final selectableListCubit = + BlocProvider.of(context); + + bool? result = await showCustomDialog( + context: context, + child: LayoutBuilder( + builder: (context, constraints) { + return Container( + color: Colors.transparent, + width: MediaQuery.of(context).size.width * 0.6, + padding: + const EdgeInsets.symmetric(horizontal: 35, vertical: 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SettingsTile( + onTap: () async { + // create a text file from the notes + final directory = + await getApplicationDocumentsDirectory(); + + final now = DateTime.now(); + final formattedTimestamp = + DateFormat('yyyyMMdd_HHmmss').format(now); + + final file = File( + '${directory.path}/diaryvault_notes_export_$formattedTimestamp.txt', + ); + + try { + String filePath = await sl() + .exportNotesToTextFile( + file: file, + noteList: selectableListCubit + .state.selectedItems); + + // Share the file and await its completion + await Share.shareXFiles([XFile(filePath)], + text: 'diaryvault_notes_export'); + + await file.delete(); + } on Exception catch (e) { + showToast( + e.toString().replaceAll("Exception: ", "")); + } + }, + child: Text( + S.current.exportToPlainText, + style: TextStyle( + fontSize: 16.0, + color: mainTextColor, + ), + ), + ), + const SizedBox(height: 15), + SettingsTile( + onTap: () async { + try { + String filePath = await sl() + .exportNotesToPDF( + noteList: selectableListCubit + .state.selectedItems); + + // Share the file and await its completion + await Share.shareXFiles([XFile(filePath)], + text: 'diaryvault_notes_export'); + } on Exception catch (e) { + showToast( + e.toString().replaceAll("Exception: ", "")); + } + }, + child: Text( + S.current.exportToPDF, + style: TextStyle( + fontSize: 16.0, + color: mainTextColor, + ), + ), + ), + ], + ), + ); + }, + ), + ); + + disableSelectedList(); + + if (result != null) { + if (result == true) { + showToast( + "$exportCount item${exportCount > 1 ? "s" : ""} deleted"); + } else { + showToast(S.current.deletionFailed); + } + } + }, + ), + ); + } +} + +class SelectionCount extends StatelessWidget { + final int selectionCount; + const SelectionCount({Key? key, required this.selectionCount}) : super(key: key); @override @@ -598,7 +744,7 @@ class DeletionCount extends StatelessWidget { return Padding( padding: const EdgeInsets.only(right: 13.0), child: Text( - "$deletionCount", + "$selectionCount", style: const TextStyle(fontSize: 18.0), ), ); diff --git a/lib/features/notes/data/datasources/local data sources/local_data_source.dart b/lib/features/notes/data/datasources/local data sources/local_data_source.dart index 01d8c785..828bab8d 100644 --- a/lib/features/notes/data/datasources/local data sources/local_data_source.dart +++ b/lib/features/notes/data/datasources/local data sources/local_data_source.dart @@ -103,14 +103,26 @@ class NotesLocalDataSource implements INotesLocalDataSource { } @override - Future> fetchNotes(String authorId) async { + Future> fetchNotes( + {required String authorId, List? noteIds}) async { List> result; try { - result = await database.query( - Notes.TABLE_NAME, - where: - "${Notes.DELETED} != 1 and ( ${Notes.AUTHOR_ID} = '$authorId' or ${Notes.AUTHOR_ID} = '${GuestUserDetails.guestUserId}' )", - ); + if (noteIds != null && noteIds.isNotEmpty) { + // Format the noteIds list for the SQL query + String noteIdsString = noteIds.map((id) => "'$id'").join(', '); + + result = await database.query( + Notes.TABLE_NAME, + where: + "${Notes.DELETED} != 1 and ( ${Notes.AUTHOR_ID} = '$authorId' or ${Notes.AUTHOR_ID} = '${GuestUserDetails.guestUserId}' ) AND ${Notes.ID} IN ($noteIdsString)", + ); + } else { + result = await database.query( + Notes.TABLE_NAME, + where: + "${Notes.DELETED} != 1 and ( ${Notes.AUTHOR_ID} = '$authorId' or ${Notes.AUTHOR_ID} = '${GuestUserDetails.guestUserId}' )", + ); + } } catch (e) { log.e("Local database query for fetching notes failed $e"); throw const DatabaseQueryException(); diff --git a/lib/features/notes/data/datasources/local data sources/local_data_source_template.dart b/lib/features/notes/data/datasources/local data sources/local_data_source_template.dart index c1230c5a..476606ea 100644 --- a/lib/features/notes/data/datasources/local data sources/local_data_source_template.dart +++ b/lib/features/notes/data/datasources/local data sources/local_data_source_template.dart @@ -9,7 +9,8 @@ abstract class INotesLocalDataSource { /// Fetches all notes /// /// Throws [DatabaseQueryException] if something goes wrong - Future> fetchNotes(String authorId); + Future> fetchNotes( + {required String authorId, List? noteIds}); // Fetch all notes with only columns required to diplay the preview Future> fetchNotesPreview(String authorId); diff --git a/lib/features/notes/data/repositories/export_notes_repository.dart b/lib/features/notes/data/repositories/export_notes_repository.dart index 383d89cb..77a5d2ac 100644 --- a/lib/features/notes/data/repositories/export_notes_repository.dart +++ b/lib/features/notes/data/repositories/export_notes_repository.dart @@ -2,8 +2,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:dairy_app/core/logger/logger.dart'; +import 'package:dairy_app/features/notes/core/failures/failure.dart'; +import 'package:dairy_app/features/notes/data/models/notes_model.dart'; import 'package:dairy_app/features/notes/domain/repositories/export_notes_repository.dart'; import 'package:dairy_app/features/notes/domain/repositories/notes_repository.dart'; +import 'package:dartz/dartz.dart'; import 'package:delta_markdown/delta_markdown.dart'; import 'package:flutter/services.dart'; import 'package:flutter_html_to_pdf/flutter_html_to_pdf.dart'; @@ -22,28 +25,33 @@ class ExportNotesRepository implements IExportNotesRepository { Future exportNotesToTextFile( {required File file, List? noteList}) async { try { + Either> result; + if (noteList == null) { log.i("Generating text file for all notes"); - final result = await notesRepository.fetchNotes(); + result = await notesRepository.fetchNotes(); + } else { + log.i("Generating text file for $noteList"); - var fileContent = ""; + result = await notesRepository.fetchNotes(noteIds: noteList); + } - result.fold((l) => null, (allNotes) async { - for (var note in allNotes) { - fileContent += note.title + "\n"; + var fileContent = ""; - fileContent += "Created at: " + formatDate(note.createdAt) + "\n"; - fileContent += note.plainText; - fileContent += "\n\n---------------------------------\n\n"; - } - }); - await file.writeAsString(fileContent); + result.fold((l) => null, (allNotes) async { + for (var note in allNotes) { + fileContent += note.title + "\n"; - return file.path; - } + fileContent += "Created at: " + formatDate(note.createdAt) + "\n"; + fileContent += note.plainText; + fileContent += "\n\n---------------------------------\n\n"; + } + }); + + await file.writeAsString(fileContent); - return ""; + return file.path; } catch (e) { log.e(e); rethrow; @@ -85,45 +93,49 @@ class ExportNotesRepository implements IExportNotesRepository { final file = File('${directory.path}/diaryvault_notes_export.txt'); try { + Either> result; + if (noteList == null) { log.i("Generating PDF for all notes"); - final result = await notesRepository.fetchNotes(); + result = await notesRepository.fetchNotes(); + } else { + log.i("Generating PDF for $noteList"); - var fileContent = ""; + result = await notesRepository.fetchNotes(noteIds: noteList); + } - String watermarkFile = - await getImageFileFromAssets('assets/images/watermark.webp'); + var fileContent = ""; - fileContent += - "\"web-img\""; + String watermarkFile = + await getImageFileFromAssets('assets/images/watermark.webp'); - result.fold((l) => null, (allNotes) async { - for (var note in allNotes) { - fileContent += "

${note.title}

"; + fileContent += + "\"web-img\""; - fileContent += "Created at: ${formatDate(note.createdAt)} "; - fileContent += "
"; + result.fold((l) => null, (allNotes) async { + for (var note in allNotes) { + fileContent += "

${note.title}

"; - final preprocessedDelta = preprocessDeltaForPDFExport(note.body); - fileContent += quillDeltaToHtml(preprocessedDelta); + fileContent += "Created at: ${formatDate(note.createdAt)} "; + fileContent += "
"; - fileContent += "

"; - } - }); + final preprocessedDelta = preprocessDeltaForPDFExport(note.body); + fileContent += quillDeltaToHtml(preprocessedDelta); - // Add margins to the HTML content - final htmlWithMargins = addMarginsToHTML(fileContent); + fileContent += "

"; + } + }); - await file.writeAsString(htmlWithMargins); + // Add margins to the HTML content + final htmlWithMargins = addMarginsToHTML(fileContent); - var generatedPdfFile = await FlutterHtmlToPdf.convertFromHtmlFile( - file, directory.path, "diaryvault_pdf_export"); + await file.writeAsString(htmlWithMargins); - return generatedPdfFile.path; - } + var generatedPdfFile = await FlutterHtmlToPdf.convertFromHtmlFile( + file, directory.path, "diaryvault_pdf_export"); - return ""; + return generatedPdfFile.path; } catch (e) { log.e(e); rethrow; diff --git a/lib/features/notes/data/repositories/notes_repository.dart b/lib/features/notes/data/repositories/notes_repository.dart index 65aa195d..80ea163c 100644 --- a/lib/features/notes/data/repositories/notes_repository.dart +++ b/lib/features/notes/data/repositories/notes_repository.dart @@ -23,11 +23,13 @@ class NotesRepository with NoteHelperMixin implements INotesRepository { }); @override - Future>> fetchNotes() async { + Future>> fetchNotes( + {List? noteIds}) async { try { // since userId is fetched asynchronously, it will be null first time final userId = authSessionBloc.state.user?.id ?? ""; - var notesList = await notesLocalDataSource.fetchNotes(userId); + var notesList = await notesLocalDataSource.fetchNotes( + authorId: userId, noteIds: noteIds); return Right(notesList); } catch (e) { log.e(e); diff --git a/lib/features/notes/domain/repositories/notes_repository.dart b/lib/features/notes/domain/repositories/notes_repository.dart index 10597ccc..6cf33228 100644 --- a/lib/features/notes/domain/repositories/notes_repository.dart +++ b/lib/features/notes/domain/repositories/notes_repository.dart @@ -13,7 +13,8 @@ abstract class INotesRepository { bool dontModifyAnyParameters = false, }); - Future>> fetchNotes(); + Future>> fetchNotes( + {List? noteIds}); Future> getNote(String id); diff --git a/pubspec.lock b/pubspec.lock index 529585d6..5d9f60fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -741,7 +741,7 @@ packages: source: hosted version: "9.2.13" flutter_svg: - dependency: transitive + dependency: "direct main" description: name: flutter_svg sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c diff --git a/pubspec.yaml b/pubspec.yaml index ee7a0aac..4da4f984 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: path: ./packages/flutter_quill/flutter_quill_extensions flutter_secure_storage: ^9.0.0 flutter_sound: ^9.2.13 + flutter_svg: ^2.0.9 flutter_timezone: ^1.0.8 flutter_tts: ^3.8.3 flutter_web_auth_2: ^2.2.1 @@ -62,6 +63,7 @@ dependencies: google_sign_in: ^5.3.3 googleapis: ^9.0.0 http: 0.13.6 + in_app_review: ^2.0.4+2 internet_connection_checker: ^0.0.1+4 intl: ^0.18.0 local_auth: ^2.1.6 @@ -82,7 +84,6 @@ dependencies: uuid: ^3.0.6 webdav_client: path: ./packages/webdav_client - in_app_review: ^2.0.4+2 dev_dependencies: bloc_test: ^9.0.3 @@ -102,10 +103,10 @@ dev_dependencies: # ios: true # image_path: 'assets/images/test-logo.png' flutter_native_splash: - color: '#9e8bd9' - image: 'assets/images/splash_icon_4.webp' + color: "#9e8bd9" + image: "assets/images/splash_icon_4.webp" android_12: - image: 'assets/images/splash_icon_4.webp' + image: "assets/images/splash_icon_4.webp" dependency_overrides: # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -122,6 +123,7 @@ flutter: - assets/images/ - assets/fonts/ - assets/sounds/ + - assets/logo/ # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware.