diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 7f6d59865..e0fe083cd 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -53,7 +53,7 @@ const double restrictedMaxWidth = 430; const double mobileSmallThreshold = 336; // Note: 0 indicates no device limit -const publicLinkDeviceLimits = [0,50, 25, 10, 5, 2, 1]; +const publicLinkDeviceLimits = [0, 50, 25, 10, 5, 2, 1]; const kilometersPerDegree = 111.16; @@ -62,3 +62,5 @@ const defaultRadiusValues = [1, 2, 10, 20, 40, 80, 200, 400, 1200]; const defaultRadiusValue = 40.0; const galleryGridSpacing = 2.0; + +const searchSectionLimit = 7; diff --git a/lib/extensions/string_ext.dart b/lib/extensions/string_ext.dart index 09d61f59b..109a5c6a7 100644 --- a/lib/extensions/string_ext.dart +++ b/lib/extensions/string_ext.dart @@ -1,3 +1,27 @@ +const List connectWords = [ + 'a', 'an', 'the', // Articles + + 'about', 'above', 'across', 'after', 'against', 'along', 'amid', 'among', + 'around', 'as', 'at', 'before', 'behind', 'below', 'beneath', 'beside', + 'between', 'beyond', 'by', 'concerning', 'considering', 'despite', 'down', + 'during', 'except', 'for', 'from', 'in', 'inside', 'into', 'like', 'near', + 'of', 'off', 'on', 'onto', 'out', 'outside', 'over', 'past', 'regarding', + 'round', 'since', 'through', 'to', 'toward', 'under', 'underneath', 'until', + 'unto', 'up', 'upon', 'with', 'within', 'without', // Prepositions + + 'and', 'as', 'because', 'but', 'for', 'if', 'nor', 'or', 'since', 'so', + 'that', 'though', 'unless', 'until', 'when', 'whenever', 'where', 'whereas', + 'wherever', 'while', 'yet', // Conjunctions + + 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', + 'my', 'your', 'his', 'its', 'our', 'their', 'mine', 'yours', 'hers', 'ours', + 'theirs', 'who', 'whom', 'whose', 'which', 'what', // Pronouns + + 'am', 'is', 'are', 'was', 'were', 'be', 'being', 'been', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'shall', 'should', 'can', 'could', + 'may', 'might', 'must', // Auxiliary Verbs +]; + extension StringExtensionsNullSafe on String? { int get sumAsciiValues { if (this == null) { @@ -10,3 +34,26 @@ extension StringExtensionsNullSafe on String? { return sum; } } + +extension DescriptionString on String? { + bool get isAllConnectWords { + if (this == null) { + throw AssertionError("String cannot be null"); + } + final subDescWords = this!.split(" "); + return subDescWords.every( + (subDescWord) => connectWords.any( + (connectWord) => subDescWord.toLowerCase() == connectWord, + ), + ); + } + + bool get isLastWordConnectWord { + if (this == null) { + throw AssertionError("String cannot be null"); + } + final subDescWords = this!.split(" "); + return connectWords + .any((element) => element == subDescWords.last.toLowerCase()); + } +} diff --git a/lib/generated/intl/messages_de.dart b/lib/generated/intl/messages_de.dart index a8418a01e..4ebc9f212 100644 --- a/lib/generated/intl/messages_de.dart +++ b/lib/generated/intl/messages_de.dart @@ -56,7 +56,7 @@ class MessageLookup extends MessageLookupByLibrary { "Bitte kontaktieren Sie uns über support@ente.io, um Ihr ${provider} Abo zu verwalten."; static String m11(count) => - "${Intl.plural(count, one: 'Lösche ${count} Element', other: 'Lösche ${count} Elemente')}"; + "${Intl.plural(count, one: 'Lösche 1 Element', other: 'Lösche ${count} Elemente')}"; static String m12(currentlyDeleting, totalCount) => "Lösche ${currentlyDeleting} / ${totalCount}"; diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index a734a1589..ef3b802a9 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -664,6 +664,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Add a description..."), "fileSavedToGallery": MessageLookupByLibrary.simpleMessage("File saved to gallery"), + "fileTypesAndNames": + MessageLookupByLibrary.simpleMessage("File types and names"), "filesBackedUpFromDevice": m19, "filesBackedUpInAlbum": m20, "filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"), @@ -837,6 +839,7 @@ class MessageLookup extends MessageLookupByLibrary { "mobileWebDesktop": MessageLookupByLibrary.simpleMessage("Mobile, Web, Desktop"), "moderateStrength": MessageLookupByLibrary.simpleMessage("Moderate"), + "moments": MessageLookupByLibrary.simpleMessage("Moments"), "monthly": MessageLookupByLibrary.simpleMessage("Monthly"), "moveItem": m30, "moveToAlbum": MessageLookupByLibrary.simpleMessage("Move to album"), @@ -910,6 +913,7 @@ class MessageLookup extends MessageLookupByLibrary { "paymentFailedTalkToProvider": m33, "paymentFailedWithReason": m34, "pendingSync": MessageLookupByLibrary.simpleMessage("Pending sync"), + "people": MessageLookupByLibrary.simpleMessage("People"), "peopleUsingYourCode": MessageLookupByLibrary.simpleMessage("People using your code"), "permDeleteWarning": MessageLookupByLibrary.simpleMessage( @@ -918,6 +922,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Permanently delete"), "permanentlyDeleteFromDevice": MessageLookupByLibrary.simpleMessage( "Permanently delete from device?"), + "photoDescriptions": + MessageLookupByLibrary.simpleMessage("Photo descriptions"), "photoGridSize": MessageLookupByLibrary.simpleMessage("Photo grid size"), "photoSmallCase": MessageLookupByLibrary.simpleMessage("photo"), @@ -1078,12 +1084,26 @@ class MessageLookup extends MessageLookupByLibrary { "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( "Scan this barcode with\nyour authenticator app"), + "searchAlbumsEmptySection": + MessageLookupByLibrary.simpleMessage("Albums"), "searchByAlbumNameHint": MessageLookupByLibrary.simpleMessage("Album name"), "searchByExamples": MessageLookupByLibrary.simpleMessage( "• Album names (e.g. \"Camera\")\n• Types of files (e.g. \"Videos\", \".gif\")\n• Years and months (e.g. \"2022\", \"January\")\n• Holidays (e.g. \"Christmas\")\n• Photo descriptions (e.g. “#fun”)"), + "searchCaptionEmptySection": MessageLookupByLibrary.simpleMessage( + "Add descriptions like \"#trip\" in photo info to quickly find them here"), + "searchDatesEmptySection": MessageLookupByLibrary.simpleMessage( + "Search by a date, month or year"), + "searchFaceEmptySection": + MessageLookupByLibrary.simpleMessage("Find all photos of a person"), + "searchFileTypesAndNamesEmptySection": + MessageLookupByLibrary.simpleMessage("File types and names"), "searchHintText": MessageLookupByLibrary.simpleMessage( "Albums, months, days, years, ..."), + "searchLocationEmptySection": MessageLookupByLibrary.simpleMessage( + "Group photos that are taken within some radius of a photo"), + "searchPeopleEmptySection": MessageLookupByLibrary.simpleMessage( + "Invite people, and you\'ll see all photos shared by them here"), "security": MessageLookupByLibrary.simpleMessage("Security"), "selectAlbum": MessageLookupByLibrary.simpleMessage("Select album"), "selectAll": MessageLookupByLibrary.simpleMessage("Select all"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 8ef582215..bae3b877e 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -6799,6 +6799,126 @@ class S { ); } + /// `Photo descriptions` + String get photoDescriptions { + return Intl.message( + 'Photo descriptions', + name: 'photoDescriptions', + desc: '', + args: [], + ); + } + + /// `File types and names` + String get fileTypes { + return Intl.message( + 'File types', + name: 'fileTypes', + desc: '', + args: [], + ); + } + + /// `Location` + String get location { + return Intl.message( + 'Location', + name: 'location', + desc: '', + args: [], + ); + } + + /// `People` + String get people { + return Intl.message( + 'People', + name: 'people', + desc: '', + args: [], + ); + } + + /// `Moments` + String get moments { + return Intl.message( + 'Moments', + name: 'moments', + desc: '', + args: [], + ); + } + + /// `Find all photos of a person` + String get searchFaceEmptySection { + return Intl.message( + 'Find all photos of a person', + name: 'searchFaceEmptySection', + desc: '', + args: [], + ); + } + + /// `Search by a date, month or year` + String get searchDatesEmptySection { + return Intl.message( + 'Search by a date, month or year', + name: 'searchDatesEmptySection', + desc: '', + args: [], + ); + } + + /// `Group photos that are taken within some radius of a photo` + String get searchLocationEmptySection { + return Intl.message( + 'Group photos that are taken within some radius of a photo', + name: 'searchLocationEmptySection', + desc: '', + args: [], + ); + } + + /// `Invite people, and you'll see all photos shared by them here` + String get searchPeopleEmptySection { + return Intl.message( + 'Invite people, and you\'ll see all photos shared by them here', + name: 'searchPeopleEmptySection', + desc: '', + args: [], + ); + } + + /// `Albums` + String get searchAlbumsEmptySection { + return Intl.message( + 'Albums', + name: 'searchAlbumsEmptySection', + desc: '', + args: [], + ); + } + + /// `File types and names` + String get searchFileTypesAndNamesEmptySection { + return Intl.message( + 'File types and names', + name: 'searchFileTypesAndNamesEmptySection', + desc: '', + args: [], + ); + } + + /// `Add descriptions like "#trip" in photo info to quickly find them here` + String get searchCaptionEmptySection { + return Intl.message( + 'Add descriptions like "#trip" in photo info to quickly find them here', + name: 'searchCaptionEmptySection', + desc: '', + args: [], + ); + } + /// `Language` String get language { return Intl.message( @@ -6849,16 +6969,6 @@ class S { ); } - /// `Location` - String get location { - return Intl.message( - 'Location', - name: 'location', - desc: '', - args: [], - ); - } - /// `km` String get kiloMeterUnit { return Intl.message( @@ -7775,6 +7885,16 @@ class S { ); } + /// `Your map` + String get yourMap { + return Intl.message( + 'Your map', + name: 'yourMap', + desc: '', + args: [], + ); + } + /// `Black Friday Sale` String get blackFridaySale { return Intl.message( @@ -7785,6 +7905,16 @@ class S { ); } + /// `Modify your query, or try searching for` + String get modifyYourQueryOrTrySearchingFor { + return Intl.message( + 'Modify your query, or try searching for', + name: 'modifyYourQueryOrTrySearchingFor', + desc: '', + args: [], + ); + } + /// `Upto 50% off, until 4th Dec.` String get upto50OffUntil4thDec { return Intl.message( diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 4e9d62c53..c9e09b7f2 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1,5 +1,8 @@ { "addToHiddenAlbum": "Add to hidden album", "moveToHiddenAlbum": "Move to hidden album", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index e3760e04a..9c7384ff3 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1102,5 +1102,8 @@ "crashReporting": "Absturzbericht", "addToHiddenAlbum": "Zum versteckten Album hinzufügen", "moveToHiddenAlbum": "Zu verstecktem Album verschieben", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b623cef00..cc538a1fa 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -956,12 +956,23 @@ "loadMessage7": "Our mobile apps run in the background to encrypt and backup any new photos you click", "loadMessage8": "web.ente.io has a slick uploader", "loadMessage9": "We use Xchacha20Poly1305 to safely encrypt your data", + "photoDescriptions": "Photo descriptions", + "fileTypesAndNames": "File types and names", + "location": "Location", + "people": "People", + "moments": "Moments", + "searchFaceEmptySection": "Find all photos of a person", + "searchDatesEmptySection": "Search by a date, month or year", + "searchLocationEmptySection": "Group photos that are taken within some radius of a photo", + "searchPeopleEmptySection": "Invite people, and you'll see all photos shared by them here", + "searchAlbumsEmptySection": "Albums", + "searchFileTypesAndNamesEmptySection": "File types and names", + "searchCaptionEmptySection": "Add descriptions like \"#trip\" in photo info to quickly find them here", "language": "Language", "selectLanguage": "Select Language", "locationName": "Location name", "addLocation": "Add location", "groupNearbyPhotos": "Group nearby photos", - "location": "Location", "kiloMeterUnit": "km", "addLocationButton": "Add", "radius": "Radius", @@ -1103,12 +1114,15 @@ "crashReporting": "Crash reporting", "addToHiddenAlbum": "Add to hidden album", "moveToHiddenAlbum": "Move to hidden album", + "fileTypes": "File types", "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", "hearUsWhereTitle": "How did you hear about Ente? (optional)", "hearUsExplanation": "We don't track app installs. It'd help if you told us where you found us!", "viewAddOnButton": "View add-ons", "addOns": "Add-ons", "addOnPageSubtitle": "Details of add-ons", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for", "blackFridaySale": "Black Friday Sale", "upto50OffUntil4thDec": "Upto 50% off, until 4th Dec." } diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 0d1547a0f..9b94e32df 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -964,5 +964,8 @@ "familyPlanOverview": "Añada 5 familiares a su plan existente sin pagar más.\n\nCada miembro tiene su propio espacio privado y no puede ver los archivos del otro a menos que sean compartidos.\n\nLos planes familiares están disponibles para los clientes que tienen una suscripción de ente pagada.\n\n¡Suscríbete ahora para empezar!", "addToHiddenAlbum": "Add to hidden album", "moveToHiddenAlbum": "Move to hidden album", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 9a9c651a1..c495dd4b4 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1102,5 +1102,8 @@ "crashReporting": "Rapports d'erreurs", "addToHiddenAlbum": "Ajouter à un album masqué", "moveToHiddenAlbum": "Déplacer vers un album masqué", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 2bbde65d1..8a73df4ae 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1108,5 +1108,7 @@ "hearUsExplanation": "Non teniamo traccia del numero di installazioni dell'app. Sarebbe utile se ci dicesse dove ci ha trovato!", "viewAddOnButton": "Visualizza componenti aggiuntivi", "addOns": "Componenti aggiuntivi", - "addOnPageSubtitle": "Dettagli dei componenti aggiuntivi" + "addOnPageSubtitle": "Dettagli dei componenti aggiuntivi", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_ko.arb b/lib/l10n/intl_ko.arb index 4e9d62c53..c9e09b7f2 100644 --- a/lib/l10n/intl_ko.arb +++ b/lib/l10n/intl_ko.arb @@ -1,5 +1,8 @@ { "addToHiddenAlbum": "Add to hidden album", "moveToHiddenAlbum": "Move to hidden album", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_nl.arb b/lib/l10n/intl_nl.arb index 53e14d528..1e6993539 100644 --- a/lib/l10n/intl_nl.arb +++ b/lib/l10n/intl_nl.arb @@ -1102,5 +1102,8 @@ "crashReporting": "Crash rapportering", "addToHiddenAlbum": "Toevoegen aan verborgen album", "moveToHiddenAlbum": "Verplaatsen naar verborgen album", - "deleteConfirmDialogBody": "Dit account is gekoppeld aan andere ente apps, als je er gebruik van maakt.\\n\\nJe geüploade gegevens worden in alle ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle ente diensten." + "fileTypes": "File types", + "deleteConfirmDialogBody": "Dit account is gekoppeld aan andere ente apps, als je er gebruik van maakt.\\n\\nJe geüploade gegevens worden in alle ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle ente diensten.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_no.arb b/lib/l10n/intl_no.arb index 16d00fc49..cab594877 100644 --- a/lib/l10n/intl_no.arb +++ b/lib/l10n/intl_no.arb @@ -15,5 +15,8 @@ "confirmAccountDeletion": "Bekreft sletting av konto", "addToHiddenAlbum": "Add to hidden album", "moveToHiddenAlbum": "Move to hidden album", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index c76ccfa0d..94fa2ed9a 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -102,5 +102,8 @@ "tryAgain": "Spróbuj ponownie", "addToHiddenAlbum": "Add to hidden album", "moveToHiddenAlbum": "Move to hidden album", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index ed78921b7..56a2c26c3 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -268,5 +268,8 @@ "updateAvailable": "Atualização disponível", "addToHiddenAlbum": "Add to hidden album", "moveToHiddenAlbum": "Move to hidden album", - "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted." + "fileTypes": "File types", + "deleteConfirmDialogBody": "This account is linked to other ente apps, if you use any.\\n\\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 992d7fb0c..f5300dbb4 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -1108,5 +1108,7 @@ "hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!", "viewAddOnButton": "查看附加组件", "addOns": "附加组件", - "addOnPageSubtitle": "附加组件详情" + "addOnPageSubtitle": "附加组件详情", + "yourMap": "Your map", + "modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for" } \ No newline at end of file diff --git a/lib/models/search/button_result.dart b/lib/models/button_result.dart similarity index 100% rename from lib/models/search/button_result.dart rename to lib/models/button_result.dart diff --git a/lib/models/search/album_search_result.dart b/lib/models/search/album_search_result.dart index 2d9dea7b4..543012b46 100644 --- a/lib/models/search/album_search_result.dart +++ b/lib/models/search/album_search_result.dart @@ -1,6 +1,7 @@ import 'package:photos/models/collection/collection_items.dart'; import 'package:photos/models/file/file.dart'; import 'package:photos/models/search/search_result.dart'; +import "package:photos/models/search/search_types.dart"; class AlbumSearchResult extends SearchResult { final CollectionWithThumbnail collectionWithThumbnail; diff --git a/lib/models/search/file_search_result.dart b/lib/models/search/file_search_result.dart index a36c0ec77..8648236c8 100644 --- a/lib/models/search/file_search_result.dart +++ b/lib/models/search/file_search_result.dart @@ -1,5 +1,6 @@ import 'package:photos/models/file/file.dart'; import 'package:photos/models/search/search_result.dart'; +import "package:photos/models/search/search_types.dart"; class FileSearchResult extends SearchResult { final EnteFile file; diff --git a/lib/models/search/generic_search_result.dart b/lib/models/search/generic_search_result.dart index abec7db8a..352886a50 100644 --- a/lib/models/search/generic_search_result.dart +++ b/lib/models/search/generic_search_result.dart @@ -1,6 +1,7 @@ import "package:flutter/cupertino.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/search/search_result.dart'; +import "package:photos/models/search/search_types.dart"; class GenericSearchResult extends SearchResult { final String _name; diff --git a/lib/models/search/location_api_response.dart b/lib/models/search/location_api_response.dart deleted file mode 100644 index e8be37497..000000000 --- a/lib/models/search/location_api_response.dart +++ /dev/null @@ -1,45 +0,0 @@ -class LocationApiResponse { - final List results; - LocationApiResponse({ - required this.results, - }); - - LocationApiResponse copyWith({ - required List results, - }) { - return LocationApiResponse( - results: results, - ); - } - - factory LocationApiResponse.fromMap(Map map) { - return LocationApiResponse( - results: (map['results']) == null - ? [] - : List.from( - (map['results']).map( - (x) => - LocationDataFromResponse.fromMap(x as Map), - ), - ), - ); - } -} - -class LocationDataFromResponse { - final String place; - final List bbox; - LocationDataFromResponse({ - required this.place, - required this.bbox, - }); - - factory LocationDataFromResponse.fromMap(Map map) { - return LocationDataFromResponse( - place: map['place'] as String, - bbox: List.from( - (map['bbox']), - ), - ); - } -} diff --git a/lib/models/search/recent_searches.dart b/lib/models/search/recent_searches.dart new file mode 100644 index 000000000..40deb68ec --- /dev/null +++ b/lib/models/search/recent_searches.dart @@ -0,0 +1,24 @@ +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; + +class RecentSearches with ChangeNotifier { + static RecentSearches? _instance; + + RecentSearches._(); + + factory RecentSearches() => _instance ??= RecentSearches._(); + + final searches = {}; + + void add(String query) { + searches.add(query); + while (searches.length > searchSectionLimit) { + searches.remove(searches.first); + } + //buffer for not surfacing a new recent search before going to the next + //screen + Future.delayed(const Duration(seconds: 1), () { + notifyListeners(); + }); + } +} diff --git a/lib/models/search/search_result.dart b/lib/models/search/search_result.dart index f04ce7ee0..02c922af0 100644 --- a/lib/models/search/search_result.dart +++ b/lib/models/search/search_result.dart @@ -1,4 +1,5 @@ -import 'package:photos/models/file/file.dart'; +import "package:photos/models/file/file.dart"; +import "package:photos/models/search/search_types.dart"; abstract class SearchResult { ResultType type(); @@ -13,15 +14,3 @@ abstract class SearchResult { List resultFiles(); } - -enum ResultType { - collection, - file, - location, - month, - year, - fileType, - fileExtension, - fileCaption, - event, -} diff --git a/lib/models/search/search_types.dart b/lib/models/search/search_types.dart new file mode 100644 index 000000000..118c56215 --- /dev/null +++ b/lib/models/search/search_types.dart @@ -0,0 +1,286 @@ +import "package:flutter/material.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/collection_updated_event.dart"; +import "package:photos/events/event.dart"; +import "package:photos/events/location_tag_updated_event.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/collection/collection.dart"; +import "package:photos/models/collection/collection_items.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/models/typedefs.dart"; +import "package:photos/services/collections_service.dart"; +import "package:photos/services/search_service.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; +import "package:photos/ui/viewer/location/add_location_sheet.dart"; +import "package:photos/ui/viewer/location/pick_center_point_widget.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; +import "package:photos/utils/share_util.dart"; + +enum ResultType { + collection, + file, + location, + month, + year, + fileType, + fileExtension, + fileCaption, + event, + shared, +} + +enum SectionType { + face, + location, + // Grouping based on ML or manual tagging + content, + // includes year, month , day, event ResultType + moment, + // People section shows the files shared by other persons + people, + fileCaption, + album, + fileTypesAndExtension, +} + +extension SectionTypeExtensions on SectionType { + // passing context for internalization in the future + String sectionTitle(BuildContext context) { + switch (this) { + case SectionType.face: + return "Faces"; + case SectionType.content: + return "Contents"; + case SectionType.moment: + return S.of(context).moments; + case SectionType.location: + return S.of(context).location; + case SectionType.people: + return S.of(context).people; + case SectionType.album: + return S.of(context).albums; + case SectionType.fileTypesAndExtension: + return S.of(context).fileTypes; + case SectionType.fileCaption: + return S.of(context).photoDescriptions; + } + } + + String getEmptyStateText(BuildContext context) { + switch (this) { + case SectionType.face: + return S.of(context).searchFaceEmptySection; + case SectionType.content: + return "Contents"; + case SectionType.moment: + return S.of(context).searchDatesEmptySection; + case SectionType.location: + return S.of(context).searchLocationEmptySection; + case SectionType.people: + return S.of(context).searchPeopleEmptySection; + case SectionType.album: + return S.of(context).searchAlbumsEmptySection; + case SectionType.fileTypesAndExtension: + return S.of(context).searchFileTypesAndNamesEmptySection; + case SectionType.fileCaption: + return S.of(context).searchCaptionEmptySection; + } + } + + // isCTAVisible is used to show/hide the CTA button in the empty state + // Disable the CTA for face, content, moment, fileTypesAndExtension, fileCaption + bool get isCTAVisible { + switch (this) { + case SectionType.face: + return false; + case SectionType.content: + return false; + case SectionType.moment: + return false; + case SectionType.location: + return true; + case SectionType.people: + return true; + case SectionType.album: + return true; + case SectionType.fileTypesAndExtension: + return false; + case SectionType.fileCaption: + return false; + } + } + + bool get isEmptyCTAVisible { + switch (this) { + case SectionType.face: + return true; + case SectionType.content: + return false; + case SectionType.moment: + return false; + case SectionType.location: + return true; + case SectionType.people: + return true; + case SectionType.album: + return true; + case SectionType.fileTypesAndExtension: + return false; + case SectionType.fileCaption: + return false; + } + } + + String getCTAText(BuildContext context) { + switch (this) { + case SectionType.face: + return "Setup"; + case SectionType.content: + return "Add tags"; + case SectionType.moment: + return "Add new"; + case SectionType.location: + return "Add new"; + case SectionType.people: + return "Invite"; + case SectionType.album: + return "Add new"; + case SectionType.fileTypesAndExtension: + return ""; + case SectionType.fileCaption: + return "Add new"; + } + } + + IconData? getCTAIcon() { + switch (this) { + case SectionType.face: + return Icons.adaptive.arrow_forward_outlined; + case SectionType.content: + return null; + case SectionType.moment: + return null; + case SectionType.location: + return Icons.add_location_alt_outlined; + case SectionType.people: + return Icons.adaptive.share; + case SectionType.album: + return Icons.add; + case SectionType.fileTypesAndExtension: + return null; + case SectionType.fileCaption: + return null; + } + } + + FutureVoidCallback ctaOnTap(BuildContext context) { + switch (this) { + case SectionType.people: + return () async { + shareText( + S.of(context).shareTextRecommendUsingEnte, + ); + }; + case SectionType.location: + return () async { + final centerPoint = await showPickCenterPointSheet(context); + if (centerPoint != null) { + showAddLocationSheet(context, centerPoint); + } + }; + case SectionType.album: + return () async { + final result = await showTextInputDialog( + context, + title: S.of(context).newAlbum, + submitButtonLabel: S.of(context).create, + hintText: S.of(context).enterAlbumName, + alwaysShowSuccessState: false, + initialValue: "", + textCapitalization: TextCapitalization.words, + onSubmit: (String text) async { + // indicates user cancelled the rename request + if (text.trim() == "") { + return; + } + try { + final Collection c = + await CollectionsService.instance.createAlbum(text); + routeToPage( + context, + CollectionPage(CollectionWithThumbnail(c, null)), + ); + } catch (e, s) { + Logger("CreateNewAlbumIcon") + .severe("Failed to create a new album", e, s); + rethrow; + } + }, + ); + if (result is Exception) { + showGenericErrorDialog(context: context); + } + }; + default: + { + return () async {}; + } + } + } + + Future> getData({int? limit, BuildContext? context}) { + if (this == SectionType.moment && context == null) { + AssertionError("context cannot be null for SectionType.moment"); + } + switch (this) { + case SectionType.face: + return SearchService.instance.getAllLocationTags(limit); + + case SectionType.content: + return SearchService.instance.getAllLocationTags(limit); + + case SectionType.moment: + return SearchService.instance.getRandomMomentsSearchResults(context!); + + case SectionType.location: + return SearchService.instance.getAllLocationTags(limit); + + case SectionType.people: + return SearchService.instance.getAllPeopleSearchResults(limit); + + case SectionType.album: + return SearchService.instance.getAllCollectionSearchResults(limit); + + case SectionType.fileTypesAndExtension: + return SearchService.instance + .getAllFileTypesAndExtensionsResults(limit); + + case SectionType.fileCaption: + return SearchService.instance.getAllDescriptionSearchResults(limit); + } + } + + List> viewAllUpdateEvents() { + switch (this) { + case SectionType.location: + return [Bus.instance.on()]; + case SectionType.album: + return [Bus.instance.on()]; + default: + return []; + } + } + + ///Events to listen to for different search sections, different from common + ///events listened to in AllSectionsExampleState. + List> sectionUpdateEvents() { + switch (this) { + case SectionType.location: + return [Bus.instance.on()]; + default: + return []; + } + } +} diff --git a/lib/models/typedefs.dart b/lib/models/typedefs.dart index ec753c539..b5624c1bf 100644 --- a/lib/models/typedefs.dart +++ b/lib/models/typedefs.dart @@ -1,6 +1,7 @@ import 'dart:async'; import "package:photos/models/location/location.dart"; +import "package:photos/models/search/search_result.dart"; typedef BoolCallBack = bool Function(); @@ -10,6 +11,7 @@ typedef VoidCallbackParamDouble = Function(double); typedef VoidCallbackParamBool = void Function(bool); typedef VoidCallbackParamListDouble = void Function(List); typedef VoidCallbackParamLocation = void Function(Location); +typedef VoidCallbackParamSearchResults = void Function(List); typedef FutureVoidCallback = Future Function(); typedef FutureOrVoidCallback = FutureOr Function(); diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 30a71cbf9..6fb985602 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -1,4 +1,7 @@ +import "dart:math"; + import "package:flutter/cupertino.dart"; +import "package:intl/intl.dart"; import 'package:logging/logging.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/data/holidays.dart'; @@ -6,15 +9,18 @@ import 'package:photos/data/months.dart'; import 'package:photos/data/years.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/events/local_photos_updated_event.dart'; +import "package:photos/extensions/string_ext.dart"; +import "package:photos/models/api/collection/user.dart"; import 'package:photos/models/collection/collection.dart'; import 'package:photos/models/collection/collection_items.dart'; +import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; import "package:photos/models/local_entity_data.dart"; import "package:photos/models/location_tag/location_tag.dart"; import 'package:photos/models/search/album_search_result.dart'; import 'package:photos/models/search/generic_search_result.dart'; -import 'package:photos/models/search/search_result.dart'; +import "package:photos/models/search/search_types.dart"; import 'package:photos/services/collections_service.dart'; import "package:photos/services/location_service.dart"; import "package:photos/states/location_screen_state.dart"; @@ -88,6 +94,36 @@ class SearchService { return collectionSearchResults; } + Future> getAllCollectionSearchResults( + int? limit, + ) async { + try { + final List collections = + _collectionService.getCollectionsForUI( + includedShared: true, + ); + + final List collectionSearchResults = []; + + for (var c in collections) { + if (limit != null && collectionSearchResults.length >= limit) { + break; + } + + if (!c.isHidden() && c.type != CollectionType.uncategorized) { + final EnteFile? thumbnail = await _collectionService.getCover(c); + collectionSearchResults + .add(AlbumSearchResult(CollectionWithThumbnail(c, thumbnail))); + } + } + + return collectionSearchResults; + } catch (e) { + _logger.severe("error gettin allCollectionSearchResults", e); + return []; + } + } + Future> getYearSearchResults( String yearFromQuery, ) async { @@ -110,6 +146,96 @@ class SearchService { return searchResults; } + Future> getRandomMomentsSearchResults( + BuildContext context, + ) async { + try { + final nonNullSearchResults = []; + final randomYear = getRadomYearSearchResult(); + final randomMonth = getRandomMonthSearchResult(context); + final randomDate = getRandomDateResults(context); + final randomHoliday = getRandomHolidaySearchResult(context); + + final searchResults = await Future.wait( + [randomYear, randomMonth, randomDate, randomHoliday], + ); + + for (GenericSearchResult? searchResult in searchResults) { + if (searchResult != null) { + nonNullSearchResults.add(searchResult); + } + } + + return nonNullSearchResults; + } catch (e) { + _logger.severe("Error getting RandomMomentsSearchResult", e); + return []; + } + } + + Future getRadomYearSearchResult() async { + for (var yearData in YearsData.instance.yearsData..shuffle()) { + final List filesInYear = + await _getFilesInYear(yearData.duration); + if (filesInYear.isNotEmpty) { + return GenericSearchResult( + ResultType.year, + yearData.year, + filesInYear, + ); + } + } + //todo this throws error + return null; + } + + Future> getMonthSearchResults( + BuildContext context, + String query, + ) async { + final List searchResults = []; + for (var month in _getMatchingMonths(context, query)) { + final matchedFiles = + await FilesDB.instance.getFilesCreatedWithinDurations( + _getDurationsOfMonthInEveryYear(month.monthNumber), + ignoreCollections(), + order: 'DESC', + ); + if (matchedFiles.isNotEmpty) { + searchResults.add( + GenericSearchResult( + ResultType.month, + month.name, + matchedFiles, + ), + ); + } + } + return searchResults; + } + + Future getRandomMonthSearchResult( + BuildContext context, + ) async { + final months = getMonthData(context)..shuffle(); + for (MonthData month in months) { + final matchedFiles = + await FilesDB.instance.getFilesCreatedWithinDurations( + _getDurationsOfMonthInEveryYear(month.monthNumber), + ignoreCollections(), + order: 'DESC', + ); + if (matchedFiles.isNotEmpty) { + return GenericSearchResult( + ResultType.month, + month.name, + matchedFiles, + ); + } + } + return null; + } + Future> getHolidaySearchResults( BuildContext context, String query, @@ -138,6 +264,28 @@ class SearchService { return searchResults; } + Future getRandomHolidaySearchResult( + BuildContext context, + ) async { + final holidays = getHolidays(context)..shuffle(); + for (var holiday in holidays) { + final matchedFiles = + await FilesDB.instance.getFilesCreatedWithinDurations( + _getDurationsForCalendarDateInEveryYear(holiday.day, holiday.month), + ignoreCollections(), + order: 'DESC', + ); + if (matchedFiles.isNotEmpty) { + return GenericSearchResult( + ResultType.event, + holiday.name, + matchedFiles, + ); + } + } + return null; + } + Future> getFileTypeResults( String query, ) async { @@ -162,6 +310,203 @@ class SearchService { return searchResults; } + Future> getAllFileTypesAndExtensionsResults( + int? limit, + ) async { + final List searchResults = []; + final List allFiles = await getAllFiles(); + final fileTypesAndMatchingFiles = >{}; + final extensionsAndMatchingFiles = >{}; + try { + for (EnteFile file in allFiles) { + if (!fileTypesAndMatchingFiles.containsKey(file.fileType)) { + fileTypesAndMatchingFiles[file.fileType] = []; + } + fileTypesAndMatchingFiles[file.fileType]!.add(file); + + final String fileName = file.displayName; + late final String ext; + //Noticed that some old edited files do not have extensions and a '.' + ext = fileName.contains(".") + ? fileName.split(".").last.toUpperCase() + : ""; + + if (ext != "") { + if (!extensionsAndMatchingFiles.containsKey(ext)) { + extensionsAndMatchingFiles[ext] = []; + } + extensionsAndMatchingFiles[ext]!.add(file); + } + } + + fileTypesAndMatchingFiles.forEach((key, value) { + searchResults + .add(GenericSearchResult(ResultType.fileType, key.name, value)); + }); + + extensionsAndMatchingFiles.forEach((key, value) { + searchResults + .add(GenericSearchResult(ResultType.fileExtension, key, value)); + }); + + if (limit != null) { + return searchResults.sublist(0, min(limit, searchResults.length)); + } else { + return searchResults; + } + } catch (e) { + _logger.severe("Error getting allFileTypesAndExtensionsResults", e); + return []; + } + } + + ///Todo: Optimise + make this function more readable + //This can be furthur optimized by not just limiting keys to 0 and 1. Use key + //0 for single word, 1 for 2 word, 2 for 3 ..... and only check the substrings + //in higher key if there are matches in the lower key. + Future> getAllDescriptionSearchResults( + //todo: use limit + int? limit, + ) async { + try { + final List searchResults = []; + final List allFiles = await getAllFiles(); + + //each list element will be substrings from a description mapped by + //word count = 1 and word count > 1 + //New items will be added to [orderedSubDescriptions] list for every + //distinct description. + //[orderedSubDescriptions[x]] has two keys, 0 & 1. Value of key 0 will be single + //word substrings. Value of key 1 will be multi word subStrings. When + //iterating through [allFiles], we check for matching substrings from + //[orderedSubDescriptions[x]] with the file's description. Starts from value + //of key 0 (x=0). If there are no substring matches from key 0, there will + //be none from key 1 as well. So these two keys are for avoiding unnecessary + //checking of all subDescriptions with file description. + final orderedSubDescs = >>[]; + final descAndMatchingFiles = >{}; + int distinctFullDescCount = 0; + final allDistinctFullDescs = []; + + for (EnteFile file in allFiles) { + if (file.caption != null && file.caption!.isNotEmpty) { + //This limit doesn't necessarily have to be the limit parameter of the + //method. Using the same variable to avoid unwanted iterations when + //iterating over [orderedSubDescriptions] in case there is a limit + //passed. Using the limit passed here so that there will be almost + //always be more than 7 descriptionAndMatchingFiles and can shuffle + //and choose only limited elements from it. Without shuffling, + //result will be ["hello", "world", "hello world"] for the string + //"hello world" + + if (limit == null || distinctFullDescCount < limit) { + final descAlreadyRecorded = allDistinctFullDescs + .any((element) => element.contains(file.caption!.trim())); + + if (!descAlreadyRecorded) { + distinctFullDescCount++; + allDistinctFullDescs.add(file.caption!.trim()); + final words = file.caption!.trim().split(" "); + orderedSubDescs.add({0: [], 1: []}); + + for (int i = 1; i <= words.length; i++) { + for (int j = 0; j <= words.length - i; j++) { + final subList = words.sublist(j, j + i); + final substring = subList.join(" ").toLowerCase(); + if (i == 1) { + orderedSubDescs.last[0]!.add(substring); + } else { + orderedSubDescs.last[1]!.add(substring); + } + } + } + } + } + + for (Map> orderedSubDescription + in orderedSubDescs) { + bool matchesSingleWordSubString = false; + for (String subDescription in orderedSubDescription[0]!) { + if (file.caption!.toLowerCase().contains(subDescription)) { + matchesSingleWordSubString = true; + + //continue only after setting [matchesSingleWordSubString] to true + if (subDescription.isAllConnectWords || + subDescription.isLastWordConnectWord) continue; + + if (descAndMatchingFiles.containsKey(subDescription)) { + descAndMatchingFiles[subDescription]!.add(file); + } else { + descAndMatchingFiles[subDescription] = {file}; + } + } + } + if (matchesSingleWordSubString) { + for (String subDescription in orderedSubDescription[1]!) { + if (subDescription.isAllConnectWords || + subDescription.isLastWordConnectWord) continue; + + if (file.caption!.toLowerCase().contains(subDescription)) { + if (descAndMatchingFiles.containsKey(subDescription)) { + descAndMatchingFiles[subDescription]!.add(file); + } else { + descAndMatchingFiles[subDescription] = {file}; + } + } + } + } + } + } + } + + ///[relevantDescAndFiles] will be a filterd version of [descriptionAndMatchingFiles] + ///In [descriptionAndMatchingFiles], there will be descriptions with the same + ///set of matching files. These descriptions will be substrings of a full + ///description. [relevantDescAndFiles] will keep only the entry which has the + ///longest description among enties with matching set of files. + final relevantDescAndFiles = >{}; + while (descAndMatchingFiles.isNotEmpty) { + final baseEntry = descAndMatchingFiles.entries.first; + final descsWithSameFiles = >{}; + final baseUploadedFileIDs = + baseEntry.value.map((e) => e.uploadedFileID).toSet(); + + descAndMatchingFiles.forEach((desc, files) { + final uploadedFileIDs = files.map((e) => e.uploadedFileID).toSet(); + + final hasSameFiles = + uploadedFileIDs.containsAll(baseUploadedFileIDs) && + baseUploadedFileIDs.containsAll(uploadedFileIDs); + if (hasSameFiles) { + descsWithSameFiles.addAll({desc: files}); + } + }); + descAndMatchingFiles + .removeWhere((desc, files) => descsWithSameFiles.containsKey(desc)); + final longestDescription = descsWithSameFiles.keys.reduce( + (desc1, desc2) => desc1.length > desc2.length ? desc1 : desc2, + ); + relevantDescAndFiles.addAll( + {longestDescription: descsWithSameFiles[longestDescription]!}, + ); + } + + relevantDescAndFiles.forEach((key, value) { + searchResults.add( + GenericSearchResult(ResultType.fileCaption, key, value.toList()), + ); + }); + if (limit != null) { + return searchResults.sublist(0, min(limit, searchResults.length)); + } else { + return searchResults; + } + } catch (e) { + _logger.severe("Error in getAllDescriptionSearchResults", e); + return []; + } + } + Future> getCaptionAndNameResults( String query, ) async { @@ -321,29 +666,63 @@ class SearchService { return searchResults; } - Future> getMonthSearchResults( - BuildContext context, - String query, - ) async { - final List searchResults = []; - for (var month in _getMatchingMonths(context, query)) { - final matchedFiles = - await FilesDB.instance.getFilesCreatedWithinDurations( - _getDurationsOfMonthInEveryYear(month.monthNumber), - ignoreCollections(), - order: 'DESC', - ); - if (matchedFiles.isNotEmpty) { - searchResults.add( - GenericSearchResult( - ResultType.month, - month.name, - matchedFiles, - ), - ); + Future> getAllLocationTags(int? limit) async { + try { + final Map, List> tagToItemsMap = {}; + final List tagSearchResults = []; + final locationTagEntities = + (await LocationService.instance.getLocationTags()); + final allFiles = await getAllFiles(); + + for (int i = 0; i < locationTagEntities.length; i++) { + if (limit != null && i >= limit) break; + tagToItemsMap[locationTagEntities.elementAt(i)] = []; } + + for (EnteFile file in allFiles) { + if (file.hasLocation) { + for (LocalEntity tag in tagToItemsMap.keys) { + if (LocationService.instance.isFileInsideLocationTag( + tag.item.centerPoint, + file.location!, + tag.item.radius, + )) { + tagToItemsMap[tag]!.add(file); + } + } + } + } + + for (MapEntry, List> entry + in tagToItemsMap.entries) { + if (entry.value.isNotEmpty) { + tagSearchResults.add( + GenericSearchResult( + ResultType.location, + entry.key.item.name, + entry.value, + onResultTap: (ctx) { + routeToPage( + ctx, + LocationScreenStateProvider( + entry.key, + LocationScreen( + //this is SearchResult.heroTag() + tagPrefix: + "${ResultType.location.toString()}_${entry.key.item.name}", + ), + ), + ); + }, + ), + ); + } + } + return tagSearchResults; + } catch (e) { + _logger.severe("Error in getAllLocationTags", e); + return []; } - return searchResults; } Future> getDateResults( @@ -376,6 +755,124 @@ class SearchService { return searchResults; } + Future getRandomDateResults( + BuildContext context, + ) async { + final allFiles = await getAllFiles(); + if (allFiles.isEmpty) return null; + + final length = allFiles.length; + final randomFile = allFiles[Random().nextInt(length)]; + final creationTime = randomFile.creationTime!; + + final originalDateTime = DateTime.fromMicrosecondsSinceEpoch(creationTime); + final startOfDay = DateTime( + originalDateTime.year, + originalDateTime.month, + originalDateTime.day, + ); + + final endOfDay = DateTime( + originalDateTime.year, + originalDateTime.month, + originalDateTime.day + 1, + ); + + final durationOfDay = [ + startOfDay.microsecondsSinceEpoch, + endOfDay.microsecondsSinceEpoch, + ]; + + final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( + [durationOfDay], + ignoreCollections(), + order: 'DESC', + ); + + return GenericSearchResult( + ResultType.event, + DateFormat.yMMMd(Localizations.localeOf(context).languageCode).format( + DateTime.fromMicrosecondsSinceEpoch(creationTime).toLocal(), + ), + matchedFiles, + ); + } + + Future> getPeopleSearchResults( + String query, + ) async { + final lowerCaseQuery = query.toLowerCase(); + final searchResults = []; + final allFiles = await getAllFiles(); + final peopleToSharedFiles = >{}; + for (EnteFile file in allFiles) { + if (file.isOwner) continue; + + final fileOwner = CollectionsService.instance + .getFileOwner(file.ownerID!, file.collectionID); + + if (fileOwner.email.toLowerCase().contains(lowerCaseQuery) || + ((fileOwner.name?.toLowerCase().contains(lowerCaseQuery)) ?? false)) { + if (peopleToSharedFiles.containsKey(fileOwner)) { + peopleToSharedFiles[fileOwner]!.add(file); + } else { + peopleToSharedFiles[fileOwner] = [file]; + } + } + } + + peopleToSharedFiles.forEach((key, value) { + searchResults.add( + GenericSearchResult( + ResultType.shared, + key.name != null && key.name!.isNotEmpty ? key.name! : key.email, + value, + ), + ); + }); + + return searchResults; + } + + Future> getAllPeopleSearchResults( + int? limit, + ) async { + try { + final searchResults = []; + final allFiles = await getAllFiles(); + final peopleToSharedFiles = >{}; + int peopleCount = 0; + for (EnteFile file in allFiles) { + if (file.isOwner) continue; + + final fileOwner = CollectionsService.instance + .getFileOwner(file.ownerID!, file.collectionID); + if (peopleToSharedFiles.containsKey(fileOwner)) { + peopleToSharedFiles[fileOwner]!.add(file); + } else { + if (limit != null && limit <= peopleCount) continue; + peopleToSharedFiles[fileOwner] = [file]; + peopleCount++; + } + } + + peopleToSharedFiles.forEach((key, value) { + searchResults.add( + GenericSearchResult( + ResultType.shared, + key.name != null && key.name!.isNotEmpty ? key.name! : key.email, + value, + ), + ); + }); + + return searchResults; + } catch (e) { + _logger.severe("Error in getAllLocationTags", e); + return []; + } + } + List _getMatchingMonths(BuildContext context, String query) { return getMonthData(context) .where( diff --git a/lib/states/all_sections_examples_state.dart b/lib/states/all_sections_examples_state.dart new file mode 100644 index 000000000..2a6dc8ca8 --- /dev/null +++ b/lib/states/all_sections_examples_state.dart @@ -0,0 +1,108 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter/scheduler.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/files_updated_event.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/utils/debouncer.dart"; + +class AllSectionsExamplesProvider extends StatefulWidget { + final Widget child; + const AllSectionsExamplesProvider({super.key, required this.child}); + + @override + State createState() => + _AllSectionsExamplesProviderState(); +} + +class _AllSectionsExamplesProviderState + extends State { + //Some section results in [allSectionsExamplesFuture] can be out of sync + //with what is displayed on UI. This happens when some section is + //independently listening to some set of events and is rebuilt. Sections + //can listen to a list of events and rebuild (see sectionUpdateEvents() + //in search_types.dart) and new results will not reflect in + //[allSectionsExamplesFuture] unless reloadAllSections() is called. + Future>> allSectionsExamplesFuture = Future.value([]); + + late StreamSubscription _filesUpdatedEvent; + final _logger = Logger("AllSectionsExamplesProvider"); + + final _debouncer = + Debouncer(const Duration(seconds: 3), executionInterval: 6000); + @override + void initState() { + super.initState(); + SchedulerBinding.instance.addPostFrameCallback((timeStamp) { + //add all common events for all search sections to reload to here. + _filesUpdatedEvent = Bus.instance.on().listen((event) { + reloadAllSections(); + }); + reloadAllSections(); + }); + } + + void reloadAllSections() { + _debouncer.run(() async { + setState(() { + _logger.info("reloading all sections in search tab"); + final allSectionsExamples = >>[]; + for (SectionType sectionType in SectionType.values) { + if (sectionType == SectionType.face || + sectionType == SectionType.content) { + continue; + } + allSectionsExamples.add( + sectionType.getData(limit: searchSectionLimit, context: context), + ); + } + allSectionsExamplesFuture = + Future.wait>(allSectionsExamples); + }); + }); + } + + @override + void dispose() { + _filesUpdatedEvent.cancel(); + _debouncer.cancelDebounce(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InheritedAllSectionsExamples( + allSectionsExamplesFuture, + _debouncer.debounceActiveNotifier, + child: widget.child, + ); + } +} + +class InheritedAllSectionsExamples extends InheritedWidget { + final Future>> allSectionsExamplesFuture; + final ValueNotifier isDebouncingNotifier; + const InheritedAllSectionsExamples( + this.allSectionsExamplesFuture, + this.isDebouncingNotifier, { + super.key, + required super.child, + }); + + static InheritedAllSectionsExamples of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType()!; + } + + @override + bool updateShouldNotify(covariant InheritedAllSectionsExamples oldWidget) { + return !identical( + oldWidget.allSectionsExamplesFuture, + allSectionsExamplesFuture, + ); + } +} diff --git a/lib/states/location_state.dart b/lib/states/location_state.dart index 93131a730..5711b2af8 100644 --- a/lib/states/location_state.dart +++ b/lib/states/location_state.dart @@ -168,8 +168,6 @@ class InheritedLocationTagData extends InheritedWidget { @override bool updateShouldNotify(InheritedLocationTagData oldWidget) { - print(selectedRadius); - print(oldWidget.selectedRadius != selectedRadius); return oldWidget.selectedRadius != selectedRadius || !oldWidget.radiusValues.equals(radiusValues) || oldWidget.centerPoint != centerPoint || diff --git a/lib/states/search_results_state.dart b/lib/states/search_results_state.dart new file mode 100644 index 000000000..042f6f2a8 --- /dev/null +++ b/lib/states/search_results_state.dart @@ -0,0 +1,53 @@ +import "package:flutter/cupertino.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/models/typedefs.dart"; + +class SearchResultsProvider extends StatefulWidget { + final Widget child; + const SearchResultsProvider({ + required this.child, + super.key, + }); + + @override + State createState() => _SearchResultsProviderState(); +} + +class _SearchResultsProviderState extends State { + var searchResults = []; + @override + Widget build(BuildContext context) { + return InheritedSearchResults( + searchResults, + updateSearchResults, + child: widget.child, + ); + } + + void updateSearchResults(List newResult) { + setState(() { + searchResults = newResult; + }); + } +} + +class InheritedSearchResults extends InheritedWidget { + final List results; + final VoidCallbackParamSearchResults updateResults; + const InheritedSearchResults( + this.results, + this.updateResults, { + required super.child, + super.key, + }); + + static InheritedSearchResults of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType()!; + } + + @override + bool updateShouldNotify(covariant InheritedSearchResults oldWidget) { + return results != oldWidget.results; + } +} diff --git a/lib/ui/collections/collection_list_page.dart b/lib/ui/collections/collection_list_page.dart index f33c81b21..589b6d77b 100644 --- a/lib/ui/collections/collection_list_page.dart +++ b/lib/ui/collections/collection_list_page.dart @@ -60,6 +60,7 @@ class _CollectionListPageState extends State { return Scaffold( body: SafeArea( child: CustomScrollView( + physics: const BouncingScrollPhysics(), controller: ScrollController( initialScrollOffset: widget.initialScrollOffset ?? 0, ), diff --git a/lib/ui/collections/device/device_folders_vertical_grid_view.dart b/lib/ui/collections/device/device_folders_vertical_grid_view.dart index 897d07038..ad7706bd7 100644 --- a/lib/ui/collections/device/device_folders_vertical_grid_view.dart +++ b/lib/ui/collections/device/device_folders_vertical_grid_view.dart @@ -23,6 +23,7 @@ class DeviceFolderVerticalGridView extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( + physics: const BouncingScrollPhysics(), slivers: [ SliverAppBar( elevation: 0, diff --git a/lib/ui/collections/new_album_icon.dart b/lib/ui/collections/new_album_icon.dart index 0ac98c372..dddf81cf5 100644 --- a/lib/ui/collections/new_album_icon.dart +++ b/lib/ui/collections/new_album_icon.dart @@ -10,13 +10,21 @@ import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/navigation_util.dart"; class NewAlbumIcon extends StatelessWidget { - const NewAlbumIcon({Key? key}) : super(key: key); + final IconData icon; + final Color? color; + final IconButtonType iconButtonType; + const NewAlbumIcon({ + required this.icon, + required this.iconButtonType, + this.color, + Key? key, + }) : super(key: key); @override Widget build(BuildContext context) { return IconButtonWidget( - icon: Icons.add_rounded, - iconButtonType: IconButtonType.secondary, + icon: icon, + iconButtonType: iconButtonType, onTap: () async { final result = await showTextInputDialog( context, diff --git a/lib/ui/components/action_sheet_widget.dart b/lib/ui/components/action_sheet_widget.dart index 62b3983aa..aa4662792 100644 --- a/lib/ui/components/action_sheet_widget.dart +++ b/lib/ui/components/action_sheet_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'package:photos/core/constants.dart'; -import "package:photos/models/search/button_result.dart"; +import 'package:photos/models/button_result.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/effects.dart'; import 'package:photos/theme/ente_theme.dart'; diff --git a/lib/ui/components/buttons/button_widget.dart b/lib/ui/components/buttons/button_widget.dart index 01c1b84ef..63054bca3 100644 --- a/lib/ui/components/buttons/button_widget.dart +++ b/lib/ui/components/buttons/button_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import "package:photos/models/button_result.dart"; import 'package:photos/models/execution_states.dart'; -import "package:photos/models/search/button_result.dart"; import 'package:photos/models/typedefs.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/ente_theme.dart'; diff --git a/lib/ui/components/dialog_widget.dart b/lib/ui/components/dialog_widget.dart index bd0c10f31..867fd59e5 100644 --- a/lib/ui/components/dialog_widget.dart +++ b/lib/ui/components/dialog_widget.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import "package:flutter/services.dart"; import 'package:photos/core/constants.dart'; import "package:photos/generated/l10n.dart"; -import "package:photos/models/search/button_result.dart"; +import 'package:photos/models/button_result.dart'; import 'package:photos/models/typedefs.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/effects.dart'; @@ -213,6 +213,7 @@ class _TextInputDialogState extends State { @override void initState() { + super.initState(); _textEditingController = widget.textEditingController ?? TextEditingController(); _inputIsEmptyNotifier = widget.initialValue?.isEmpty ?? true @@ -223,7 +224,6 @@ class _TextInputDialogState extends State { _inputIsEmptyNotifier.value = _textEditingController.text.isEmpty; } }); - super.initState(); } @override @@ -235,7 +235,7 @@ class _TextInputDialogState extends State { @override Widget build(BuildContext context) { - final widthOfScreen = MediaQuery.of(context).size.width; + final widthOfScreen = MediaQuery.sizeOf(context).width; final isMobileSmall = widthOfScreen <= mobileSmallThreshold; final colorScheme = getEnteColorScheme(context); return Container( diff --git a/lib/ui/components/home_header_widget.dart b/lib/ui/components/home_header_widget.dart index 89dfe425c..aea0aca52 100644 --- a/lib/ui/components/home_header_widget.dart +++ b/lib/ui/components/home_header_widget.dart @@ -1,6 +1,15 @@ +import "dart:async"; +import "dart:io"; + import 'package:flutter/material.dart'; +import "package:logging/logging.dart"; +import "package:photo_manager/photo_manager.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/services/local_sync_service.dart"; import 'package:photos/ui/components/buttons/icon_button_widget.dart'; -import 'package:photos/ui/viewer/search/search_widget.dart'; +import "package:photos/ui/settings/backup/backup_folder_selection_page.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; class HomeHeaderWidget extends StatefulWidget { final Widget centerWidget; @@ -33,7 +42,56 @@ class _HomeHeaderWidgetState extends State { duration: const Duration(milliseconds: 250), child: widget.centerWidget, ), - const SearchIconWidget(), + IconButtonWidget( + icon: Icons.add_photo_alternate_outlined, + iconButtonType: IconButtonType.primary, + onTap: () async { + try { + final PermissionState state = + await PhotoManager.requestPermissionExtend(); + await LocalSyncService.instance.onUpdatePermission(state); + } on Exception catch (e) { + Logger("HomeHeaderWidget").severe( + "Failed to request permission: ${e.toString()}", + e, + ); + } + if (!LocalSyncService.instance.hasGrantedFullPermission()) { + if (Platform.isAndroid) { + await PhotoManager.openSetting(); + } else { + final bool hasGrantedLimit = + LocalSyncService.instance.hasGrantedLimitedPermissions(); + showChoiceActionSheet( + context, + title: S.of(context).preserveMore, + body: S.of(context).grantFullAccessPrompt, + firstButtonLabel: S.of(context).openSettings, + firstButtonOnTap: () async { + await PhotoManager.openSetting(); + }, + secondButtonLabel: hasGrantedLimit + ? S.of(context).selectMorePhotos + : S.of(context).cancel, + secondButtonOnTap: () async { + if (hasGrantedLimit) { + await PhotoManager.presentLimited(); + } + }, + ); + } + } else { + unawaited( + routeToPage( + context, + BackupFolderSelectionPage( + buttonText: S.of(context).backup, + ), + ), + ); + } + }, + ), ], ); } diff --git a/lib/ui/components/notification_widget.dart b/lib/ui/components/notification_widget.dart index cf684f0f4..6779a58fa 100644 --- a/lib/ui/components/notification_widget.dart +++ b/lib/ui/components/notification_widget.dart @@ -36,7 +36,7 @@ class NotificationWidget extends StatelessWidget { @override Widget build(BuildContext context) { - EnteColorScheme colorScheme = getEnteColorScheme(context); + final colorScheme = getEnteColorScheme(context); EnteTextTheme textTheme = getEnteTextTheme(context); TextStyle mainTextStyle = darkTextTheme.bodyBold; TextStyle subTextStyle = darkTextTheme.miniMuted; @@ -49,7 +49,6 @@ class NotificationWidget extends StatelessWidget { backgroundColor = warning500; break; case NotificationType.banner: - colorScheme = getEnteColorScheme(context); textTheme = getEnteTextTheme(context); backgroundColor = colorScheme.backgroundElevated2; mainTextStyle = textTheme.bodyBold; @@ -158,10 +157,47 @@ class NotificationWidget extends StatelessWidget { } } +class NotificationTipWidget extends StatelessWidget { + final String name; + const NotificationTipWidget(this.name, {super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + decoration: BoxDecoration( + border: Border.all(color: colorScheme.strokeFaint), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 12, + child: Text( + name, + style: textTheme.miniFaint, + ), + ), + Flexible( + flex: 2, + child: Icon( + Icons.tips_and_updates_outlined, + color: colorScheme.strokeFaint, + size: 36, + ), + ), + ], + ), + ); + } +} + class NotificationNoteWidget extends StatelessWidget { final String note; const NotificationNoteWidget(this.note, {super.key}); - @override Widget build(BuildContext context) { final colorScheme = getEnteColorScheme(context); diff --git a/lib/ui/components/text_input_widget.dart b/lib/ui/components/text_input_widget.dart index f85ce1184..8edb66ab8 100644 --- a/lib/ui/components/text_input_widget.dart +++ b/lib/ui/components/text_input_widget.dart @@ -90,6 +90,7 @@ class _TextInputWidgetState extends State { @override void initState() { + super.initState(); widget.submitNotifier?.addListener(_onSubmit); widget.cancelNotifier?.addListener(_onCancel); _textController = widget.textEditingController ?? TextEditingController(); @@ -109,7 +110,6 @@ class _TextInputWidgetState extends State { widget.isEmptyNotifier!.value = _textController.text.isEmpty; }); } - super.initState(); } @override diff --git a/lib/ui/home/home_bottom_nav_bar.dart b/lib/ui/home/home_bottom_nav_bar.dart index f43f08465..e4867a08b 100644 --- a/lib/ui/home/home_bottom_nav_bar.dart +++ b/lib/ui/home/home_bottom_nav_bar.dart @@ -147,6 +147,20 @@ class _HomeBottomNavigationBarState extends State { // of occasional missing events }, ), + GButton( + margin: const EdgeInsets.fromLTRB(10, 6, 8, 6), + icon: Icons.search_outlined, + iconColor: enteColorScheme.tabIcon, + iconActiveColor: strokeBaseLight, + text: '', + onPressed: () { + _onTabChange( + 3, + mode: "OnPressed", + ); // To take care + // of occasional missing events + }, + ), ], selectedIndex: currentTabIndex, onTabChange: _onTabChange, diff --git a/lib/ui/home/preserve_footer_widget.dart b/lib/ui/home/preserve_footer_widget.dart deleted file mode 100644 index a9a3019c8..000000000 --- a/lib/ui/home/preserve_footer_widget.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:async'; -import "dart:io"; - -import 'package:flutter/material.dart'; -import "package:logging/logging.dart"; -import 'package:photo_manager/photo_manager.dart'; -import "package:photos/generated/l10n.dart"; -import 'package:photos/services/local_sync_service.dart'; -import 'package:photos/ui/common/gradient_button.dart'; -import 'package:photos/ui/settings/backup/backup_folder_selection_page.dart'; -import "package:photos/utils/dialog_util.dart"; -import 'package:photos/utils/navigation_util.dart'; - -class PreserveFooterWidget extends StatelessWidget { - const PreserveFooterWidget({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 100), - child: GradientButton( - onTap: () async { - try { - final PermissionState state = - await PhotoManager.requestPermissionExtend(); - await LocalSyncService.instance.onUpdatePermission(state); - } on Exception catch (e) { - Logger("PreserveFooterWidget").severe( - "Failed to request permission: ${e.toString()}", - e, - ); - } - if (!LocalSyncService.instance.hasGrantedFullPermission()) { - if (Platform.isAndroid) { - await PhotoManager.openSetting(); - } else { - final bool hasGrantedLimit = - LocalSyncService.instance.hasGrantedLimitedPermissions(); - showChoiceActionSheet( - context, - title: S.of(context).preserveMore, - body: S.of(context).grantFullAccessPrompt, - firstButtonLabel: S.of(context).openSettings, - firstButtonOnTap: () async { - await PhotoManager.openSetting(); - }, - secondButtonLabel: hasGrantedLimit - ? S.of(context).selectMorePhotos - : S.of(context).cancel, - secondButtonOnTap: () async { - if (hasGrantedLimit) { - await PhotoManager.presentLimited(); - } - }, - ); - } - } else { - unawaited( - routeToPage( - context, - BackupFolderSelectionPage( - buttonText: S.of(context).backup, - ), - ), - ); - } - }, - text: S.of(context).preserveMore, - iconData: Icons.cloud_upload_outlined, - ), - ); - } -} diff --git a/lib/ui/huge_listview/huge_listview.dart b/lib/ui/huge_listview/huge_listview.dart index adb7b892f..a2dd3119c 100644 --- a/lib/ui/huge_listview/huge_listview.dart +++ b/lib/ui/huge_listview/huge_listview.dart @@ -170,7 +170,7 @@ class HugeListViewState extends State> { child: ScrollablePositionedList.builder( physics: widget.disableScroll ? const NeverScrollableScrollPhysics() - : null, + : const BouncingScrollPhysics(), itemScrollController: widget.controller, itemPositionsListener: listener, initialScrollIndex: widget.startIndex, @@ -183,6 +183,7 @@ class HugeListViewState extends State> { ), ) : ListView.builder( + physics: const BouncingScrollPhysics(), itemCount: max(widget.totalCount, 0), itemBuilder: (context, index) { return ExcludeSemantics( diff --git a/lib/ui/map/enable_map.dart b/lib/ui/map/enable_map.dart index 74803a811..320c7b033 100644 --- a/lib/ui/map/enable_map.dart +++ b/lib/ui/map/enable_map.dart @@ -1,6 +1,6 @@ import "package:flutter/cupertino.dart"; import "package:photos/generated/l10n.dart"; -import "package:photos/models/search/button_result.dart"; +import 'package:photos/models/button_result.dart'; import "package:photos/services/user_remote_flag_service.dart"; import "package:photos/ui/components/buttons/button_widget.dart"; import "package:photos/ui/components/dialog_widget.dart"; diff --git a/lib/ui/search_tab.dart b/lib/ui/search_tab.dart new file mode 100644 index 000000000..a54a131f6 --- /dev/null +++ b/lib/ui/search_tab.dart @@ -0,0 +1,128 @@ +import "package:fade_indexed_stack/fade_indexed_stack.dart"; +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/states/all_sections_examples_state.dart"; +import "package:photos/states/search_results_state.dart"; +import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/viewer/search/result/no_result_widget.dart"; +import "package:photos/ui/viewer/search/search_section.dart"; +import "package:photos/ui/viewer/search/search_suggestions.dart"; +import 'package:photos/ui/viewer/search/search_widget.dart'; +import "package:photos/ui/viewer/search/tab_empty_state.dart"; + +class SearchTab extends StatefulWidget { + const SearchTab({Key? key}) : super(key: key); + + @override + State createState() => _SearchTabState(); +} + +class _SearchTabState extends State { + var _searchResults = []; + int index = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _searchResults = InheritedSearchResults.of(context).results; + if (_searchResults.isEmpty) { + if (isSearchQueryEmpty) { + index = 0; + } else { + index = 2; + } + } else { + index = 1; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: AllSectionsExamplesProvider( + child: FadeIndexedStack( + duration: const Duration(milliseconds: 150), + index: index, + children: [ + const AllSearchSections(), + SearchSuggestionsWidget(_searchResults), + const NoResultWidget(), + ], + ), + ), + ); + } +} + +class AllSearchSections extends StatefulWidget { + const AllSearchSections({super.key}); + + @override + State createState() => _AllSearchSectionsState(); +} + +class _AllSearchSectionsState extends State { + @override + Widget build(BuildContext context) { + final searchTypes = SectionType.values.toList(growable: true); + // remove face and content sectionType + searchTypes.remove(SectionType.face); + searchTypes.remove(SectionType.content); + return Stack( + children: [ + FutureBuilder( + future: InheritedAllSectionsExamples.of(context) + .allSectionsExamplesFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + if (snapshot.data!.every((element) => element.isEmpty)) { + return const Padding( + padding: EdgeInsets.only(bottom: 72), + child: SearchTabEmptyState(), + ); + } + return ListView.builder( + padding: const EdgeInsets.only(bottom: 180), + physics: const BouncingScrollPhysics(), + itemCount: searchTypes.length, + itemBuilder: (context, index) { + return SearchSection( + sectionType: searchTypes[index], + examples: snapshot.data!.elementAt(index), + limit: searchSectionLimit, + ); + }, + ); + } else if (snapshot.hasError) { + //Errors are handled and this else if condition will be false always + //is the understanding. + return const Padding( + padding: EdgeInsets.only(bottom: 72), + child: EnteLoadingWidget(), + ); + } else { + return const Padding( + padding: EdgeInsets.only(bottom: 72), + child: EnteLoadingWidget(), + ); + } + }, + ), + ValueListenableBuilder( + valueListenable: + InheritedAllSectionsExamples.of(context).isDebouncingNotifier, + builder: (context, value, _) { + return value + ? const EnteLoadingWidget( + alignment: Alignment.topRight, + ) + : const SizedBox.shrink(); + }, + ), + ], + ); + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 2f314906d..044a70975 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -171,6 +171,7 @@ class SettingsPage extends StatelessWidget { return SafeArea( bottom: false, child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/ui/tabs/home_widget.dart b/lib/ui/tabs/home_widget.dart index a211c468e..e2ccdee87 100644 --- a/lib/ui/tabs/home_widget.dart +++ b/lib/ui/tabs/home_widget.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import "package:flutter_animate/flutter_animate.dart"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; import 'package:logging/logging.dart'; import 'package:media_extension/media_extension_action_types.dart'; @@ -32,8 +33,10 @@ import 'package:photos/services/local_sync_service.dart'; import "package:photos/services/notification_service.dart"; import 'package:photos/services/update_service.dart'; import 'package:photos/services/user_service.dart'; +import "package:photos/states/search_results_state.dart"; import 'package:photos/states/user_details_state.dart'; import 'package:photos/theme/colors.dart'; +import "package:photos/theme/effects.dart"; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/collections/collection_action_sheet.dart'; import 'package:photos/ui/extents_page_view.dart'; @@ -43,14 +46,15 @@ import 'package:photos/ui/home/home_bottom_nav_bar.dart'; import 'package:photos/ui/home/home_gallery_widget.dart'; import 'package:photos/ui/home/landing_page_widget.dart'; import "package:photos/ui/home/loading_photos_widget.dart"; -import 'package:photos/ui/home/preserve_footer_widget.dart'; import 'package:photos/ui/home/start_backup_hook_widget.dart'; import 'package:photos/ui/notification/update/change_log_page.dart'; +import "package:photos/ui/search_tab.dart"; import 'package:photos/ui/settings/app_update_dialog.dart'; -import 'package:photos/ui/settings_page.dart'; +import "package:photos/ui/settings_page.dart"; import "package:photos/ui/tabs/shared_collections_tab.dart"; import "package:photos/ui/tabs/user_collections_tab.dart"; import "package:photos/ui/viewer/gallery/collection_page.dart"; +import 'package:photos/ui/viewer/search/search_widget.dart'; import 'package:photos/utils/dialog_util.dart'; import "package:photos/utils/navigation_util.dart"; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; @@ -69,6 +73,7 @@ class HomeWidget extends StatefulWidget { class _HomeWidgetState extends State { static const _userCollectionsTab = UserCollectionsTab(); static const _sharedCollectionTab = SharedCollectionsTab(); + static const _searchTab = SearchTab(); static final _settingsPage = SettingsPage( emailNotifier: UserService.instance.emailValueNotifier, ); @@ -87,6 +92,7 @@ class _HomeWidgetState extends State { List? _sharedFiles; bool _shouldRenderCreateCollectionSheet = false; bool _showShowBackupHook = false; + final isOnSearchTabNotifier = ValueNotifier(false); late StreamSubscription _tabChangedEventSubscription; late StreamSubscription @@ -104,12 +110,17 @@ class _HomeWidgetState extends State { _logger.info("Building initstate"); _tabChangedEventSubscription = Bus.instance.on().listen((event) { + _selectedTabIndex = event.selectedIndex; + + if (event.selectedIndex == 3) { + isOnSearchTabNotifier.value = true; + } else { + isOnSearchTabNotifier.value = false; + } if (event.source != TabChangedEventSource.pageView) { debugPrint( "TabChange going from $_selectedTabIndex to ${event.selectedIndex} souce: ${event.source}", ); - _selectedTabIndex = event.selectedIndex; - // _pageController.jumpToPage(_selectedTabIndex); _pageController.animateToPage( event.selectedIndex, duration: const Duration(milliseconds: 100), @@ -263,6 +274,7 @@ class _HomeWidgetState extends State { _accountConfiguredEvent.cancel(); _intentDataStreamSubscription?.cancel(); _collectionUpdatedEvent.cancel(); + isOnSearchTabNotifier.dispose(); _pageController.dispose(); super.dispose(); } @@ -381,44 +393,90 @@ class _HomeWidgetState extends State { !LocalSyncService.instance.hasGrantedLimitedPermissions() && CollectionsService.instance.getActiveCollections().isEmpty; - return Stack( - children: [ - Builder( - builder: (context) { - return ExtentsPageView( - onPageChanged: (page) { - Bus.instance.fire( - TabChangedEvent( - page, - TabChangedEventSource.pageView, + return SearchResultsProvider( + child: Stack( + children: [ + Builder( + builder: (context) { + return ExtentsPageView( + onPageChanged: (page) { + Bus.instance.fire( + TabChangedEvent( + page, + TabChangedEventSource.pageView, + ), + ); + }, + controller: _pageController, + openDrawer: Scaffold.of(context).openDrawer, + physics: const BouncingScrollPhysics(), + children: [ + _showShowBackupHook + ? const StartBackupHookWidget(headerWidget: _headerWidget) + : HomeGalleryWidget( + header: _headerWidget, + footer: const SizedBox( + height: 160, + ), + selectedFiles: _selectedFiles, + ), + _userCollectionsTab, + _sharedCollectionTab, + _searchTab, + ], + ); + }, + ), + Align( + alignment: Alignment.bottomCenter, + child: ValueListenableBuilder( + valueListenable: isOnSearchTabNotifier, + builder: (context, value, child) { + return Container( + decoration: value + ? BoxDecoration( + color: getEnteColorScheme(context).backgroundElevated, + boxShadow: shadowFloatFaintLight, + ) + : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + value + ? const SearchWidget() + .animate() + .fadeIn( + duration: const Duration(milliseconds: 225), + curve: Curves.easeInOutSine, + ) + .scale( + begin: const Offset(0.8, 0.8), + end: const Offset(1, 1), + duration: const Duration( + milliseconds: 225, + ), + curve: Curves.easeInOutSine, + ) + .slide( + begin: const Offset(0, 0.4), + curve: Curves.easeInOutSine, + duration: const Duration( + milliseconds: 225, + ), + ) + : const SizedBox.shrink(), + HomeBottomNavigationBar( + _selectedFiles, + selectedTabIndex: _selectedTabIndex, + ), + ], ), ); }, - controller: _pageController, - openDrawer: Scaffold.of(context).openDrawer, - physics: const BouncingScrollPhysics(), - children: [ - _showShowBackupHook - ? const StartBackupHookWidget(headerWidget: _headerWidget) - : HomeGalleryWidget( - header: _headerWidget, - footer: const PreserveFooterWidget(), - selectedFiles: _selectedFiles, - ), - _userCollectionsTab, - _sharedCollectionTab, - ], - ); - }, - ), - Align( - alignment: Alignment.bottomCenter, - child: HomeBottomNavigationBar( - _selectedFiles, - selectedTabIndex: _selectedTabIndex, + ), ), - ), - ], + ], + ), ); } diff --git a/lib/ui/tabs/shared_collections_tab.dart b/lib/ui/tabs/shared_collections_tab.dart index 6dd635e9d..665bb8623 100644 --- a/lib/ui/tabs/shared_collections_tab.dart +++ b/lib/ui/tabs/shared_collections_tab.dart @@ -89,6 +89,7 @@ class _SharedCollectionsTabState extends State final SectionTitle sharedByYou = SectionTitle(title: S.of(context).sharedByYou); return SingleChildScrollView( + physics: const BouncingScrollPhysics(), child: Container( margin: const EdgeInsets.only(bottom: 50), child: Column( diff --git a/lib/ui/tabs/user_collections_tab.dart b/lib/ui/tabs/user_collections_tab.dart index d37d4d9c3..7c49f976f 100644 --- a/lib/ui/tabs/user_collections_tab.dart +++ b/lib/ui/tabs/user_collections_tab.dart @@ -95,6 +95,7 @@ class _UserCollectionsTabState extends State ); return CustomScrollView( + physics: const BouncingScrollPhysics(), controller: _scrollController, slivers: [ SliverToBoxAdapter( @@ -224,7 +225,10 @@ class _UserCollectionsTabState extends State ), child: Row( children: [ - const NewAlbumIcon(), + const NewAlbumIcon( + icon: Icons.add_rounded, + iconButtonType: IconButtonType.secondary, + ), GestureDetector( onTapDown: (TapDownDetails details) async { final int? selectedValue = await showMenu( diff --git a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index 63c928a6c..b23780edf 100644 --- a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -113,15 +113,14 @@ class _GalleryAppBarWidgetState extends State { : AppBar( elevation: 0, centerTitle: false, - title: TextButton( - child: Text( - _appBarTitle!, - style: Theme.of(context) - .textTheme - .headlineSmall! - .copyWith(fontSize: 16), - ), - onPressed: () => _renameAlbum(context), + title: Text( + _appBarTitle!, + style: Theme.of(context) + .textTheme + .headlineSmall! + .copyWith(fontSize: 16), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), actions: _getDefaultActions(context), ); diff --git a/lib/ui/viewer/location/edit_center_point_tile_widget.dart b/lib/ui/viewer/location/edit_center_point_tile_widget.dart index 169dc825d..9891bd02e 100644 --- a/lib/ui/viewer/location/edit_center_point_tile_widget.dart +++ b/lib/ui/viewer/location/edit_center_point_tile_widget.dart @@ -1,6 +1,6 @@ import "package:flutter/material.dart"; import "package:photos/generated/l10n.dart"; -import 'package:photos/models/file/file.dart'; +import "package:photos/models/location/location.dart"; import "package:photos/services/location_service.dart"; import "package:photos/states/location_state.dart"; import "package:photos/theme/ente_theme.dart"; @@ -50,13 +50,16 @@ class EditCenterPointTileWidget extends StatelessWidget { ), IconButtonWidget( onTap: () async { - final EnteFile? centerPointFile = await showPickCenterPointSheet( + final Location? centerPoint = await showPickCenterPointSheet( context, - InheritedLocationTagData.of(context).locationTagEntity!, + locationTagName: InheritedLocationTagData.of(context) + .locationTagEntity! + .item + .name, ); - if (centerPointFile != null) { + if (centerPoint != null) { InheritedLocationTagData.of(context) - .updateCenterPoint(centerPointFile.location!); + .updateCenterPoint(centerPoint); } }, icon: Icons.edit, diff --git a/lib/ui/viewer/location/location_screen.dart b/lib/ui/viewer/location/location_screen.dart index 2915f94ae..8e82a1991 100644 --- a/lib/ui/viewer/location/location_screen.dart +++ b/lib/ui/viewer/location/location_screen.dart @@ -28,7 +28,8 @@ import "package:photos/ui/viewer/location/edit_location_sheet.dart"; import "package:photos/utils/dialog_util.dart"; class LocationScreen extends StatelessWidget { - const LocationScreen({super.key}); + final String tagPrefix; + const LocationScreen({this.tagPrefix = "", super.key}); @override Widget build(BuildContext context) { @@ -49,7 +50,9 @@ class LocationScreen extends StatelessWidget { height: MediaQuery.of(context).size.height - (heightOfAppBar + heightOfStatusBar), width: double.infinity, - child: const LocationGalleryWidget(), + child: LocationGalleryWidget( + tagPrefix: tagPrefix, + ), ), ], ), @@ -126,7 +129,8 @@ class LocationScreenPopUpMenu extends StatelessWidget { } class LocationGalleryWidget extends StatefulWidget { - const LocationGalleryWidget({super.key}); + final String tagPrefix; + const LocationGalleryWidget({required this.tagPrefix, super.key}); @override State createState() => _LocationGalleryWidgetState(); @@ -229,7 +233,7 @@ class _LocationGalleryWidgetState extends State { EventType.deletedFromEverywhere, }, selectedFiles: _selectedFiles, - tagPrefix: "location_gallery", + tagPrefix: widget.tagPrefix, ), FileSelectionOverlayBar( GalleryType.locationTag, diff --git a/lib/ui/viewer/location/pick_center_point_widget.dart b/lib/ui/viewer/location/pick_center_point_widget.dart index 7f7269aee..de95aff09 100644 --- a/lib/ui/viewer/location/pick_center_point_widget.dart +++ b/lib/ui/viewer/location/pick_center_point_widget.dart @@ -7,10 +7,8 @@ import "package:photos/core/event_bus.dart"; import "package:photos/db/files_db.dart"; import "package:photos/events/local_photos_updated_event.dart"; import "package:photos/generated/l10n.dart"; -import 'package:photos/models/file/file.dart'; import "package:photos/models/file_load_result.dart"; -import "package:photos/models/local_entity_data.dart"; -import "package:photos/models/location_tag/location_tag.dart"; +import "package:photos/models/location/location.dart"; import "package:photos/models/selected_files.dart"; import "package:photos/services/collections_service.dart"; import "package:photos/services/filter/db_filters.dart"; @@ -19,17 +17,18 @@ import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/bottom_of_title_bar_widget.dart"; import "package:photos/ui/components/buttons/button_widget.dart"; import "package:photos/ui/components/models/button_type.dart"; +import "package:photos/ui/components/notification_widget.dart"; import "package:photos/ui/components/title_bar_title_widget.dart"; import "package:photos/ui/viewer/gallery/gallery.dart"; -Future showPickCenterPointSheet( - BuildContext context, - LocalEntity locationTagEntity, -) async { +Future showPickCenterPointSheet( + BuildContext context, { + String? locationTagName, +}) async { return await showBarModalBottomSheet( context: context, builder: (context) { - return PickCenterPointWidget(locationTagEntity); + return PickCenterPointWidget(locationTagName); }, shape: const RoundedRectangleBorder( side: BorderSide(width: 0), @@ -45,10 +44,10 @@ Future showPickCenterPointSheet( } class PickCenterPointWidget extends StatelessWidget { - final LocalEntity locationTagEntity; + final String? locationTagName; const PickCenterPointWidget( - this.locationTagEntity, { + this.locationTagName, { super.key, }); @@ -81,7 +80,7 @@ class PickCenterPointWidget extends StatelessWidget { title: TitleBarTitleWidget( title: S.of(context).pickCenterPoint, ), - caption: locationTagEntity.item.name, + caption: locationTagName ?? "New location", ), Expanded( child: Gallery( @@ -114,6 +113,12 @@ class PickCenterPointWidget extends StatelessWidget { selectedFiles: selectedFiles, limitSelectionToOne: true, showSelectAllByDefault: false, + header: const Padding( + padding: EdgeInsets.all(10), + child: NotificationTipWidget( + "You can also add a location centered on a photo from the photo's info screen", + ), + ), ), ), ], @@ -145,9 +150,9 @@ class PickCenterPointWidget extends StatelessWidget { buttonType: ButtonType.neutral, labelText: S.of(context).useSelectedPhoto, onTap: () async { - final selectedFile = - selectedFiles.files.first; - Navigator.pop(context, selectedFile); + final selectedLocation = + selectedFiles.files.first.location; + Navigator.pop(context, selectedLocation); }, ), ); diff --git a/lib/ui/viewer/search/result/go_to_map_widget.dart b/lib/ui/viewer/search/result/go_to_map_widget.dart new file mode 100644 index 000000000..a3b76e7ea --- /dev/null +++ b/lib/ui/viewer/search/result/go_to_map_widget.dart @@ -0,0 +1,68 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/services/search_service.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/map/enable_map.dart"; +import "package:photos/ui/map/map_screen.dart"; + +class GoToMapWidget extends StatelessWidget { + const GoToMapWidget({super.key}); + + @override + Widget build(BuildContext context) { + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + late final double width; + if (textScaleFactor <= 1.0) { + width = 85.0; + } else { + width = 85.0 + ((textScaleFactor - 1.0) * 64); + } + + final colorScheme = getEnteColorScheme(context); + return GestureDetector( + onTap: () async { + final bool result = await requestForMapEnable(context); + if (result) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MapScreen( + filesFutureFn: SearchService.instance.getAllFiles, + ), + ), + ); + } + }, + child: SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 64, + height: 64, + child: Icon( + CupertinoIcons.map_fill, + color: colorScheme.strokeFaint, + size: 48, + ), + ), + const SizedBox( + height: 10, + ), + Text( + S.of(context).yourMap, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: getEnteTextTheme(context).mini, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/viewer/search/result/no_result_widget.dart b/lib/ui/viewer/search/result/no_result_widget.dart index c4d027eac..10c7b9dce 100644 --- a/lib/ui/viewer/search/result/no_result_widget.dart +++ b/lib/ui/viewer/search/result/no_result_widget.dart @@ -1,74 +1,118 @@ import 'package:flutter/material.dart'; -import 'package:photos/ente_theme_data.dart'; import "package:photos/generated/l10n.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/states/all_sections_examples_state.dart"; +import "package:photos/theme/ente_theme.dart"; -class NoResultWidget extends StatelessWidget { +class NoResultWidget extends StatefulWidget { const NoResultWidget({Key? key}) : super(key: key); + @override + State createState() => _NoResultWidgetState(); +} + +class _NoResultWidgetState extends State { + late final List searchTypes; + final searchTypeToQuerySuggestion = >{}; + @override + void initState() { + super.initState(); + searchTypes = SectionType.values.toList(growable: true); + // remove face and content sectionType + searchTypes.remove(SectionType.face); + searchTypes.remove(SectionType.content); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + InheritedAllSectionsExamples.of(context) + .allSectionsExamplesFuture + .then((value) { + for (int i = 0; i < searchTypes.length; i++) { + final querySuggestions = []; + for (int j = 0; j < 2 && j < value[i].length; j++) { + querySuggestions.add(value[i][j].name()); + } + if (querySuggestions.isNotEmpty) { + searchTypeToQuerySuggestion.addAll({ + searchTypes[i].sectionTitle(context): querySuggestions, + }); + } + } + setState(() {}); + }); + } + @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(top: 6), - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.searchResultsColor, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - spreadRadius: -3, - blurRadius: 6, - offset: const Offset(0, 8), + final textTheme = getEnteTextTheme(context); + final searchTypeAndSuggestion = []; + searchTypeToQuerySuggestion.forEach( + (key, value) { + searchTypeAndSuggestion.add( + Row( + children: [ + Text( + key, + style: textTheme.bodyMuted, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + formatList(value), + style: textTheme.miniMuted, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context).noResultsFound, + style: textTheme.largeBold, + ), + const SizedBox(height: 6), + searchTypeToQuerySuggestion.isNotEmpty + ? Text( + S.of(context).modifyYourQueryOrTrySearchingFor, + style: textTheme.smallMuted, + ) + : const SizedBox.shrink(), + ], + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ListView.separated( + itemBuilder: (context, index) { + return searchTypeAndSuggestion[index]; + }, + separatorBuilder: (context, index) { + return const SizedBox(height: 12); + }, + itemCount: searchTypeToQuerySuggestion.length, + shrinkWrap: true, + ), ), ], ), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: const EdgeInsets.only(top: 8), - child: Text( - S.of(context).noResultsFound, - textAlign: TextAlign.left, - style: const TextStyle( - fontSize: 16, - ), - ), - ), - Container( - margin: const EdgeInsets.only(top: 16), - child: Text( - S.of(context).youCanTrySearchingForADifferentQuery, - style: TextStyle( - fontSize: 14, - color: Theme.of(context) - .colorScheme - .defaultTextColor - .withOpacity(0.5), - height: 1.5, - ), - ), - ), - Container( - margin: const EdgeInsets.only(bottom: 20, top: 12), - child: Text( - S.of(context).searchByExamples, - style: TextStyle( - fontSize: 14, - color: Theme.of(context) - .colorScheme - .defaultTextColor - .withOpacity(0.5), - height: 1.5, - ), - ), - ), - ], - ), - ), ); } + + /// Join the strings with ', ' and wrap each element with double quotes + String formatList(List strings) { + return strings.map((str) => '"$str"').join(', '); + } } diff --git a/lib/ui/viewer/search/result/search_result_page.dart b/lib/ui/viewer/search/result/search_result_page.dart index 6474ca65b..f043574b9 100644 --- a/lib/ui/viewer/search/result/search_result_page.dart +++ b/lib/ui/viewer/search/result/search_result_page.dart @@ -13,6 +13,7 @@ import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart'; class SearchResultPage extends StatelessWidget { final SearchResult searchResult; + final String tagPrefix; final _selectedFiles = SelectedFiles(); static const GalleryType appBarType = GalleryType.searchResults; @@ -20,6 +21,7 @@ class SearchResultPage extends StatelessWidget { SearchResultPage( this.searchResult, { + this.tagPrefix = "", Key? key, }) : super(key: key); @@ -47,9 +49,9 @@ class SearchResultPage extends StatelessWidget { EventType.deletedFromRemote, EventType.deletedFromEverywhere, }, - tagPrefix: searchResult.heroTag(), + tagPrefix: tagPrefix + searchResult.heroTag(), selectedFiles: _selectedFiles, - initialFiles: const [], + initialFiles: [searchResult.resultFiles().first], ); return Scaffold( appBar: PreferredSize( diff --git a/lib/ui/viewer/search/result/search_result_widget.dart b/lib/ui/viewer/search/result/search_result_widget.dart index b76e5c9f8..f160ac7ad 100644 --- a/lib/ui/viewer/search/result/search_result_widget.dart +++ b/lib/ui/viewer/search/result/search_result_widget.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; +import "package:photos/models/search/recent_searches.dart"; import 'package:photos/models/search/search_result.dart'; +import "package:photos/models/search/search_types.dart"; +import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/viewer/search/result/search_result_page.dart'; import 'package:photos/ui/viewer/search/result/search_thumbnail_widget.dart'; import 'package:photos/utils/navigation_util.dart'; @@ -20,81 +23,81 @@ class SearchResultWidget extends StatelessWidget { @override Widget build(BuildContext context) { final heroTagPrefix = searchResult.heroTag(); + final textTheme = getEnteTextTheme(context); return GestureDetector( behavior: HitTestBehavior.opaque, child: Container( - color: Theme.of(context).colorScheme.searchResultsColor, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SearchThumbnailWidget( - searchResult.previewThumbnail(), - heroTagPrefix, - ), - const SizedBox(width: 16), - Column( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + border: Border.all( + color: getEnteColorScheme(context).strokeFainter, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SearchThumbnailWidget( + searchResult.previewThumbnail(), + heroTagPrefix, + ), + const SizedBox(width: 12), + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _resultTypeName(searchResult.type()), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.subTextColor, - ), - ), - const SizedBox(height: 6), SizedBox( width: 220, child: Text( searchResult.name(), - style: const TextStyle(fontSize: 18), + style: textTheme.body, overflow: TextOverflow.ellipsis, ), ), - const SizedBox(height: 2), - FutureBuilder( - future: resultCount ?? - Future.value(searchResult.resultFiles().length), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data! > 0) { - final noOfMemories = snapshot.data; - return RichText( - text: TextSpan( - style: TextStyle( - color: Theme.of(context) - .colorScheme - .searchResultsCountTextColor, - ), - children: [ - TextSpan(text: noOfMemories.toString()), - TextSpan( - text: - noOfMemories != 1 ? ' memories' : ' memory', - ), - ], - ), - ); - } else { - return const SizedBox.shrink(); - } - }, + const SizedBox(height: 4), + Row( + children: [ + Text( + _resultTypeName(searchResult.type()), + style: textTheme.smallMuted, + ), + FutureBuilder( + future: resultCount ?? + Future.value(searchResult.resultFiles().length), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! > 0) { + final noOfMemories = snapshot.data; + + return Text( + " \u2022 " + noOfMemories.toString(), + style: textTheme.smallMuted, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ], ), ], ), - const Spacer(), - Icon( + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(16.0), + child: Icon( Icons.chevron_right, color: Theme.of(context).colorScheme.subTextColor, ), - ], - ), + ), + ], ), ), onTap: () { + RecentSearches().add(searchResult.name()); + if (onResultTap != null) { onResultTap!(); } else { @@ -127,6 +130,8 @@ class SearchResultWidget extends StatelessWidget { return "File extension"; case ResultType.fileCaption: return "Description"; + case ResultType.shared: + return "Shared"; default: return type.name.toUpperCase(); } diff --git a/lib/ui/viewer/search/result/search_section_all_page.dart b/lib/ui/viewer/search/result/search_section_all_page.dart new file mode 100644 index 000000000..89c704626 --- /dev/null +++ b/lib/ui/viewer/search/result/search_section_all_page.dart @@ -0,0 +1,197 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_animate/flutter_animate.dart"; +import "package:photos/events/event.dart"; +import "package:photos/models/search/album_search_result.dart"; +import "package:photos/models/search/generic_search_result.dart"; +import "package:photos/models/search/recent_searches.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/services/collections_service.dart"; +import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; +import "package:photos/ui/viewer/search/result/searchable_item.dart"; +import "package:photos/utils/navigation_util.dart"; + +class SearchSectionAllPage extends StatefulWidget { + final SectionType sectionType; + const SearchSectionAllPage({required this.sectionType, super.key}); + + @override + State createState() => _SearchSectionAllPageState(); +} + +class _SearchSectionAllPageState extends State { + late Future> sectionData; + final streamSubscriptions = []; + + @override + void initState() { + super.initState(); + final streamsToListenTo = widget.sectionType.viewAllUpdateEvents(); + for (Stream stream in streamsToListenTo) { + streamSubscriptions.add( + stream.listen((event) async { + setState(() { + sectionData = widget.sectionType.getData(); + }); + }), + ); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + sectionData = widget.sectionType.getData(limit: null, context: context); + } + + @override + void dispose() { + for (var subscriptions in streamSubscriptions) { + subscriptions.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + toolbarHeight: 48, + leadingWidth: 48, + leading: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Icon( + Icons.arrow_back_outlined, + ), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TitleBarTitleWidget( + title: widget.sectionType.sectionTitle(context), + ), + FutureBuilder( + future: sectionData, + builder: (context, snapshot) { + if (snapshot.hasData) { + final sectionResults = snapshot.data!; + return Text(sectionResults.length.toString()) + .animate() + .fadeIn( + duration: const Duration(milliseconds: 150), + curve: Curves.easeIn, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), + child: FutureBuilder( + future: sectionData, + builder: (context, snapshot) { + if (snapshot.hasData) { + final sectionResults = snapshot.data!; + return ListView.separated( + itemBuilder: (context, index) { + if (sectionResults.length == index) { + return SearchableItemPlaceholder( + widget.sectionType, + ); + } + if (sectionResults[index] is AlbumSearchResult) { + final albumSectionResult = + sectionResults[index] as AlbumSearchResult; + return SearchableItemWidget( + albumSectionResult, + resultCount: + CollectionsService.instance.getFileCount( + albumSectionResult + .collectionWithThumbnail.collection, + ), + onResultTap: () { + RecentSearches() + .add(sectionResults[index].name()); + + routeToPage( + context, + CollectionPage( + albumSectionResult.collectionWithThumbnail, + tagPrefix: "searchable_item" + + albumSectionResult.heroTag(), + ), + ); + }, + ); + } else if (sectionResults[index] + is GenericSearchResult) { + final result = + sectionResults[index] as GenericSearchResult; + return SearchableItemWidget( + sectionResults[index], + onResultTap: result.onResultTap != null + ? () => result.onResultTap!(context) + : null, + ); + } + return SearchableItemWidget( + sectionResults[index], + ); + }, + separatorBuilder: (context, index) { + return const SizedBox(height: 10); + }, + itemCount: sectionResults.length + + (widget.sectionType.isCTAVisible ? 1 : 0), + physics: const BouncingScrollPhysics(), + //This cache extend is needed for creating a new album + //using SearchSectionCTATile to work. This is so that + //SearchSectionCTATile doesn't get disposed when keyboard + //is open and the widget is out of view. + cacheExtent: + widget.sectionType == SectionType.album ? 400 : null, + ) + .animate() + .fadeIn( + duration: const Duration(milliseconds: 225), + curve: Curves.easeIn, + ) + .slide( + begin: const Offset(0, -0.01), + curve: Curves.easeIn, + duration: const Duration( + milliseconds: 225, + ), + ); + } else { + return const EnteLoadingWidget(); + } + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/viewer/search/result/search_thumbnail_widget.dart b/lib/ui/viewer/search/result/search_thumbnail_widget.dart index a479c8ece..13b303fec 100644 --- a/lib/ui/viewer/search/result/search_thumbnail_widget.dart +++ b/lib/ui/viewer/search/result/search_thumbnail_widget.dart @@ -18,15 +18,17 @@ class SearchThumbnailWidget extends StatelessWidget { return Hero( tag: tagPrefix + (file?.tag ?? ""), child: SizedBox( - height: 58, - width: 58, + height: 60, + width: 60, child: ClipRRect( - borderRadius: BorderRadius.circular(3), + borderRadius: const BorderRadius.horizontal(left: Radius.circular(4)), child: file != null ? ThumbnailWidget( file!, ) - : const NoThumbnailWidget(), + : const NoThumbnailWidget( + addBorder: false, + ), ), ), ); diff --git a/lib/ui/viewer/search/result/searchable_item.dart b/lib/ui/viewer/search/result/searchable_item.dart new file mode 100644 index 000000000..a8a280296 --- /dev/null +++ b/lib/ui/viewer/search/result/searchable_item.dart @@ -0,0 +1,174 @@ +import "package:dotted_border/dotted_border.dart"; +import "package:flutter/material.dart"; +import "package:photos/models/search/recent_searches.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/components/buttons/icon_button_widget.dart"; +import "package:photos/ui/viewer/search/result/search_result_page.dart"; +import "package:photos/ui/viewer/search/result/search_thumbnail_widget.dart"; +import "package:photos/utils/navigation_util.dart"; + +class SearchableItemWidget extends StatelessWidget { + final SearchResult searchResult; + final Future? resultCount; + final Function? onResultTap; + const SearchableItemWidget( + this.searchResult, { + Key? key, + this.resultCount, + this.onResultTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + //The "searchable_item" tag is to remove hero animation between section + //examples and searchableItems in 'view all'. Animation should exist between + //searchableItems and SearchResultPages, so passing the extra prefix to + //SearchResultPage + const additionalPrefix = "searchable_item"; + final heroTagPrefix = additionalPrefix + searchResult.heroTag(); + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); + + return GestureDetector( + onTap: () { + RecentSearches().add(searchResult.name()); + if (onResultTap != null) { + onResultTap!(); + } else { + routeToPage( + context, + SearchResultPage( + searchResult, + tagPrefix: additionalPrefix, + ), + ); + } + }, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.strokeFainter), + borderRadius: const BorderRadius.all( + Radius.circular(4), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 6, + child: Row( + children: [ + SizedBox( + width: 60, + height: 60, + child: SearchThumbnailWidget( + searchResult.previewThumbnail(), + heroTagPrefix, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + searchResult.name(), + style: textTheme.body, + overflow: TextOverflow.ellipsis, + ), + const SizedBox( + height: 2, + ), + FutureBuilder( + future: resultCount ?? + Future.value(searchResult.resultFiles().length), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! > 0) { + final noOfMemories = snapshot.data; + final String suffix = + noOfMemories! > 1 ? " memories" : " memory"; + + return Text( + noOfMemories.toString() + suffix, + style: textTheme.smallMuted, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ], + ), + ), + ), + ], + ), + ), + const Flexible( + flex: 1, + child: IconButtonWidget( + icon: Icons.chevron_right_outlined, + iconButtonType: IconButtonType.secondary, + ), + ), + ], + ), + ), + ); + } +} + +class SearchableItemPlaceholder extends StatelessWidget { + final SectionType sectionType; + const SearchableItemPlaceholder(this.sectionType, {super.key}); + + @override + Widget build(BuildContext context) { + if (sectionType.isCTAVisible == false) { + return const SizedBox.shrink(); + } + + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + return Padding( + padding: const EdgeInsets.only(right: 1), + child: GestureDetector( + onTap: sectionType.ctaOnTap(context), + child: DottedBorder( + strokeWidth: 2, + borderType: BorderType.RRect, + radius: const Radius.circular(4), + padding: EdgeInsets.zero, + dashPattern: const [4, 4], + color: colorScheme.strokeFainter, + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: + const BorderRadius.horizontal(left: Radius.circular(4)), + color: colorScheme.fillFaint, + ), + child: Icon( + sectionType.getCTAIcon(), + color: colorScheme.strokeMuted, + ), + ), + const SizedBox(width: 12), + Text( + sectionType.getCTAText(context), + style: textTheme.body, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/viewer/search/search_section.dart b/lib/ui/viewer/search/search_section.dart new file mode 100644 index 000000000..a34ab0ffd --- /dev/null +++ b/lib/ui/viewer/search/search_section.dart @@ -0,0 +1,266 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/events/event.dart"; +import "package:photos/models/search/album_search_result.dart"; +import "package:photos/models/search/generic_search_result.dart"; +import "package:photos/models/search/recent_searches.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/viewer/file/no_thumbnail_widget.dart"; +import "package:photos/ui/viewer/file/thumbnail_widget.dart"; +import "package:photos/ui/viewer/gallery/collection_page.dart"; +import "package:photos/ui/viewer/search/result/go_to_map_widget.dart"; +import "package:photos/ui/viewer/search/result/search_result_page.dart"; +import 'package:photos/ui/viewer/search/result/search_section_all_page.dart'; +import "package:photos/ui/viewer/search/search_section_cta.dart"; +import "package:photos/utils/navigation_util.dart"; + +class SearchSection extends StatefulWidget { + final SectionType sectionType; + final List examples; + final int limit; + + const SearchSection({ + Key? key, + required this.sectionType, + required this.examples, + required this.limit, + }) : super(key: key); + + @override + State createState() => _SearchSectionState(); +} + +class _SearchSectionState extends State { + late List _examples; + final streamSubscriptions = []; + + @override + void initState() { + super.initState(); + _examples = widget.examples; + + final streamsToListenTo = widget.sectionType.sectionUpdateEvents(); + for (Stream stream in streamsToListenTo) { + streamSubscriptions.add( + stream.listen((event) async { + _examples = + await widget.sectionType.getData(limit: searchSectionLimit); + setState(() {}); + }), + ); + } + } + + @override + void dispose() { + for (var subscriptions in streamSubscriptions) { + subscriptions.cancel(); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant SearchSection oldWidget) { + super.didUpdateWidget(oldWidget); + _examples = widget.examples; + } + + @override + Widget build(BuildContext context) { + debugPrint("Building section for ${widget.sectionType.name}"); + final textTheme = getEnteTextTheme(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: widget.examples.isNotEmpty + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Text( + widget.sectionType.sectionTitle(context), + style: textTheme.largeBold, + ), + ), + _examples.length < (widget.limit - 1) + ? const SizedBox.shrink() + : GestureDetector( + onTap: () { + routeToPage( + context, + SearchSectionAllPage( + sectionType: widget.sectionType, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.chevron_right_outlined, + color: getEnteColorScheme(context).strokeMuted, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + SearchExampleRow(_examples, widget.sectionType), + ], + ) + : Padding( + padding: const EdgeInsets.only(left: 16, right: 8), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.sectionType.sectionTitle(context), + style: textTheme.largeBold, + ), + const SizedBox(height: 24), + Text( + widget.sectionType.getEmptyStateText(context), + style: textTheme.smallMuted, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + SearchSectionEmptyCTAIcon(widget.sectionType), + ], + ), + ), + ); + } +} + +class SearchExampleRow extends StatelessWidget { + final SectionType sectionType; + final List examples; + + const SearchExampleRow(this.examples, this.sectionType, {super.key}); + + @override + Widget build(BuildContext context) { + //Cannot use listView.builder here + final scrollableExamples = []; + if (sectionType == SectionType.location) { + scrollableExamples.add(const GoToMapWidget()); + } + examples.forEachIndexed((index, element) { + scrollableExamples.add( + SearchExample( + searchResult: examples.elementAt(index), + ), + ); + }); + scrollableExamples.add(SearchSectionCTAIcon(sectionType)); + return SizedBox( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: scrollableExamples, + ), + ), + ); + } +} + +class SearchExample extends StatelessWidget { + final SearchResult searchResult; + const SearchExample({required this.searchResult, super.key}); + + @override + Widget build(BuildContext context) { + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + late final double width; + if (textScaleFactor <= 1.0) { + width = 85.0; + } else { + width = 85.0 + ((textScaleFactor - 1.0) * 64); + } + final heroTag = + searchResult.heroTag() + (searchResult.previewThumbnail()?.tag ?? ""); + return GestureDetector( + onTap: () { + RecentSearches().add(searchResult.name()); + + if (searchResult is GenericSearchResult) { + final genericSearchResult = searchResult as GenericSearchResult; + if (genericSearchResult.onResultTap != null) { + genericSearchResult.onResultTap!(context); + } else { + routeToPage( + context, + SearchResultPage(searchResult), + ); + } + } else if (searchResult is AlbumSearchResult) { + final albumSearchResult = searchResult as AlbumSearchResult; + routeToPage( + context, + CollectionPage( + albumSearchResult.collectionWithThumbnail, + tagPrefix: albumSearchResult.heroTag(), + ), + ); + } + }, + child: SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 64, + height: 64, + child: searchResult.previewThumbnail() != null + ? Hero( + tag: heroTag, + child: ClipOval( + child: ThumbnailWidget( + searchResult.previewThumbnail()!, + shouldShowSyncStatus: false, + ), + ), + ) + : const ClipOval( + child: NoThumbnailWidget( + addBorder: false, + ), + ), + ), + const SizedBox( + height: 10, + ), + Text( + searchResult.name(), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: getEnteTextTheme(context).mini, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/viewer/search/search_section_cta.dart b/lib/ui/viewer/search/search_section_cta.dart new file mode 100644 index 000000000..4a4e43f9d --- /dev/null +++ b/lib/ui/viewer/search/search_section_cta.dart @@ -0,0 +1,109 @@ +import "package:dotted_border/dotted_border.dart"; +import "package:flutter/material.dart"; +import "package:photos/models/search/search_types.dart"; +import "package:photos/theme/ente_theme.dart"; + +class SearchSectionCTAIcon extends StatelessWidget { + final SectionType sectionType; + + const SearchSectionCTAIcon(this.sectionType, {super.key}); + + @override + Widget build(BuildContext context) { + if (sectionType.isCTAVisible == false) { + return const SizedBox.shrink(); + } + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); + return GestureDetector( + onTap: sectionType.ctaOnTap(context), + child: SizedBox( + width: 84, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DottedBorder( + color: colorScheme.strokeFaint, + dashPattern: const [3.875, 3.875], + borderType: BorderType.Circle, + strokeWidth: 1.5, + radius: const Radius.circular(33.25), + child: SizedBox( + width: 62.5, + height: 62.5, + child: Icon( + sectionType.getCTAIcon() ?? Icons.add, + color: colorScheme.strokeFaint, + size: 20, + ), + ), + ), + const SizedBox( + height: 8.5, + ), + Text( + sectionType.getCTAText(context), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: textTheme.miniFaint, + ), + ], + ), + ), + ), + ); + } +} + +class SearchSectionEmptyCTAIcon extends StatelessWidget { + final SectionType sectionType; + const SearchSectionEmptyCTAIcon(this.sectionType, {super.key}); + + @override + Widget build(BuildContext context) { + if (sectionType.isCTAVisible == false) { + return const SizedBox(height: 115); + } + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); + return GestureDetector( + onTap: sectionType.ctaOnTap(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 24, 8, 0), + child: Column( + children: [ + DottedBorder( + color: colorScheme.strokeFaint, + dashPattern: const [3.875, 3.875], + borderType: BorderType.Circle, + strokeWidth: 1.5, + radius: const Radius.circular(33.25), + child: SizedBox( + width: 62.5, + height: 62.5, + child: Icon( + sectionType.getCTAIcon() ?? Icons.add, + color: colorScheme.strokeFaint, + size: 20, + ), + ), + ), + const SizedBox( + height: 10, + ), + Text( + sectionType.getCTAText(context), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: textTheme.miniFaint, + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/viewer/search/search_suffix_icon_widget.dart b/lib/ui/viewer/search/search_suffix_icon_widget.dart index 43ffd0391..c8f034fb1 100644 --- a/lib/ui/viewer/search/search_suffix_icon_widget.dart +++ b/lib/ui/viewer/search/search_suffix_icon_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:photos/ente_theme_data.dart'; +import "package:photos/theme/ente_theme.dart"; +import 'package:photos/ui/viewer/search/search_widget.dart'; class SearchSuffixIcon extends StatefulWidget { final bool shouldShowSpinner; @@ -13,6 +14,7 @@ class _SearchSuffixIconState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); return AnimatedSwitcher( duration: const Duration(milliseconds: 175), child: widget.shouldShowSpinner @@ -24,22 +26,23 @@ class _SearchSuffixIconState extends State child: Center( child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context) - .colorScheme - .iconColor - .withOpacity(0.5), + color: colorScheme.strokeMuted, ), ), ), ) : IconButton( + splashRadius: 1, visualDensity: const VisualDensity(horizontal: -1, vertical: -1), onPressed: () { - Navigator.pop(context); + final searchWidgetState = + context.findAncestorStateOfType()!; + searchWidgetState.textController.clear(); + searchWidgetState.focusNode.unfocus(); }, icon: Icon( Icons.close, - color: Theme.of(context).colorScheme.iconColor.withOpacity(0.5), + color: colorScheme.strokeMuted, ), ), ); diff --git a/lib/ui/viewer/search/search_suggestions.dart b/lib/ui/viewer/search/search_suggestions.dart index 08094ba11..eac86ce17 100644 --- a/lib/ui/viewer/search/search_suggestions.dart +++ b/lib/ui/viewer/search/search_suggestions.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; -import 'package:photos/ente_theme_data.dart'; import 'package:photos/models/search/album_search_result.dart'; -import 'package:photos/models/search/file_search_result.dart'; import 'package:photos/models/search/generic_search_result.dart'; import 'package:photos/models/search/search_result.dart'; import "package:photos/services/collections_service.dart"; +import "package:photos/theme/ente_theme.dart"; import 'package:photos/ui/viewer/gallery/collection_page.dart'; -import 'package:photos/ui/viewer/search/result/file_result_widget.dart'; import 'package:photos/ui/viewer/search/result/search_result_widget.dart'; import 'package:photos/utils/navigation_util.dart'; @@ -21,40 +19,29 @@ class SearchSuggestionsWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SingleChildScrollView( - child: Container( - margin: const EdgeInsets.only(top: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.searchResultsColor, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - spreadRadius: -3, - blurRadius: 6, - offset: const Offset(0, 8), - ), - ], - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: Container( - margin: const EdgeInsets.only(top: 6), - constraints: const BoxConstraints( - maxHeight: 324, - ), - child: Scrollbar( - child: ListView.builder( - physics: const ClampingScrollPhysics(), - shrinkWrap: true, - itemCount: results.length + 1, + late final String title; + final resultsCount = results.length; + //todo: extract string + if (resultsCount == 1) { + title = "$resultsCount result found"; + } else { + title = "$resultsCount results found"; + } + return Padding( + padding: const EdgeInsets.fromLTRB(12, 32, 12, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: getEnteTextTheme(context).largeBold, + ), + const SizedBox(height: 20), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ListView.separated( itemBuilder: (context, index) { - if (results.length == index) { - return Container( - height: 6, - color: Theme.of(context).colorScheme.searchResultsColor, - ); - } final result = results[index]; if (result is AlbumSearchResult) { final AlbumSearchResult albumSearchResult = result; @@ -71,8 +58,6 @@ class SearchSuggestionsWidget extends StatelessWidget { ), ), ); - } else if (result is FileSearchResult) { - return FileSearchResultWidget(result); } else if (result is GenericSearchResult) { return SearchResultWidget( result, @@ -86,10 +71,18 @@ class SearchSuggestionsWidget extends StatelessWidget { return const SizedBox.shrink(); } }, + padding: EdgeInsets.only( + bottom: (MediaQuery.sizeOf(context).height / 2) + 50, + ), + separatorBuilder: (context, index) { + return const SizedBox(height: 12); + }, + itemCount: results.length, + physics: const BouncingScrollPhysics(), ), ), ), - ), + ], ), ); } diff --git a/lib/ui/viewer/search/search_widget.dart b/lib/ui/viewer/search/search_widget.dart index c37e453d2..efe4c828f 100644 --- a/lib/ui/viewer/search/search_widget.dart +++ b/lib/ui/viewer/search/search_widget.dart @@ -1,179 +1,194 @@ -import 'dart:async'; +import "dart:async"; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:photos/ente_theme_data.dart'; -import "package:photos/generated/l10n.dart"; -import 'package:photos/models/search/search_result.dart'; -import 'package:photos/services/search_service.dart'; +import "package:flutter/material.dart"; +import "package:flutter/scheduler.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/tab_changed_event.dart"; +import "package:photos/models/search/search_result.dart"; +import "package:photos/services/search_service.dart"; +import "package:photos/states/search_results_state.dart"; import "package:photos/theme/ente_theme.dart"; -import 'package:photos/ui/components/buttons/icon_button_widget.dart'; -import "package:photos/ui/map/enable_map.dart"; -import "package:photos/ui/map/map_screen.dart"; -import 'package:photos/ui/viewer/search/result/no_result_widget.dart'; -import 'package:photos/ui/viewer/search/search_suffix_icon_widget.dart'; -import 'package:photos/ui/viewer/search/search_suggestions.dart'; -import 'package:photos/utils/date_time_util.dart'; -import 'package:photos/utils/debouncer.dart'; -import 'package:photos/utils/navigation_util.dart'; +import "package:photos/ui/viewer/search/search_suffix_icon_widget.dart"; +import "package:photos/utils/date_time_util.dart"; +import "package:photos/utils/debouncer.dart"; -class SearchIconWidget extends StatefulWidget { - const SearchIconWidget({Key? key}) : super(key: key); - - @override - State createState() => _SearchIconWidgetState(); -} - -class _SearchIconWidgetState extends State { - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Hero( - tag: "search_icon", - child: IconButtonWidget( - iconButtonType: IconButtonType.primary, - icon: Icons.search, - onTap: () { - Navigator.push( - context, - TransparentRoute( - builder: (BuildContext context) => const SearchWidget(), - ), - ); - }, - ), - ); - } -} +bool isSearchQueryEmpty = true; class SearchWidget extends StatefulWidget { const SearchWidget({Key? key}) : super(key: key); @override - State createState() => _SearchWidgetState(); + State createState() => SearchWidgetState(); } -class _SearchWidgetState extends State { - String _query = ""; - final List _results = []; +class SearchWidgetState extends State { + static String query = ""; final _searchService = SearchService.instance; final _debouncer = Debouncer(const Duration(milliseconds: 100)); - final Logger _logger = Logger((_SearchWidgetState).toString()); + final Logger _logger = Logger((SearchWidgetState).toString()); + late FocusNode focusNode; + StreamSubscription? _tabDoubleTapEvent; + double _bottomPadding = 0.0; + double _distanceOfWidgetFromBottom = 0; + GlobalKey widgetKey = GlobalKey(); + TextEditingController textController = TextEditingController(); @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - Navigator.pop(context); - }, - child: Container( - color: Theme.of(context).colorScheme.searchResultsBackgroundColor, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - height: 44, - color: Theme.of(context).colorScheme.defaultBackgroundColor, - child: TextFormField( - style: Theme.of(context).textTheme.titleMedium, - // Below parameters are to disable auto-suggestion - enableSuggestions: false, - autocorrect: false, - // Above parameters are to disable auto-suggestion - decoration: InputDecoration( - hintText: S.of(context).searchHintText, - filled: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - ), - border: const UnderlineInputBorder( - borderSide: BorderSide.none, - ), - focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide.none, - ), - prefixIconConstraints: const BoxConstraints( - maxHeight: 44, - maxWidth: 44, - minHeight: 44, - minWidth: 44, - ), - suffixIconConstraints: const BoxConstraints( - maxHeight: 44, - maxWidth: 44, - minHeight: 44, - minWidth: 44, - ), - prefixIcon: Hero( - tag: "search_icon", - child: Icon( - Icons.search, - color: Theme.of(context) - .colorScheme - .iconColor - .withOpacity(0.5), - ), - ), - /*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when - setState is called when deboucncing is over and the spinner needs to be shown while debouncing */ - suffixIcon: ValueListenableBuilder( - valueListenable: _debouncer.debounceActiveNotifier, - builder: ( - BuildContext context, - bool isDebouncing, - Widget? child, - ) { - return SearchSuffixIcon( - isDebouncing, - ); - }, - ), - ), - onChanged: (value) async { - _query = value; - final List allResults = - await getSearchResultsForQuery(context, value); - /*checking if _query == value to make sure that the results are from the current query - and not from the previous query (race condition).*/ - if (mounted && _query == value) { - setState(() { - _results.clear(); - _results.addAll(allResults); - }); - } - }, - autofocus: true, - ), - ), - ), - _results.isNotEmpty - ? SearchSuggestionsWidget(_results) - : _query.isNotEmpty - ? const NoResultWidget() - : const NavigateToMap(), - ], - ), - ), - ), - ), - ); + void initState() { + super.initState(); + focusNode = FocusNode(); + _tabDoubleTapEvent = + Bus.instance.on().listen((event) async { + debugPrint("Firing now ${event.selectedIndex}"); + if (mounted && event.selectedIndex == 3) { + focusNode.requestFocus(); + } + }); + + SchedulerBinding.instance.addPostFrameCallback((_) { + //This buffer is for doing this operation only after SearchWidget's + //animation is complete. + Future.delayed(const Duration(milliseconds: 300), () { + final RenderBox box = + widgetKey.currentContext!.findRenderObject() as RenderBox; + final heightOfWidget = box.size.height; + final offsetPosition = box.localToGlobal(Offset.zero); + final y = offsetPosition.dy; + final heightOfScreen = MediaQuery.sizeOf(context).height; + _distanceOfWidgetFromBottom = heightOfScreen - (y + heightOfWidget); + }); + + textController.addListener(textControllerListener); + }); + textController.text = query; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _bottomPadding = + (MediaQuery.viewInsetsOf(context).bottom - _distanceOfWidgetFromBottom); + if (_bottomPadding < 0) { + _bottomPadding = 0; + } } @override void dispose() { _debouncer.cancelDebounce(); + focusNode.dispose(); + _tabDoubleTapEvent?.cancel(); + textController.removeListener(textControllerListener); + textController.dispose(); super.dispose(); } + Future textControllerListener() async { + //query in local varialbe + final value = textController.text; + isSearchQueryEmpty = value.isEmpty; + //latest query in global variable + query = textController.text; + + final List allResults = + await getSearchResultsForQuery(context, value); + /*checking if query == value to make sure that the results are from the current query + and not from the previous query (race condition).*/ + //checking if query == value to make sure that the latest query's result + //(allResults) is passed to updateResult. Due to race condition, the previous + //query's allResults could be passed to updateResult after the lastest query's + //allResults is passed. + + if (mounted && query == value) { + final inheritedSearchResults = InheritedSearchResults.of(context); + inheritedSearchResults.updateResults(allResults); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return RepaintBoundary( + key: widgetKey, + child: Padding( + padding: EdgeInsets.only(bottom: _bottomPadding), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: colorScheme.backgroundBase, + child: Container( + height: 44, + color: colorScheme.fillFaint, + child: TextFormField( + controller: textController, + focusNode: focusNode, + style: Theme.of(context).textTheme.titleMedium, + // Below parameters are to disable auto-suggestion + enableSuggestions: false, + autocorrect: false, + // Above parameters are to disable auto-suggestion + decoration: InputDecoration( + // hintText: S.of(context).searchHintText, + hintText: "Search", + filled: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + border: const UnderlineInputBorder( + borderSide: BorderSide.none, + ), + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide.none, + ), + prefixIconConstraints: const BoxConstraints( + maxHeight: 44, + maxWidth: 44, + minHeight: 44, + minWidth: 44, + ), + suffixIconConstraints: const BoxConstraints( + maxHeight: 44, + maxWidth: 44, + minHeight: 44, + minWidth: 44, + ), + prefixIcon: Hero( + tag: "search_icon", + child: Icon( + Icons.search, + color: colorScheme.strokeFaint, + ), + ), + /*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when + setState is called when deboucncing is over and the spinner needs to be shown while debouncing */ + suffixIcon: ValueListenableBuilder( + valueListenable: _debouncer.debounceActiveNotifier, + builder: ( + BuildContext context, + bool isDebouncing, + Widget? child, + ) { + return SearchSuffixIcon( + isDebouncing, + ); + }, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + Future> getSearchResultsForQuery( BuildContext context, String query, @@ -206,7 +221,7 @@ class _SearchWidgetState extends State { } final holidayResults = - await _searchService.getHolidaySearchResults(context, query); + await _searchService.getHolidaySearchResults(context, query); allResults.addAll(holidayResults); final fileTypeSearchResults = @@ -235,6 +250,9 @@ class _SearchWidgetState extends State { final possibleEvents = await _searchService.getDateResults(context, query); allResults.addAll(possibleEvents); + + final peopleResults = await _searchService.getPeopleSearchResults(query); + allResults.addAll(peopleResults); } catch (e, s) { _logger.severe("error during search", e, s); } @@ -246,34 +264,3 @@ class _SearchWidgetState extends State { return yearAsInt != null && yearAsInt <= currentYear; } } - -class NavigateToMap extends StatelessWidget { - const NavigateToMap({super.key}); - - @override - Widget build(BuildContext context) { - final colorScheme = getEnteColorScheme(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: IconButtonWidget( - icon: Icons.map_sharp, - iconButtonType: IconButtonType.primary, - defaultColor: colorScheme.backgroundElevated, - pressedColor: colorScheme.backgroundElevated2, - size: 28, - onTap: () async { - final bool result = await requestForMapEnable(context); - if (result) { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (context) => MapScreen( - filesFutureFn: SearchService.instance.getAllFiles, - ), - ), - ); - } - }, - ), - ); - } -} diff --git a/lib/ui/viewer/search/tab_empty_state.dart b/lib/ui/viewer/search/tab_empty_state.dart new file mode 100644 index 000000000..f514d3313 --- /dev/null +++ b/lib/ui/viewer/search/tab_empty_state.dart @@ -0,0 +1,50 @@ +import "package:flutter/material.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/components/buttons/button_widget.dart"; +import "package:photos/ui/components/empty_state_item_widget.dart"; +import "package:photos/ui/components/models/button_type.dart"; +import "package:photos/ui/settings/backup/backup_folder_selection_page.dart"; +import "package:photos/utils/navigation_util.dart"; + +class SearchTabEmptyState extends StatelessWidget { + const SearchTabEmptyState({super.key}); + + @override + Widget build(BuildContext context) { + final textStyle = getEnteTextTheme(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Fast, on-device search", style: textStyle.h3Bold), + const SizedBox(height: 24), + const EmptyStateItemWidget("Photo dates, descriptions"), + const SizedBox(height: 12), + const EmptyStateItemWidget("Albums, file names, and types"), + const SizedBox(height: 12), + const EmptyStateItemWidget("Location"), + const SizedBox(height: 12), + const EmptyStateItemWidget("Coming soon: Photo contents, faces"), + const SizedBox(height: 32), + ButtonWidget( + buttonType: ButtonType.trailingIconPrimary, + labelText: "Add your photos now", + icon: Icons.arrow_forward_outlined, + onTap: () async { + routeToPage( + context, + const BackupFolderSelectionPage( + buttonText: "Backup", + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart index 42c2f58d8..97332382d 100644 --- a/lib/utils/debouncer.dart +++ b/lib/utils/debouncer.dart @@ -5,6 +5,8 @@ import "package:photos/models/typedefs.dart"; class Debouncer { final Duration _duration; + + ///in milliseconds final ValueNotifier _debounceActiveNotifier = ValueNotifier(false); /// If executionInterval is not null, then the debouncer will execute the diff --git a/lib/utils/dialog_util.dart b/lib/utils/dialog_util.dart index 337038f0e..b6fd7e533 100644 --- a/lib/utils/dialog_util.dart +++ b/lib/utils/dialog_util.dart @@ -2,7 +2,7 @@ import "package:dio/dio.dart"; import 'package:flutter/material.dart'; import "package:flutter/services.dart"; import "package:photos/generated/l10n.dart"; -import "package:photos/models/search/button_result.dart"; +import 'package:photos/models/button_result.dart'; import 'package:photos/models/typedefs.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/ui/common/loading_widget.dart'; @@ -233,7 +233,7 @@ ProgressDialog createProgressDialog( return dialog; } -//Can return ButtonResult? from ButtonWidget or Exception? from TextInputDialog +///Can return ButtonResult? from ButtonWidget or Exception? from TextInputDialog Future showTextInputDialog( BuildContext context, { required String title, diff --git a/pubspec.lock b/pubspec.lock index a034f8c8c..49dcaabbe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -460,6 +460,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.0" + fade_indexed_stack: + dependency: "direct main" + description: + name: fade_indexed_stack + sha256: "0d625709d0bf6d0fa275cfa4eba84695fdea93d672c47413cdb49bcbe758a9f3" + url: "https://pub.dev" + source: hosted + version: "0.2.2" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 902beaa86..9d2349252 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: expandable: ^5.0.1 expansion_tile_card: ^3.0.0 extended_image: ^8.1.1 + fade_indexed_stack: ^0.2.2 fast_base58: ^0.2.1 # https://github.com/incrediblezayed/file_saver/issues/86 file_saver: 0.2.8