diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 7f6d59865a972726005344c8e1f57adea68eb410..e0fe083cde2188d1575e715864c7d16831a37139 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 09d61f59b729898fe87157fd968c9490c2acc175..109a5c6a777e19afb5e7fdc0fd2504370d32c242 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 a8418a01e11a6fa6ad9374b9a530d1cb788d0e8a..4ebc9f212ac7272c4ac2ab6315b7f29ed394bb93 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 a734a15891b8f94bb2a48751c07ee28f60e1225d..ef3b802a95eb7fd06725cacc83fe8a61f479e318 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 8ef5822153c62c54d24bf43ba79d66d55c2c5291..bae3b877e62efcdb3a3394d9dec83c809035391e 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 4e9d62c53f4fce6e027a17df0bb98f7fe8eee7b5..c9e09b7f2c6b44dc01f3ef11f82d1ab57e6269d2 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 e3760e04a64fbb497321c3e4a3a6a92159470d86..9c7384ff3defb813965474bc54f5a336dd4d5540 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 b623cef00869669c745ebdaa4fef00fbc126687b..cc538a1fa61610077801e0696f8a3f9d2339e243 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 0d1547a0f7f99899ddf9d24162f4964c90ec5e68..9b94e32df765b029c9df835f7b45107d6903de73 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 9a9c651a1c68909da125ec1f2c5293aa0964f6f7..c495dd4b4bdbc0cf13b5861f4fdfe251024dd6ef 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 2bbde65d1c768c69480eff6b493d277e6dccaea7..8a73df4ae41f3d1da6e4bae54999c43fea549647 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 4e9d62c53f4fce6e027a17df0bb98f7fe8eee7b5..c9e09b7f2c6b44dc01f3ef11f82d1ab57e6269d2 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 53e14d5281a29c53435cda377aaf221515af619f..1e69935394ea82f764bf865c57660e1720802780 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 16d00fc498bf1569c659a63119483be45ed17950..cab59487728a59ecff1ff621ed686a99e86bf92b 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 c76ccfa0de1ec3a5347401890f4ce58dfad507ad..94fa2ed9a924e6a76243bd850d876c69866aec68 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 ed78921b72c1be0105b17731d68a693a5d04c592..56a2c26c3c8db6329d25b8ef1a6245b82597b9a7 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 992d7fb0c87da19c7e8ca211dfed35067ab3e91e..f5300dbb4d98d459e547390b2361b64b25e9c51f 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 2d9dea7b4bc02bdcccb5f2b4bdcc0decaec167c4..543012b467600e485067002c7732df27e4a531f8 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 a36c0ec77c4b67eb71e5da1d3e7db1f91f6db8d4..8648236c894f740eff7bbdbd07f42f54cf1f2a8b 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 abec7db8a9c65abfed993f8fa5c96d5aa5733b2c..352886a509244371837affdf4b75641eeb64f4ff 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 e8be37497031d2830f34abbb0fdfb40fe23a7a8d..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..40deb68ec01696701b619be008e53dc18a8e72b6 --- /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 f04ce7ee044afe5fc781224ebfc51accab778193..02c922af0dfa46cd11c491777552e186c4739d34 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 0000000000000000000000000000000000000000..118c562157061931e24a57f6425da4d742d6d12a --- /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 ec753c53973b14776610d5ff1180ab3b7c7452cb..b5624c1bf8994fa1720a7226116ee25012fe5f19 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 30a71cbf995c052cba26e9872fa03d4e65351c3e..6fb98560291cc6c33af18c8588d3c35fe91070cf 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 0000000000000000000000000000000000000000..2a6dc8ca8c0ff0b3b6f889d587202476bfac26e1 --- /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 93131a7305debf87457ed9f959ecc560814a41f1..5711b2af8df615b2761f6d18ae7c4aaae2026ea5 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 0000000000000000000000000000000000000000..042f6f2a8fa20906452f9f7132f3a873c63a2207 --- /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 f33c81b21b58b34a961c4d6fbf0c3235667a4b27..589b6d77be50ee8927b4565667efd157a3a50f35 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 897d0703860db10b1da807b3bbc049fe92a80398..ad7706bd7df27b605984d50953b735317ad6de73 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 0ac98c37250f24c16a7905b224f383d59fe9a03a..dddf81cf5c3fdb492aa869132ff51dd74a198c48 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 62b3983aad6c19acfc7b5c73f7543e025980f456..aa4662792ba761ccc2d5b300f72dc614cf6111de 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 01c1b84efe1079857ba54234de914324fecd4444..63054bca3d986dbae775879d256cb3881ac81883 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 bd0c10f31b8c9703a1350f915eda7d63cfad4372..867fd59e5c2db207ee0090f39d000c504ec73f5c 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 89dfe425ccc4575d24492121fd95b5b7bc219fd4..aea0aca525727cf067dab59e831be5e7d678188a 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 cf684f0f4c35e7f73453fc085f8d50a64f6ffa9b..6779a58fae013145f8d293280eaf2b8b45201349 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 f85ce1184ea99018fe62ef2229d7749b2ae5750c..8edb66ab8f6149f5b44786a2895ae31211662b3c 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 f43f0846554db0e669971bc9802f87aecfadf6fa..e4867a08b406cc80ed6cb3d890a8ac203a45abc0 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 a9a3019c8a999cbf47631e2aa37fc0f0d7dbb2f3..0000000000000000000000000000000000000000 --- 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 adb7b892fb37ea54a6d24a61c9afaa2963d8a3df..a2dd3119c3aa92ff7975977b141e2233f8d8793f 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 74803a8117a82ef5afa77305c384daa5087786e6..320c7b0331b62ebaa97d92bfbadccb78ecf5fa35 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 0000000000000000000000000000000000000000..a54a131f6690fd828e59617643ce2ecbf7f6b2ed --- /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 2f314906d5b31aab0bc9d70e8cfa550bb4afffe8..044a70975a0e46b8f5bc14918e56c53a0b3ca30b 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 a211c468e6898eda69800ae3947e84aee2db13c5..e2ccdee87353d513736fc7329e9129a47961427e 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 6dd635e9d161052c237b4d3e1a7a6dd4b34b7a0e..665bb8623b26e22180d2df078f4231975c4ed4f3 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 d37d4d9c3d74b380c9e1448b38e03bc6f50d9298..7c49f976f36160d67aa0793d5288193f8d5cc5f9 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 63c928a6c3d807f7c2387d874bc204ce066cf94c..b23780edf1437ea7014e15746831ecc86a9d6167 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 169dc825ddec0851ae8a502d865ceb4dc80a0298..9891bd02e2b0f5ca73639de1df3b82755e2a1032 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 2915f94ae69ff113793572e9f2848c17907d59e9..8e82a1991209027946ca74882b14022e0bc40d59 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 7f7269aee447639ff3816023d080ee37bd1d9457..de95aff0936d6a23eedb1b2c92981350d695d06c 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 0000000000000000000000000000000000000000..a3b76e7ea98f0b3a195e0b44d02de719f930fb27 --- /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 c4d027eac9ac9fa3dab4bf1c2372196ce6c0fe4b..10c7b9dce3e08dc4b60bed2a3a332a1612a0169e 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), - ), - ], - ), - 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, - ), + final textTheme = getEnteTextTheme(context); + final searchTypeAndSuggestion = []; + searchTypeToQuerySuggestion.forEach( + (key, value) { + searchTypeAndSuggestion.add( + Row( + children: [ + Text( + key, + style: textTheme.bodyMuted, ), - ), - 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, + const SizedBox(width: 6), + Flexible( + child: Text( + formatList(value), + style: textTheme.miniMuted, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - ), - 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, - ), + ], + ), + ); + }, + ); + 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, ), - ], - ), + ), + ], ), ); } + + /// 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 6474ca65b60d834c88da998069722fa08df0dc06..f043574b9692f05283750032f905dada003da344 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 b76e5c9f8155cc31d72d36cf3acbdce2c4df7e8a..f160ac7adfb92a398e401731c2e3d8d570afb7ae 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 0000000000000000000000000000000000000000..89c7046263b68be85efc726a30399fe627860c34 --- /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 a479c8ece54b20fde73376d0ea16b8e9876fe54b..13b303fecc82ee3c3ec6412a441d131dfa781c1a 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 0000000000000000000000000000000000000000..a8a280296f373cb6e0a52531162cbf0dbe658a8a --- /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 0000000000000000000000000000000000000000..a34ab0ffd749de1f338201f1309e6505c79bbb95 --- /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 0000000000000000000000000000000000000000..4a4e43f9d48d7c0c9067bdb7d7c960f38de46c78 --- /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 43ffd0391f7e2feadb959b84bced8601f92eba2e..c8f034fb19b569fed6aef30622ed7f31f3fb3f8e 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 08094ba110cf0f15d2639183a85c9232a8e33b14..eac86ce170db7d754c69c59057c4bb6ef0aebe84 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 c37e453d2b5d3e21b3c01616786fdfa32803ad28..efe4c828f2bb0546218dc36af6f66b52fc0ee48a 100644 --- a/lib/ui/viewer/search/search_widget.dart +++ b/lib/ui/viewer/search/search_widget.dart @@ -1,166 +1,187 @@ -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); +bool isSearchQueryEmpty = true; + +class SearchWidget extends StatefulWidget { + const SearchWidget({Key? key}) : super(key: key); @override - State createState() => _SearchIconWidgetState(); + State createState() => SearchWidgetState(); } -class _SearchIconWidgetState extends State { +class SearchWidgetState extends State { + static String query = ""; + final _searchService = SearchService.instance; + final _debouncer = Debouncer(const Duration(milliseconds: 100)); + 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 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 - 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(), - ), - ); - }, - ), - ); + void didChangeDependencies() { + super.didChangeDependencies(); + _bottomPadding = + (MediaQuery.viewInsetsOf(context).bottom - _distanceOfWidgetFromBottom); + if (_bottomPadding < 0) { + _bottomPadding = 0; + } } -} - -class SearchWidget extends StatefulWidget { - const SearchWidget({Key? key}) : super(key: key); @override - State createState() => _SearchWidgetState(); -} + void dispose() { + _debouncer.cancelDebounce(); + focusNode.dispose(); + _tabDoubleTapEvent?.cancel(); + textController.removeListener(textControllerListener); + textController.dispose(); + super.dispose(); + } -class _SearchWidgetState extends State { - String _query = ""; - final List _results = []; - final _searchService = SearchService.instance; - final _debouncer = Debouncer(const Duration(milliseconds: 100)); - final Logger _logger = Logger((_SearchWidgetState).toString()); + 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) { - return GestureDetector( - onTap: () { - Navigator.pop(context); - }, - child: Container( - color: Theme.of(context).colorScheme.searchResultsBackgroundColor, - child: SafeArea( + 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(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, - ); - }, + 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, ), ), - 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, + /*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, + ); + }, + ), ), ), ), - _results.isNotEmpty - ? SearchSuggestionsWidget(_results) - : _query.isNotEmpty - ? const NoResultWidget() - : const NavigateToMap(), - ], + ), ), ), ), @@ -168,12 +189,6 @@ class _SearchWidgetState extends State { ); } - @override - void dispose() { - _debouncer.cancelDebounce(); - super.dispose(); - } - 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 0000000000000000000000000000000000000000..f514d3313c876dd3acf709f388b87113877d2bcd --- /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 42c2f58d83bb6f6e2f9d3d0369dfa0a3e9fbb98c..97332382d24830537e21d4368dc580648ffb5f97 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 337038f0ee5d1228c484d2abf4fba85010e1ebae..b6fd7e5335fcdb6aa0a6436b3932844dc4a2e1f3 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 a034f8c8c344684cb3fc9f0ac1c9f915e9459536..49dcaabbe7a6a82f18a053e43180c0d9a556b956 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 902beaa8639a09cca6fbce62052242f60e466710..9d2349252df8cf1839ffddbd6ccb6519f6b7b49b 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