Merge branch 'main' into clip

This commit is contained in:
vishnukvmd 2023-11-16 19:00:32 +05:30
commit 6b7579da26
77 changed files with 3164 additions and 679 deletions

View file

@ -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 = <double>[1, 2, 10, 20, 40, 80, 200, 400, 1200];
const defaultRadiusValue = 40.0;
const galleryGridSpacing = 2.0;
const searchSectionLimit = 7;

View file

@ -1,3 +1,27 @@
const List<String> 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());
}
}

View file

@ -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}";

View file

@ -347,6 +347,8 @@ class MessageLookup extends MessageLookupByLibrary {
"backupSettings":
MessageLookupByLibrary.simpleMessage("Backup settings"),
"backupVideos": MessageLookupByLibrary.simpleMessage("Backup videos"),
"blackFridaySale":
MessageLookupByLibrary.simpleMessage("Black Friday Sale"),
"blog": MessageLookupByLibrary.simpleMessage("Blog"),
"cachedData": MessageLookupByLibrary.simpleMessage("Cached data"),
"calculating": MessageLookupByLibrary.simpleMessage("Calculating..."),
@ -662,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"),
@ -841,6 +845,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"),
@ -915,6 +920,7 @@ class MessageLookup extends MessageLookupByLibrary {
"paymentFailedWithReason": m34,
"pendingItems": MessageLookupByLibrary.simpleMessage("Pending items"),
"pendingSync": MessageLookupByLibrary.simpleMessage("Pending sync"),
"people": MessageLookupByLibrary.simpleMessage("People"),
"peopleUsingYourCode":
MessageLookupByLibrary.simpleMessage("People using your code"),
"permDeleteWarning": MessageLookupByLibrary.simpleMessage(
@ -923,6 +929,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"),
@ -1083,12 +1091,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"),
@ -1326,6 +1348,8 @@ class MessageLookup extends MessageLookupByLibrary {
"upgrade": MessageLookupByLibrary.simpleMessage("Upgrade"),
"uploadingFilesToAlbum":
MessageLookupByLibrary.simpleMessage("Uploading files to album..."),
"upto50OffUntil4thDec": MessageLookupByLibrary.simpleMessage(
"Upto 50% off, until 4th Dec."),
"usableReferralStorageInfo": MessageLookupByLibrary.simpleMessage(
"Usable storage is limited by your current plan. Excess claimed storage will automatically become usable when you upgrade your plan."),
"usePublicLinksForPeopleNotOnEnte":

170
lib/generated/l10n.dart generated
View file

@ -6859,6 +6859,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(
@ -6909,16 +7029,6 @@ class S {
);
}
/// `Location`
String get location {
return Intl.message(
'Location',
name: 'location',
desc: '',
args: [],
);
}
/// `km`
String get kiloMeterUnit {
return Intl.message(
@ -7834,6 +7944,46 @@ class S {
args: [],
);
}
/// `Your map`
String get yourMap {
return Intl.message(
'Your map',
name: 'yourMap',
desc: '',
args: [],
);
}
/// `Black Friday Sale`
String get blackFridaySale {
return Intl.message(
'Black Friday Sale',
name: 'blackFridaySale',
desc: '',
args: [],
);
}
/// `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(
'Upto 50% off, until 4th Dec.',
name: 'upto50OffUntil4thDec',
desc: '',
args: [],
);
}
}
class AppLocalizationDelegate extends LocalizationsDelegate<S> {

View file

@ -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"
}

View file

@ -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"
}

View file

@ -962,12 +962,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",
@ -1109,10 +1120,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"
"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."
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1108,5 +1108,7 @@
"hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!",
"viewAddOnButton": "查看附加组件",
"addOns": "附加组件",
"addOnPageSubtitle": "附加组件详情"
"addOnPageSubtitle": "附加组件详情",
"yourMap": "Your map",
"modifyYourQueryOrTrySearchingFor": "Modify your query, or try searching for"
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -1,45 +0,0 @@
class LocationApiResponse {
final List<LocationDataFromResponse> results;
LocationApiResponse({
required this.results,
});
LocationApiResponse copyWith({
required List<LocationDataFromResponse> results,
}) {
return LocationApiResponse(
results: results,
);
}
factory LocationApiResponse.fromMap(Map<String, dynamic> map) {
return LocationApiResponse(
results: (map['results']) == null
? []
: List<LocationDataFromResponse>.from(
(map['results']).map(
(x) =>
LocationDataFromResponse.fromMap(x as Map<String, dynamic>),
),
),
);
}
}
class LocationDataFromResponse {
final String place;
final List<double> bbox;
LocationDataFromResponse({
required this.place,
required this.bbox,
});
factory LocationDataFromResponse.fromMap(Map<String, dynamic> map) {
return LocationDataFromResponse(
place: map['place'] as String,
bbox: List<double>.from(
(map['bbox']),
),
);
}
}

View file

@ -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 = <String>{};
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();
});
}
}

View file

@ -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,16 +14,3 @@ abstract class SearchResult {
List<EnteFile> resultFiles();
}
enum ResultType {
collection,
file,
location,
month,
year,
fileType,
fileExtension,
fileCaption,
event,
magic,
}

View file

@ -0,0 +1,287 @@
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,
magic,
}
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<List<SearchResult>> 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<Stream<Event>> viewAllUpdateEvents() {
switch (this) {
case SectionType.location:
return [Bus.instance.on<LocationTagUpdatedEvent>()];
case SectionType.album:
return [Bus.instance.on<CollectionUpdatedEvent>()];
default:
return [];
}
}
///Events to listen to for different search sections, different from common
///events listened to in AllSectionsExampleState.
List<Stream<Event>> sectionUpdateEvents() {
switch (this) {
case SectionType.location:
return [Bus.instance.on<LocationTagUpdatedEvent>()];
default:
return [];
}
}
}

View file

@ -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<double>);
typedef VoidCallbackParamLocation = void Function(Location);
typedef VoidCallbackParamSearchResults = void Function(List<SearchResult>);
typedef FutureVoidCallback = Future<void> Function();
typedef FutureOrVoidCallback = FutureOr<void> Function();

View file

@ -33,6 +33,10 @@ class UserDetails {
return familyData?.members?.isNotEmpty ?? false;
}
bool hasPaidAddon() {
return bonusData?.getAddOnBonuses().isNotEmpty ?? false;
}
bool isFamilyAdmin() {
assert(isPartOfFamily(), "verify user is part of family before calling");
final FamilyMember currentUserMember = familyData!.members!

View file

@ -166,7 +166,8 @@ class BillingService {
BuildContext context,
UserDetails userDetails,
) async {
if (userDetails.subscription.productID == freeProductID) {
if (userDetails.subscription.productID == freeProductID &&
!userDetails.hasPaidAddon()) {
await showErrorDialog(
context,
S.of(context).familyPlans,

View file

@ -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/services/semantic_search/semantic_search_service.dart';
@ -89,6 +95,36 @@ class SearchService {
return collectionSearchResults;
}
Future<List<AlbumSearchResult>> getAllCollectionSearchResults(
int? limit,
) async {
try {
final List<Collection> collections =
_collectionService.getCollectionsForUI(
includedShared: true,
);
final List<AlbumSearchResult> 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<List<GenericSearchResult>> getYearSearchResults(
String yearFromQuery,
) async {
@ -111,6 +147,96 @@ class SearchService {
return searchResults;
}
Future<List<GenericSearchResult>> getRandomMomentsSearchResults(
BuildContext context,
) async {
try {
final nonNullSearchResults = <GenericSearchResult>[];
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<GenericSearchResult?> getRadomYearSearchResult() async {
for (var yearData in YearsData.instance.yearsData..shuffle()) {
final List<EnteFile> filesInYear =
await _getFilesInYear(yearData.duration);
if (filesInYear.isNotEmpty) {
return GenericSearchResult(
ResultType.year,
yearData.year,
filesInYear,
);
}
}
//todo this throws error
return null;
}
Future<List<GenericSearchResult>> getMonthSearchResults(
BuildContext context,
String query,
) async {
final List<GenericSearchResult> 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<GenericSearchResult?> 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<List<GenericSearchResult>> getHolidaySearchResults(
BuildContext context,
String query,
@ -139,6 +265,28 @@ class SearchService {
return searchResults;
}
Future<GenericSearchResult?> 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<List<GenericSearchResult>> getFileTypeResults(
String query,
) async {
@ -163,6 +311,203 @@ class SearchService {
return searchResults;
}
Future<List<GenericSearchResult>> getAllFileTypesAndExtensionsResults(
int? limit,
) async {
final List<GenericSearchResult> searchResults = [];
final List<EnteFile> allFiles = await getAllFiles();
final fileTypesAndMatchingFiles = <FileType, List<EnteFile>>{};
final extensionsAndMatchingFiles = <String, List<EnteFile>>{};
try {
for (EnteFile file in allFiles) {
if (!fileTypesAndMatchingFiles.containsKey(file.fileType)) {
fileTypesAndMatchingFiles[file.fileType] = <EnteFile>[];
}
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] = <EnteFile>[];
}
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<List<GenericSearchResult>> getAllDescriptionSearchResults(
//todo: use limit
int? limit,
) async {
try {
final List<GenericSearchResult> searchResults = [];
final List<EnteFile> 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 = <Map<int, List<String>>>[];
final descAndMatchingFiles = <String, Set<EnteFile>>{};
int distinctFullDescCount = 0;
final allDistinctFullDescs = <String>[];
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: <String>[], 1: <String>[]});
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<int, List<String>> 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 = <String, Set<EnteFile>>{};
while (descAndMatchingFiles.isNotEmpty) {
final baseEntry = descAndMatchingFiles.entries.first;
final descsWithSameFiles = <String, Set<EnteFile>>{};
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<List<GenericSearchResult>> getCaptionAndNameResults(
String query,
) async {
@ -322,29 +667,63 @@ class SearchService {
return searchResults;
}
Future<List<GenericSearchResult>> getMonthSearchResults(
BuildContext context,
String query,
) async {
final List<GenericSearchResult> 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<List<GenericSearchResult>> getAllLocationTags(int? limit) async {
try {
final Map<LocalEntity<LocationTag>, List<EnteFile>> tagToItemsMap = {};
final List<GenericSearchResult> 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<LocationTag> tag in tagToItemsMap.keys) {
if (LocationService.instance.isFileInsideLocationTag(
tag.item.centerPoint,
file.location!,
tag.item.radius,
)) {
tagToItemsMap[tag]!.add(file);
}
}
}
}
for (MapEntry<LocalEntity<LocationTag>, List<EnteFile>> 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<List<GenericSearchResult>> getDateResults(
@ -389,6 +768,124 @@ class SearchService {
return searchResults;
}
Future<GenericSearchResult?> 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<List<GenericSearchResult>> getPeopleSearchResults(
String query,
) async {
final lowerCaseQuery = query.toLowerCase();
final searchResults = <GenericSearchResult>[];
final allFiles = await getAllFiles();
final peopleToSharedFiles = <User, List<EnteFile>>{};
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<List<GenericSearchResult>> getAllPeopleSearchResults(
int? limit,
) async {
try {
final searchResults = <GenericSearchResult>[];
final allFiles = await getAllFiles();
final peopleToSharedFiles = <User, List<EnteFile>>{};
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<MonthData> _getMatchingMonths(BuildContext context, String query) {
return getMonthData(context)
.where(

View file

@ -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<AllSectionsExamplesProvider> createState() =>
_AllSectionsExamplesProviderState();
}
class _AllSectionsExamplesProviderState
extends State<AllSectionsExamplesProvider> {
//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<List<List<SearchResult>>> allSectionsExamplesFuture = Future.value([]);
late StreamSubscription<FilesUpdatedEvent> _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<FilesUpdatedEvent>().listen((event) {
reloadAllSections();
});
reloadAllSections();
});
}
void reloadAllSections() {
_debouncer.run(() async {
setState(() {
_logger.info("reloading all sections in search tab");
final allSectionsExamples = <Future<List<SearchResult>>>[];
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<List<SearchResult>>(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<List<List<SearchResult>>> allSectionsExamplesFuture;
final ValueNotifier<bool> isDebouncingNotifier;
const InheritedAllSectionsExamples(
this.allSectionsExamplesFuture,
this.isDebouncingNotifier, {
super.key,
required super.child,
});
static InheritedAllSectionsExamples of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<InheritedAllSectionsExamples>()!;
}
@override
bool updateShouldNotify(covariant InheritedAllSectionsExamples oldWidget) {
return !identical(
oldWidget.allSectionsExamplesFuture,
allSectionsExamplesFuture,
);
}
}

View file

@ -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 ||

View file

@ -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<SearchResultsProvider> createState() => _SearchResultsProviderState();
}
class _SearchResultsProviderState extends State<SearchResultsProvider> {
var searchResults = <SearchResult>[];
@override
Widget build(BuildContext context) {
return InheritedSearchResults(
searchResults,
updateSearchResults,
child: widget.child,
);
}
void updateSearchResults(List<SearchResult> newResult) {
setState(() {
searchResults = newResult;
});
}
}
class InheritedSearchResults extends InheritedWidget {
final List<SearchResult> results;
final VoidCallbackParamSearchResults updateResults;
const InheritedSearchResults(
this.results,
this.updateResults, {
required super.child,
super.key,
});
static InheritedSearchResults of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<InheritedSearchResults>()!;
}
@override
bool updateShouldNotify(covariant InheritedSearchResults oldWidget) {
return results != oldWidget.results;
}
}

View file

@ -60,6 +60,7 @@ class _CollectionListPageState extends State<CollectionListPage> {
return Scaffold(
body: SafeArea(
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
controller: ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0,
),

View file

@ -23,6 +23,7 @@ class DeviceFolderVerticalGridView extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: <Widget>[
SliverAppBar(
elevation: 0,

View file

@ -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,

View file

@ -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';

View file

@ -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';

View file

@ -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<TextInputDialog> {
@override
void initState() {
super.initState();
_textEditingController =
widget.textEditingController ?? TextEditingController();
_inputIsEmptyNotifier = widget.initialValue?.isEmpty ?? true
@ -223,7 +224,6 @@ class _TextInputDialogState extends State<TextInputDialog> {
_inputIsEmptyNotifier.value = _textEditingController.text.isEmpty;
}
});
super.initState();
}
@override
@ -235,7 +235,7 @@ class _TextInputDialogState extends State<TextInputDialog> {
@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(

View file

@ -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<HomeHeaderWidget> {
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,
),
),
);
}
},
),
],
);
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import "package:flutter_animate/flutter_animate.dart";
import "package:photos/ente_theme_data.dart";
import 'package:photos/theme/colors.dart';
import "package:photos/theme/ente_theme.dart";
@ -20,6 +21,7 @@ class NotificationWidget extends StatelessWidget {
final String? subText;
final GestureTapCallback onTap;
final NotificationType type;
final bool isBlackFriday;
const NotificationWidget({
Key? key,
@ -27,13 +29,14 @@ class NotificationWidget extends StatelessWidget {
required this.actionIcon,
required this.text,
required this.onTap,
this.isBlackFriday = false,
this.subText,
this.type = NotificationType.warning,
}) : super(key: key);
@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;
@ -46,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;
@ -90,11 +92,34 @@ class NotificationWidget extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(
startIcon,
size: 36,
color: strokeColorScheme.strokeBase,
),
isBlackFriday
? Icon(
startIcon,
size: 36,
color: strokeColorScheme.strokeBase,
)
.animate(
onPlay: (controller) =>
controller.repeat(reverse: true),
delay: 2000.ms,
)
.shake(
duration: 500.ms,
hz: 6,
delay: 1600.ms,
)
.scale(
duration: 500.ms,
begin: const Offset(0.9, 0.9),
end: const Offset(1.1, 1.1),
delay: 1600.ms,
// curve: Curves.easeInOut,
)
: Icon(
startIcon,
size: 36,
color: strokeColorScheme.strokeBase,
),
const SizedBox(width: 12),
Expanded(
child: Column(
@ -132,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);

View file

@ -90,6 +90,7 @@ class _TextInputWidgetState extends State<TextInputWidget> {
@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<TextInputWidget> {
widget.isEmptyNotifier!.value = _textController.text.isEmpty;
});
}
super.initState();
}
@override

View file

@ -147,6 +147,20 @@ class _HomeBottomNavigationBarState extends State<HomeBottomNavigationBar> {
// 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,

View file

@ -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,
),
);
}
}

View file

@ -170,7 +170,7 @@ class HugeListViewState<T> extends State<HugeListView<T>> {
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<T> extends State<HugeListView<T>> {
),
)
: ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: max(widget.totalCount, 0),
itemBuilder: (context, index) {
return ExcludeSemantics(

View file

@ -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";

View file

@ -240,7 +240,12 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
}
if (_hasActiveSubscription) {
widgets.add(ValidityWidget(currentSubscription: _currentSubscription));
widgets.add(
ValidityWidget(
currentSubscription: _currentSubscription,
bonusData: _userDetails.bonusData,
),
);
}
if (_currentSubscription!.productID == freeProductID) {

View file

@ -211,7 +211,12 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
widgets.add(_showSubscriptionToggle());
if (_hasActiveSubscription) {
widgets.add(ValidityWidget(currentSubscription: _currentSubscription));
widgets.add(
ValidityWidget(
currentSubscription: _currentSubscription,
bonusData: _userDetails.bonusData,
),
);
}
if (_currentSubscription!.productID == freeProductID) {

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import "package:intl/intl.dart";
import 'package:photos/ente_theme_data.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/models/api/storage_bonus/bonus.dart";
import 'package:photos/models/subscription.dart';
import "package:photos/services/update_service.dart";
import "package:photos/theme/ente_theme.dart";
@ -87,21 +88,27 @@ class _SubscriptionHeaderWidgetState extends State<SubscriptionHeaderWidget> {
class ValidityWidget extends StatelessWidget {
final Subscription? currentSubscription;
final BonusData? bonusData;
const ValidityWidget({Key? key, this.currentSubscription}) : super(key: key);
const ValidityWidget({Key? key, this.currentSubscription, this.bonusData})
: super(key: key);
@override
Widget build(BuildContext context) {
if (currentSubscription == null) {
return const SizedBox.shrink();
}
final bool isFreeTrialSub = currentSubscription!.productID == freeProductID;
if (isFreeTrialSub && (bonusData?.getAddOnBonuses().isNotEmpty ?? false)) {
return const SizedBox.shrink();
}
final endDate =
DateFormat.yMMMd(Localizations.localeOf(context).languageCode).format(
DateTime.fromMicrosecondsSinceEpoch(currentSubscription!.expiryTime),
);
var message = S.of(context).renewsOn(endDate);
if (currentSubscription!.productID == freeProductID) {
if (isFreeTrialSub) {
message = UpdateService.instance.isPlayStoreFlavor()
? S.of(context).playStoreFreeTrialValidTill(endDate)
: S.of(context).freeTrialValidTill(endDate);

128
lib/ui/search_tab.dart Normal file
View file

@ -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<SearchTab> createState() => _SearchTabState();
}
class _SearchTabState extends State<SearchTab> {
var _searchResults = <SearchResult>[];
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<AllSearchSections> createState() => _AllSearchSectionsState();
}
class _AllSearchSectionsState extends State<AllSearchSections> {
@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();
},
),
],
);
}
}

View file

@ -8,7 +8,9 @@ import 'package:photos/services/deduplication_service.dart';
import 'package:photos/services/sync_service.dart';
import 'package:photos/services/update_service.dart';
import 'package:photos/theme/ente_theme.dart';
import "package:photos/ui/components/buttons/button_widget.dart";
import "package:photos/ui/components/captioned_text_widget.dart";
import "package:photos/ui/components/dialog_widget.dart";
import 'package:photos/ui/components/expandable_menu_item_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import "package:photos/ui/components/models/button_type.dart";
@ -16,9 +18,10 @@ import 'package:photos/ui/settings/backup/backup_folder_selection_page.dart';
import 'package:photos/ui/settings/backup/backup_settings_screen.dart';
import 'package:photos/ui/settings/common_settings.dart';
import 'package:photos/ui/tools/deduplicate_page.dart';
import 'package:photos/ui/tools/free_space_page.dart';
import "package:photos/ui/tools/free_space_page.dart";
import 'package:photos/utils/data_util.dart';
import 'package:photos/utils/dialog_util.dart';
import "package:photos/utils/local_settings.dart";
import 'package:photos/utils/navigation_util.dart';
import 'package:photos/utils/toast_util.dart';
@ -153,25 +156,55 @@ class BackupSectionWidgetState extends State<BackupSectionWidget> {
}
void _showSpaceFreedDialog(BackupStatus status) {
showChoiceDialog(
context,
title: S.of(context).success,
body: S.of(context).youHaveSuccessfullyFreedUp(formatBytes(status.size)),
firstButtonLabel: S.of(context).rateUs,
firstButtonOnTap: () async {
UpdateService.instance.launchReviewUrl();
},
firstButtonType: ButtonType.primary,
secondButtonLabel: S.of(context).ok,
secondButtonOnTap: () async {
if (Platform.isIOS) {
showToast(
context,
S.of(context).remindToEmptyDeviceTrash,
);
}
},
);
if (LocalSettings.instance.shouldPromptToRateUs()) {
LocalSettings.instance.setRateUsShownCount(
LocalSettings.instance.getRateUsShownCount() + 1,
);
showChoiceDialog(
context,
title: S.of(context).success,
body:
S.of(context).youHaveSuccessfullyFreedUp(formatBytes(status.size)),
firstButtonLabel: S.of(context).rateUs,
firstButtonOnTap: () async {
UpdateService.instance.launchReviewUrl();
},
firstButtonType: ButtonType.primary,
secondButtonLabel: S.of(context).ok,
secondButtonOnTap: () async {
if (Platform.isIOS) {
showToast(
context,
S.of(context).remindToEmptyDeviceTrash,
);
}
},
);
} else {
showDialogWidget(
context: context,
title: S.of(context).success,
body:
S.of(context).youHaveSuccessfullyFreedUp(formatBytes(status.size)),
icon: Icons.download_done_rounded,
isDismissible: true,
buttons: [
ButtonWidget(
buttonType: ButtonType.neutral,
labelText: S.of(context).ok,
isInAlert: true,
onTap: () async {
if (Platform.isIOS) {
showToast(
context,
S.of(context).remindToEmptyDeviceTrash,
);
}
},
),
],
);
}
}
void _showDuplicateFilesDeletedDialog(DeduplicationResult result) {

View file

@ -9,6 +9,7 @@ import 'package:photos/events/opened_settings_event.dart';
import "package:photos/generated/l10n.dart";
import 'package:photos/services/feature_flag_service.dart';
import "package:photos/services/storage_bonus_service.dart";
import "package:photos/services/user_service.dart";
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/ente_theme.dart';
import "package:photos/ui/components/notification_widget.dart";
@ -28,6 +29,7 @@ import 'package:photos/ui/settings/support_section_widget.dart';
import 'package:photos/ui/settings/theme_switch_widget.dart';
import "package:photos/ui/sharing/verify_identity_dialog.dart";
import "package:photos/utils/navigation_util.dart";
import "package:url_launcher/url_launcher_string.dart";
class SettingsPage extends StatelessWidget {
final ValueNotifier<String?> emailNotifier;
@ -84,23 +86,42 @@ class SettingsPage extends StatelessWidget {
const sectionSpacing = SizedBox(height: 8);
contents.add(const SizedBox(height: 8));
if (hasLoggedIn) {
final shouldShowBFBanner = shouldShowBfBanner();
final showStorageBonusBanner =
StorageBonusService.instance.shouldShowStorageBonus();
contents.addAll([
const StorageCardWidget(),
StorageBonusService.instance.shouldShowStorageBonus()
(shouldShowBFBanner || showStorageBonusBanner)
? RepaintBoundary(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: NotificationWidget(
startIcon: Icons.auto_awesome,
actionIcon: Icons.arrow_forward_outlined,
text: S.of(context).doubleYourStorage,
subText: S.of(context).referFriendsAnd2xYourPlan,
type: NotificationType.goldenBanner,
onTap: () async {
StorageBonusService.instance.markStorageBonusAsDone();
routeToPage(context, const ReferralScreen());
},
),
child: shouldShowBFBanner
? NotificationWidget(
isBlackFriday: true,
startIcon: Icons.celebration,
actionIcon: Icons.arrow_forward_outlined,
text: S.of(context).blackFridaySale,
subText: S.of(context).upto50OffUntil4thDec,
type: NotificationType.goldenBanner,
onTap: () async {
launchUrlString(
"https://ente.io/blackfriday",
mode: LaunchMode.platformDefault,
);
},
)
: NotificationWidget(
startIcon: Icons.auto_awesome,
actionIcon: Icons.arrow_forward_outlined,
text: S.of(context).doubleYourStorage,
subText: S.of(context).referFriendsAnd2xYourPlan,
type: NotificationType.goldenBanner,
onTap: () async {
StorageBonusService.instance
.markStorageBonusAsDone();
routeToPage(context, const ReferralScreen());
},
),
).animate(onPlay: (controller) => controller.repeat()).shimmer(
duration: 1000.ms,
delay: 3200.ms,
@ -150,6 +171,7 @@ class SettingsPage extends StatelessWidget {
return SafeArea(
bottom: false,
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -166,6 +188,23 @@ class SettingsPage extends StatelessWidget {
);
}
bool shouldShowBfBanner() {
if (!Platform.isAndroid && !kDebugMode) {
return false;
}
// if date is after 5th of December 2023, 00:00:00, hide banner
if (DateTime.now().isAfter(DateTime(2023, 12, 5))) {
return false;
}
// if coupon is already applied, can hide the banner
return (UserService.instance
.getCachedUserDetails()
?.bonusData
?.getAddOnBonuses()
.isEmpty ??
true);
}
Future<void> _showVerifyIdentityDialog(BuildContext context) async {
await showDialog(
context: context,

View file

@ -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<HomeWidget> {
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<HomeWidget> {
List<SharedMediaFile>? _sharedFiles;
bool _shouldRenderCreateCollectionSheet = false;
bool _showShowBackupHook = false;
final isOnSearchTabNotifier = ValueNotifier<bool>(false);
late StreamSubscription<TabChangedEvent> _tabChangedEventSubscription;
late StreamSubscription<SubscriptionPurchasedEvent>
@ -104,12 +110,17 @@ class _HomeWidgetState extends State<HomeWidget> {
_logger.info("Building initstate");
_tabChangedEventSubscription =
Bus.instance.on<TabChangedEvent>().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<HomeWidget> {
_accountConfiguredEvent.cancel();
_intentDataStreamSubscription?.cancel();
_collectionUpdatedEvent.cancel();
isOnSearchTabNotifier.dispose();
_pageController.dispose();
super.dispose();
}
@ -381,44 +393,90 @@ class _HomeWidgetState extends State<HomeWidget> {
!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,
),
),
),
],
],
),
);
}

View file

@ -89,6 +89,7 @@ class _SharedCollectionsTabState extends State<SharedCollectionsTab>
final SectionTitle sharedByYou =
SectionTitle(title: S.of(context).sharedByYou);
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Container(
margin: const EdgeInsets.only(bottom: 50),
child: Column(

View file

@ -95,6 +95,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
);
return CustomScrollView(
physics: const BouncingScrollPhysics(),
controller: _scrollController,
slivers: [
SliverToBoxAdapter(
@ -224,7 +225,10 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
),
child: Row(
children: [
const NewAlbumIcon(),
const NewAlbumIcon(
icon: Icons.add_rounded,
iconButtonType: IconButtonType.secondary,
),
GestureDetector(
onTapDown: (TapDownDetails details) async {
final int? selectedValue = await showMenu<int>(

View file

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:photo_view/photo_view.dart';
import "package:photo_view/photo_view_gallery.dart";
import 'package:photos/core/cache/thumbnail_in_memory_cache.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/core/event_bus.dart';
@ -88,21 +89,23 @@ class _ZoomableImageState extends State<ZoomableImage>
Widget content;
if (_imageProvider != null) {
content = PhotoViewGestureDetectorScope(
axis: Axis.vertical,
child: PhotoView(
imageProvider: _imageProvider,
controller: _photoViewController,
scaleStateChangedCallback: _scaleStateChangedCallback,
minScale: widget.shouldCover
? PhotoViewComputedScale.covered
: PhotoViewComputedScale.contained,
gaplessPlayback: true,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.tagPrefix! + _photo.tag,
),
backgroundDecoration: widget.backgroundDecoration as BoxDecoration?,
),
content = PhotoViewGallery.builder(
gaplessPlayback: true,
scaleStateChangedCallback: _scaleStateChangedCallback,
backgroundDecoration: widget.backgroundDecoration as BoxDecoration?,
builder: (context, index) {
return PhotoViewGalleryPageOptions(
imageProvider: _imageProvider!,
minScale: widget.shouldCover
? PhotoViewComputedScale.covered
: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.tagPrefix! + _photo.tag,
),
controller: _photoViewController,
);
},
itemCount: 1,
);
} else {
content = const EnteLoadingWidget();

View file

@ -113,15 +113,14 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
: 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),
);

View file

@ -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,

View file

@ -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<LocationGalleryWidget> createState() => _LocationGalleryWidgetState();
@ -229,7 +233,7 @@ class _LocationGalleryWidgetState extends State<LocationGalleryWidget> {
EventType.deletedFromEverywhere,
},
selectedFiles: _selectedFiles,
tagPrefix: "location_gallery",
tagPrefix: widget.tagPrefix,
),
FileSelectionOverlayBar(
GalleryType.locationTag,

View file

@ -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<EnteFile?> showPickCenterPointSheet(
BuildContext context,
LocalEntity<LocationTag> locationTagEntity,
) async {
Future<Location?> 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<EnteFile?> showPickCenterPointSheet(
}
class PickCenterPointWidget extends StatelessWidget {
final LocalEntity<LocationTag> 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);
},
),
);

View file

@ -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,
),
],
),
),
),
);
}
}

View file

@ -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<NoResultWidget> createState() => _NoResultWidgetState();
}
class _NoResultWidgetState extends State<NoResultWidget> {
late final List<SectionType> searchTypes;
final searchTypeToQuerySuggestion = <String, List<String>>{};
@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 = <String>[];
for (int j = 0; j < 2 && j < value[i].length; j++) {
querySuggestions.add(value[i][j].name());
}
if (querySuggestions.isNotEmpty) {
searchTypeToQuerySuggestion.addAll({
searchTypes[i].sectionTitle(context): querySuggestions,
});
}
}
setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.searchResultsColor,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: -3,
blurRadius: 6,
offset: const Offset(0, 8),
final textTheme = getEnteTextTheme(context);
final searchTypeAndSuggestion = <Widget>[];
searchTypeToQuerySuggestion.forEach(
(key, value) {
searchTypeAndSuggestion.add(
Row(
children: [
Text(
key,
style: textTheme.bodyMuted,
),
const SizedBox(width: 6),
Flexible(
child: Text(
formatList(value),
style: textTheme.miniMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context).noResultsFound,
style: textTheme.largeBold,
),
const SizedBox(height: 6),
searchTypeToQuerySuggestion.isNotEmpty
? Text(
S.of(context).modifyYourQueryOrTrySearchingFor,
style: textTheme.smallMuted,
)
: const SizedBox.shrink(),
],
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ListView.separated(
itemBuilder: (context, index) {
return searchTypeAndSuggestion[index];
},
separatorBuilder: (context, index) {
return const SizedBox(height: 12);
},
itemCount: searchTypeToQuerySuggestion.length,
shrinkWrap: true,
),
),
],
),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
S.of(context).noResultsFound,
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 16,
),
),
),
Container(
margin: const EdgeInsets.only(top: 16),
child: Text(
S.of(context).youCanTrySearchingForADifferentQuery,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.colorScheme
.defaultTextColor
.withOpacity(0.5),
height: 1.5,
),
),
),
Container(
margin: const EdgeInsets.only(bottom: 20, top: 12),
child: Text(
S.of(context).searchByExamples,
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.colorScheme
.defaultTextColor
.withOpacity(0.5),
height: 1.5,
),
),
),
],
),
),
);
}
/// Join the strings with ', ' and wrap each element with double quotes
String formatList(List<String> strings) {
return strings.map((str) => '"$str"').join(', ');
}
}

View file

@ -14,6 +14,7 @@ import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart';
class SearchResultPage extends StatelessWidget {
final SearchResult searchResult;
final bool enableGrouping;
final String tagPrefix;
final _selectedFiles = SelectedFiles();
static const GalleryType appBarType = GalleryType.searchResults;
@ -22,6 +23,7 @@ class SearchResultPage extends StatelessWidget {
SearchResultPage(
this.searchResult, {
this.enableGrouping = true,
this.tagPrefix = "",
Key? key,
}) : super(key: key);
@ -49,10 +51,10 @@ class SearchResultPage extends StatelessWidget {
EventType.deletedFromRemote,
EventType.deletedFromEverywhere,
},
tagPrefix: searchResult.heroTag(),
tagPrefix: tagPrefix + searchResult.heroTag(),
selectedFiles: _selectedFiles,
initialFiles: const [],
enableFileGrouping: enableGrouping,
initialFiles: [searchResult.resultFiles().first],
);
return Scaffold(
appBar: PreferredSize(

View file

@ -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<int>(
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<int>(
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 {
@ -132,6 +135,8 @@ class SearchResultWidget extends StatelessWidget {
return "Description";
case ResultType.magic:
return "Magic";
case ResultType.shared:
return "Shared";
default:
return type.name.toUpperCase();
}

View file

@ -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<SearchSectionAllPage> createState() => _SearchSectionAllPageState();
}
class _SearchSectionAllPageState extends State<SearchSectionAllPage> {
late Future<List<SearchResult>> sectionData;
final streamSubscriptions = <StreamSubscription>[];
@override
void initState() {
super.initState();
final streamsToListenTo = widget.sectionType.viewAllUpdateEvents();
for (Stream<Event> 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();
}
},
),
),
),
],
),
);
}
}

View file

@ -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,
),
),
),
);

View file

@ -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<int>? 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<int>(
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,
),
],
),
),
),
);
}
}

View file

@ -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<SearchResult> examples;
final int limit;
const SearchSection({
Key? key,
required this.sectionType,
required this.examples,
required this.limit,
}) : super(key: key);
@override
State<SearchSection> createState() => _SearchSectionState();
}
class _SearchSectionState extends State<SearchSection> {
late List<SearchResult> _examples;
final streamSubscriptions = <StreamSubscription>[];
@override
void initState() {
super.initState();
_examples = widget.examples;
final streamsToListenTo = widget.sectionType.sectionUpdateEvents();
for (Stream<Event> 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<SearchResult> examples;
const SearchExampleRow(this.examples, this.sectionType, {super.key});
@override
Widget build(BuildContext context) {
//Cannot use listView.builder here
final scrollableExamples = <Widget>[];
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,
),
],
),
),
),
);
}
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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<SearchSuffixIcon>
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<SearchSuffixIcon>
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>()!;
searchWidgetState.textController.clear();
searchWidgetState.focusNode.unfocus();
},
icon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.iconColor.withOpacity(0.5),
color: colorScheme.strokeMuted,
),
),
);

View file

@ -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(),
),
),
),
),
],
),
);
}

View file

@ -1,179 +1,194 @@
import 'dart:async';
import "dart:async";
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/ente_theme_data.dart';
import "package:photos/generated/l10n.dart";
import 'package:photos/models/search/search_result.dart';
import 'package:photos/services/search_service.dart';
import "package:flutter/material.dart";
import "package:flutter/scheduler.dart";
import "package:logging/logging.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/tab_changed_event.dart";
import "package:photos/models/search/search_result.dart";
import "package:photos/services/search_service.dart";
import "package:photos/states/search_results_state.dart";
import "package:photos/theme/ente_theme.dart";
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/map/enable_map.dart";
import "package:photos/ui/map/map_screen.dart";
import 'package:photos/ui/viewer/search/result/no_result_widget.dart';
import 'package:photos/ui/viewer/search/search_suffix_icon_widget.dart';
import 'package:photos/ui/viewer/search/search_suggestions.dart';
import 'package:photos/utils/date_time_util.dart';
import 'package:photos/utils/debouncer.dart';
import 'package:photos/utils/navigation_util.dart';
import "package:photos/ui/viewer/search/search_suffix_icon_widget.dart";
import "package:photos/utils/date_time_util.dart";
import "package:photos/utils/debouncer.dart";
class SearchIconWidget extends StatefulWidget {
const SearchIconWidget({Key? key}) : super(key: key);
@override
State<SearchIconWidget> createState() => _SearchIconWidgetState();
}
class _SearchIconWidgetState extends State<SearchIconWidget> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Hero(
tag: "search_icon",
child: IconButtonWidget(
iconButtonType: IconButtonType.primary,
icon: Icons.search,
onTap: () {
Navigator.push(
context,
TransparentRoute(
builder: (BuildContext context) => const SearchWidget(),
),
);
},
),
);
}
}
bool isSearchQueryEmpty = true;
class SearchWidget extends StatefulWidget {
const SearchWidget({Key? key}) : super(key: key);
@override
State<SearchWidget> createState() => _SearchWidgetState();
State<SearchWidget> createState() => SearchWidgetState();
}
class _SearchWidgetState extends State<SearchWidget> {
String _query = "";
final List<SearchResult> _results = [];
class SearchWidgetState extends State<SearchWidget> {
static String query = "";
final _searchService = SearchService.instance;
final _debouncer = Debouncer(const Duration(milliseconds: 200));
final Logger _logger = Logger((_SearchWidgetState).toString());
final Logger _logger = Logger((SearchWidgetState).toString());
late FocusNode focusNode;
StreamSubscription<TabDoubleTapEvent>? _tabDoubleTapEvent;
double _bottomPadding = 0.0;
double _distanceOfWidgetFromBottom = 0;
GlobalKey widgetKey = GlobalKey();
TextEditingController textController = TextEditingController();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Container(
color: Theme.of(context).colorScheme.searchResultsBackgroundColor,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
height: 44,
color: Theme.of(context).colorScheme.defaultBackgroundColor,
child: TextFormField(
style: Theme.of(context).textTheme.titleMedium,
// Below parameters are to disable auto-suggestion
enableSuggestions: false,
autocorrect: false,
// Above parameters are to disable auto-suggestion
decoration: InputDecoration(
hintText: S.of(context).searchHintText,
filled: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 10,
),
border: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
prefixIconConstraints: const BoxConstraints(
maxHeight: 44,
maxWidth: 44,
minHeight: 44,
minWidth: 44,
),
suffixIconConstraints: const BoxConstraints(
maxHeight: 44,
maxWidth: 44,
minHeight: 44,
minWidth: 44,
),
prefixIcon: Hero(
tag: "search_icon",
child: Icon(
Icons.search,
color: Theme.of(context)
.colorScheme
.iconColor
.withOpacity(0.5),
),
),
/*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when
setState is called when deboucncing is over and the spinner needs to be shown while debouncing */
suffixIcon: ValueListenableBuilder(
valueListenable: _debouncer.debounceActiveNotifier,
builder: (
BuildContext context,
bool isDebouncing,
Widget? child,
) {
return SearchSuffixIcon(
isDebouncing,
);
},
),
),
onChanged: (value) async {
_query = value;
final List<SearchResult> allResults =
await getSearchResultsForQuery(context, value);
/*checking if _query == value to make sure that the results are from the current query
and not from the previous query (race condition).*/
if (mounted && _query == value) {
setState(() {
_results.clear();
_results.addAll(allResults);
});
}
},
autofocus: true,
),
),
),
_results.isNotEmpty
? SearchSuggestionsWidget(_results)
: _query.isNotEmpty
? const NoResultWidget()
: const NavigateToMap(),
],
),
),
),
),
);
void initState() {
super.initState();
focusNode = FocusNode();
_tabDoubleTapEvent =
Bus.instance.on<TabDoubleTapEvent>().listen((event) async {
debugPrint("Firing now ${event.selectedIndex}");
if (mounted && event.selectedIndex == 3) {
focusNode.requestFocus();
}
});
SchedulerBinding.instance.addPostFrameCallback((_) {
//This buffer is for doing this operation only after SearchWidget's
//animation is complete.
Future.delayed(const Duration(milliseconds: 300), () {
final RenderBox box =
widgetKey.currentContext!.findRenderObject() as RenderBox;
final heightOfWidget = box.size.height;
final offsetPosition = box.localToGlobal(Offset.zero);
final y = offsetPosition.dy;
final heightOfScreen = MediaQuery.sizeOf(context).height;
_distanceOfWidgetFromBottom = heightOfScreen - (y + heightOfWidget);
});
textController.addListener(textControllerListener);
});
textController.text = query;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_bottomPadding =
(MediaQuery.viewInsetsOf(context).bottom - _distanceOfWidgetFromBottom);
if (_bottomPadding < 0) {
_bottomPadding = 0;
}
}
@override
void dispose() {
_debouncer.cancelDebounce();
focusNode.dispose();
_tabDoubleTapEvent?.cancel();
textController.removeListener(textControllerListener);
textController.dispose();
super.dispose();
}
Future<void> textControllerListener() async {
//query in local varialbe
final value = textController.text;
isSearchQueryEmpty = value.isEmpty;
//latest query in global variable
query = textController.text;
final List<SearchResult> allResults =
await getSearchResultsForQuery(context, value);
/*checking if query == value to make sure that the results are from the current query
and not from the previous query (race condition).*/
//checking if query == value to make sure that the latest query's result
//(allResults) is passed to updateResult. Due to race condition, the previous
//query's allResults could be passed to updateResult after the lastest query's
//allResults is passed.
if (mounted && query == value) {
final inheritedSearchResults = InheritedSearchResults.of(context);
inheritedSearchResults.updateResults(allResults);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return RepaintBoundary(
key: widgetKey,
child: Padding(
padding: EdgeInsets.only(bottom: _bottomPadding),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: colorScheme.backgroundBase,
child: Container(
height: 44,
color: colorScheme.fillFaint,
child: TextFormField(
controller: textController,
focusNode: focusNode,
style: Theme.of(context).textTheme.titleMedium,
// Below parameters are to disable auto-suggestion
enableSuggestions: false,
autocorrect: false,
// Above parameters are to disable auto-suggestion
decoration: InputDecoration(
// hintText: S.of(context).searchHintText,
hintText: "Search",
filled: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 10,
),
border: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
prefixIconConstraints: const BoxConstraints(
maxHeight: 44,
maxWidth: 44,
minHeight: 44,
minWidth: 44,
),
suffixIconConstraints: const BoxConstraints(
maxHeight: 44,
maxWidth: 44,
minHeight: 44,
minWidth: 44,
),
prefixIcon: Hero(
tag: "search_icon",
child: Icon(
Icons.search,
color: colorScheme.strokeFaint,
),
),
/*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when
setState is called when deboucncing is over and the spinner needs to be shown while debouncing */
suffixIcon: ValueListenableBuilder(
valueListenable: _debouncer.debounceActiveNotifier,
builder: (
BuildContext context,
bool isDebouncing,
Widget? child,
) {
return SearchSuffixIcon(
isDebouncing,
);
},
),
),
),
),
),
),
),
),
),
);
}
Future<List<SearchResult>> getSearchResultsForQuery(
BuildContext context,
String query,
@ -239,6 +254,9 @@ class _SearchWidgetState extends State<SearchWidget> {
final magicResults =
await _searchService.getMagicSearchResults(context, query);
allResults.addAll(magicResults);
final peopleResults = await _searchService.getPeopleSearchResults(query);
allResults.addAll(peopleResults);
} catch (e, s) {
_logger.severe("error during search", e, s);
}
@ -250,34 +268,3 @@ class _SearchWidgetState extends State<SearchWidget> {
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,
),
),
);
}
},
),
);
}
}

View file

@ -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",
),
);
},
),
],
),
),
);
}
}

View file

@ -5,6 +5,8 @@ import "package:photos/models/typedefs.dart";
class Debouncer {
final Duration _duration;
///in milliseconds
final ValueNotifier<bool> _debounceActiveNotifier = ValueNotifier(false);
/// If executionInterval is not null, then the debouncer will execute the

View file

@ -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<dynamic> showTextInputDialog(
BuildContext context, {
required String title,

View file

@ -14,6 +14,8 @@ class LocalSettings {
static const kCollectionSortPref = "collection_sort_pref";
static const kPhotoGridSize = "photo_grid_size";
static const kEnableMagicSearch = "enable_magic_search";
static const kRateUsShownCount = "rate_us_shown_count";
static const kRateUsPromptThreshold = 2;
late SharedPreferences _prefs;
@ -51,4 +53,20 @@ class LocalSettings {
Future<void> setShouldEnableMagicSearch(bool value) async {
await _prefs.setBool(kEnableMagicSearch, value);
}
int getRateUsShownCount() {
if (_prefs.containsKey(kRateUsShownCount)) {
return _prefs.getInt(kRateUsShownCount)!;
} else {
return 0;
}
}
Future<void> setRateUsShownCount(int value) async {
await _prefs.setInt(kRateUsShownCount, value);
}
bool shouldPromptToRateUs() {
return getRateUsShownCount() < kRateUsPromptThreshold;
}
}

View file

@ -475,6 +475,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:

View file

@ -55,6 +55,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
@ -151,7 +152,7 @@ dependencies:
git:
url: https://github.com/ente-io/packages.git
ref: android_video_roation_fix
path: packages/video_player/video_player/p
path: packages/video_player/video_player/
video_thumbnail: ^0.5.3
visibility_detector: ^0.3.3
wakelock_plus: ^1.1.1