diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6ec1d0f4..7b0601e4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.7.3' + flutter-version: '3.7.7' # Fetch sub modules - run: git submodule update --init --recursive diff --git a/CHANGELOG.md b/CHANGELOG.md index ea66763d3..9184f1d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -## Unreleased +## v0.7.71 ### Added * #### Map View ✨ @@ -16,10 +16,12 @@ ### Improvements +* **Translations**: Add support for German language * This release contains massive improvements to how smoothly our gallery scrolls. More improvements are on the way! + ## 0.7.62 ### Added diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 90c866455..4672bed21 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -111,4 +111,5 @@ tools:ignore="ScopedStorage" /> + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/notification_icon.png b/android/app/src/main/res/drawable/notification_icon.png index 05f1fc8c3..dbaecfac8 100644 Binary files a/android/app/src/main/res/drawable/notification_icon.png and b/android/app/src/main/res/drawable/notification_icon.png differ diff --git a/fastlane/metadata/android/fr/short_description.txt b/fastlane/metadata/android/fr/short_description.txt new file mode 100644 index 000000000..0cac8bdcf --- /dev/null +++ b/fastlane/metadata/android/fr/short_description.txt @@ -0,0 +1 @@ +ente est une application de stockage de photos chiffrées de bout en bout \ No newline at end of file diff --git a/fastlane/metadata/ios/fr/keywords.txt b/fastlane/metadata/ios/fr/keywords.txt new file mode 100644 index 000000000..ff9d3e4f0 --- /dev/null +++ b/fastlane/metadata/ios/fr/keywords.txt @@ -0,0 +1 @@ +photos,photographie,famille,vie privée,cloud,sauvegarde,vidéos,photo,chiffrement,stockage,album,alternative diff --git a/fastlane/metadata/playstore/es/short_description.txt b/fastlane/metadata/playstore/es/short_description.txt index 3e101c447..f21708284 100644 --- a/fastlane/metadata/playstore/es/short_description.txt +++ b/fastlane/metadata/playstore/es/short_description.txt @@ -1 +1 @@ -Almacenamiento de fotos encriptado - copias de seguridad, organiza y comprte tus fotos y vídeos \ No newline at end of file +Almacenamiento de fotos encriptadas: copia de seguridad y comparte tus fotos \ No newline at end of file diff --git a/fastlane/metadata/playstore/fr/short_description.txt b/fastlane/metadata/playstore/fr/short_description.txt new file mode 100644 index 000000000..0d99dbcc9 --- /dev/null +++ b/fastlane/metadata/playstore/fr/short_description.txt @@ -0,0 +1 @@ +Stockage de photos chiffrées - sauvegardez, organisez et partagez vos photos et vidéos \ No newline at end of file diff --git a/fastlane/metadata/playstore/it/short_description.txt b/fastlane/metadata/playstore/it/short_description.txt index 32e83bd46..841ec3190 100644 --- a/fastlane/metadata/playstore/it/short_description.txt +++ b/fastlane/metadata/playstore/it/short_description.txt @@ -1 +1 @@ -Archiviazione foto e video crittografata - backup, organizza e condividi album fotografici \ No newline at end of file +Archiviazione foto/video criptata - backup, organizza, condividi album \ No newline at end of file diff --git a/fastlane/metadata/playstore/ru/short_description.txt b/fastlane/metadata/playstore/ru/short_description.txt index 201bf7524..d64bddbec 100644 --- a/fastlane/metadata/playstore/ru/short_description.txt +++ b/fastlane/metadata/playstore/ru/short_description.txt @@ -1 +1 @@ -Зашифрованное хранилище фото - совершайте резервное копирование и делитесь вашими фото и видео \ No newline at end of file +Зашифрованное хранилище фотографий для резервного копирования и обмена \ No newline at end of file diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 78e65dbde..07cf964c9 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,6 +10,10 @@ import Flutter var flutter_native_splash = 1 UIApplication.shared.isStatusBarHidden = false + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate + } + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index b15025c4b..ee7ec4cd7 100644 --- a/lib/db/files_db.dart +++ b/lib/db/files_db.dart @@ -50,6 +50,7 @@ class FilesDB { static const columnCreationTime = 'creation_time'; static const columnModificationTime = 'modification_time'; static const columnUpdationTime = 'updation_time'; + static const columnAddedTime = 'added_time'; static const columnEncryptedKey = 'encrypted_key'; static const columnKeyDecryptionNonce = 'key_decryption_nonce'; static const columnFileDecryptionHeader = 'file_decryption_header'; @@ -82,6 +83,7 @@ class FilesDB { ...addFileSizeColumn(), ...updateIndexes(), ...createEntityDataTable(), + ...addAddedTime(), ]; final dbConfig = MigrationConfig( @@ -367,6 +369,17 @@ class FilesDB { ]; } + static List addAddedTime() { + return [ + ''' + ALTER TABLE $filesTable ADD COLUMN $columnAddedTime INTEGER NOT NULL DEFAULT -1; + ''', + ''' + CREATE INDEX IF NOT EXISTS added_time_index ON $filesTable($columnAddedTime); + ''' + ]; + } + Future clearTable() async { final db = await instance.database; await db.delete(filesTable); @@ -627,6 +640,23 @@ class FilesDB { return files; } + Future> getNewFilesInCollection( + int collectionID, + int addedTime, + ) async { + final db = await instance.database; + const String whereClause = + '$columnCollectionID = ? AND $columnAddedTime > ?'; + final List whereArgs = [collectionID, addedTime]; + final results = await db.query( + filesTable, + where: whereClause, + whereArgs: whereArgs, + ); + final files = convertToFiles(results); + return files; + } + Future getFilesInCollections( List collectionIDs, int startTime, @@ -1507,6 +1537,8 @@ class FilesDB { row[columnCreationTime] = file.creationTime; row[columnModificationTime] = file.modificationTime; row[columnUpdationTime] = file.updationTime; + row[columnAddedTime] = + file.addedTime ?? DateTime.now().microsecondsSinceEpoch; row[columnEncryptedKey] = file.encryptedKey; row[columnKeyDecryptionNonce] = file.keyDecryptionNonce; row[columnFileDecryptionHeader] = file.fileDecryptionHeader; @@ -1552,6 +1584,8 @@ class FilesDB { row[columnCreationTime] = file.creationTime; row[columnModificationTime] = file.modificationTime; row[columnUpdationTime] = file.updationTime; + row[columnAddedTime] = + file.addedTime ?? DateTime.now().microsecondsSinceEpoch; row[columnFileDecryptionHeader] = file.fileDecryptionHeader; row[columnThumbnailDecryptionHeader] = file.thumbnailDecryptionHeader; row[columnMetadataDecryptionHeader] = file.metadataDecryptionHeader; @@ -1597,6 +1631,7 @@ class FilesDB { file.creationTime = row[columnCreationTime]; file.modificationTime = row[columnModificationTime]; file.updationTime = row[columnUpdationTime] ?? -1; + file.addedTime = row[columnAddedTime]; file.encryptedKey = row[columnEncryptedKey]; file.keyDecryptionNonce = row[columnKeyDecryptionNonce]; file.fileDecryptionHeader = row[columnFileDecryptionHeader]; diff --git a/lib/events/collection_meta_event.dart b/lib/events/collection_meta_event.dart index d0fcb7d71..ac5288f74 100644 --- a/lib/events/collection_meta_event.dart +++ b/lib/events/collection_meta_event.dart @@ -12,5 +12,6 @@ enum CollectionMetaEventType { deleted, archived, sortChanged, + orderChanged, thumbnailChanged, } diff --git a/lib/generated/intl/messages_de.dart b/lib/generated/intl/messages_de.dart index 2e8ef1133..ebaed858c 100644 --- a/lib/generated/intl/messages_de.dart +++ b/lib/generated/intl/messages_de.dart @@ -21,7 +21,7 @@ class MessageLookup extends MessageLookupByLibrary { String get localeName => 'de'; static String m0(count) => - "${Intl.plural(count, one: 'Füge ein Element hinzu', other: 'Füge # Elemente hinzu')}"; + "${Intl.plural(count, one: 'Element hinzufügen', other: 'Elemente hinzufügen')}"; static String m1(emailOrName) => "Von ${emailOrName} hinzugefügt"; @@ -108,7 +108,7 @@ class MessageLookup extends MessageLookupByLibrary { "${Intl.plural(count, zero: 'keine Erinnerungsstücke', one: '${formattedCount} Erinnerung', other: '${formattedCount} Erinnerungsstücke')}"; static String m30(count) => - "${Intl.plural(count, one: '# Element', other: '# Elemente')}"; + "${Intl.plural(count, one: 'Element verschieben', other: 'Elemente verschieben')}"; static String m31(albumName) => "Erfolgreich zu ${albumName} hinzugefügt"; @@ -229,7 +229,7 @@ class MessageLookup extends MessageLookupByLibrary { "advanced": MessageLookupByLibrary.simpleMessage("Erweitert"), "advancedSettings": MessageLookupByLibrary.simpleMessage("Erweitert"), "after1Day": MessageLookupByLibrary.simpleMessage("Nach einem Tag"), - "after1Hour": MessageLookupByLibrary.simpleMessage("Nach 1. Stunde"), + "after1Hour": MessageLookupByLibrary.simpleMessage("Nach 1 Stunde"), "after1Month": MessageLookupByLibrary.simpleMessage("Nach 1 Monat"), "after1Week": MessageLookupByLibrary.simpleMessage("Nach 1 Woche"), "after1Year": MessageLookupByLibrary.simpleMessage("Nach 1 Jahr"), diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 80e128b95..88834c00d 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -53,6 +53,9 @@ class MessageLookup extends MessageLookupByLibrary { static String m10(provider) => "Please contact us at support@ente.io to manage your ${provider} subscription."; + static String m63(count) => + "${Intl.plural(count, one: 'Delete ${count} item', other: 'Delete ${count} items')}"; + static String m11(currentlyDeleting, totalCount) => "Deleting ${currentlyDeleting} / ${totalCount}"; @@ -65,6 +68,9 @@ class MessageLookup extends MessageLookupByLibrary { static String m14(count, storageSaved) => "Your have cleaned up ${Intl.plural(count, one: '${count} duplicate file', other: '${count} duplicate files')}, saving (${storageSaved}!)"; + static String m64(count, formattedSize) => + "${count} files, ${formattedSize} each"; + static String m15(newEmail) => "Email changed to ${newEmail}"; static String m16(email) => @@ -119,6 +125,9 @@ class MessageLookup extends MessageLookupByLibrary { static String m34(reason) => "Unfortunately your payment failed due to ${reason}"; + static String m65(endDate) => + "Free trial valid till ${endDate}.\nYou can choose a paid plan afterwards."; + static String m35(toEmail) => "Please email us at ${toEmail}"; static String m36(toEmail) => "Please send the logs to \n${toEmail}"; @@ -484,6 +493,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Delete from device"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("Delete from ente"), + "deleteItemCount": m63, "deleteLocation": MessageLookupByLibrary.simpleMessage("Delete location"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Delete photos"), @@ -541,6 +551,7 @@ class MessageLookup extends MessageLookupByLibrary { "downloading": MessageLookupByLibrary.simpleMessage("Downloading..."), "dropSupportEmail": m13, "duplicateFileCountWithStorageSaved": m14, + "duplicateItemsGroup": m64, "edit": MessageLookupByLibrary.simpleMessage("Edit"), "editLocationTagTitle": MessageLookupByLibrary.simpleMessage("Edit location"), @@ -566,6 +577,8 @@ class MessageLookup extends MessageLookupByLibrary { "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": MessageLookupByLibrary.simpleMessage( "ente can encrypt and preserve files only if you grant access to them"), + "entePhotosPerm": MessageLookupByLibrary.simpleMessage( + "ente needs permission to preserve your photos"), "enteSubscriptionPitch": MessageLookupByLibrary.simpleMessage( "ente preserves your memories, so they\'re always available to you, even if you lose your device."), "enteSubscriptionShareWithFamily": MessageLookupByLibrary.simpleMessage( @@ -830,6 +843,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("No results found"), "nothingToSeeHere": MessageLookupByLibrary.simpleMessage("Nothing to see here! 👀"), + "notifications": MessageLookupByLibrary.simpleMessage("Notifications"), "ok": MessageLookupByLibrary.simpleMessage("Ok"), "onDevice": MessageLookupByLibrary.simpleMessage("On device"), "onEnte": MessageLookupByLibrary.simpleMessage( @@ -875,6 +889,8 @@ class MessageLookup extends MessageLookupByLibrary { "Photos added by you will be removed from the album"), "pickCenterPoint": MessageLookupByLibrary.simpleMessage("Pick center point"), + "pinAlbum": MessageLookupByLibrary.simpleMessage("Pin album"), + "playStoreFreeTrialValidTill": m65, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("PlayStore subscription"), "pleaseContactSupportAndWeWillBeHappyToHelp": @@ -1085,6 +1101,10 @@ class MessageLookup extends MessageLookupByLibrary { "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( "Create shared and collaborative albums with other ente users, including users on free plans."), "sharedByMe": MessageLookupByLibrary.simpleMessage("Shared by me"), + "sharedPhotoNotifications": + MessageLookupByLibrary.simpleMessage("New shared photos"), + "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( + "Receive notifications when someone adds a photo to a shared album that you\'re a part of"), "sharedWith": m47, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Shared with me"), "sharing": MessageLookupByLibrary.simpleMessage("Sharing..."), @@ -1236,6 +1256,7 @@ class MessageLookup extends MessageLookupByLibrary { "unhidingFilesToAlbum": MessageLookupByLibrary.simpleMessage("Unhiding files to album"), "unlock": MessageLookupByLibrary.simpleMessage("Unlock"), + "unpinAlbum": MessageLookupByLibrary.simpleMessage("Unpin album"), "unselectAll": MessageLookupByLibrary.simpleMessage("Unselect all"), "update": MessageLookupByLibrary.simpleMessage("Update"), "updateAvailable": diff --git a/lib/generated/intl/messages_it.dart b/lib/generated/intl/messages_it.dart index 6397c3ed2..37764e305 100644 --- a/lib/generated/intl/messages_it.dart +++ b/lib/generated/intl/messages_it.dart @@ -54,6 +54,9 @@ class MessageLookup extends MessageLookupByLibrary { static String m10(provider) => "Scrivi all\'indirizzo support@ente.io per gestire il tuo abbonamento ${provider}."; + static String m63(count) => + "${Intl.plural(count, one: 'Elimina ${count} elemento', other: 'Elimina ${count} elementi')}"; + static String m11(currentlyDeleting, totalCount) => "Eliminazione di ${currentlyDeleting} / ${totalCount}"; @@ -66,6 +69,9 @@ class MessageLookup extends MessageLookupByLibrary { static String m14(count, storageSaved) => "Hai ripulito ${Intl.plural(count, one: '${count} doppione', other: '${count} doppioni')}, salvando (${storageSaved}!)"; + static String m64(count, formattedSize) => + "${count} file, ${formattedSize} l\'uno"; + static String m15(newEmail) => "Email cambiata in ${newEmail}"; static String m16(email) => @@ -120,6 +126,9 @@ class MessageLookup extends MessageLookupByLibrary { static String m34(reason) => "Purtroppo il tuo pagamento non è riuscito a causa di ${reason}"; + static String m65(endDate) => + "Prova gratuita valida fino al ${endDate}.\nPuoi scegliere un piano a pagamento in seguito."; + static String m35(toEmail) => "Per favore invia un\'email a ${toEmail}"; static String m36(toEmail) => "Invia i log a \n${toEmail}"; @@ -494,6 +503,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Elimina dal dispositivo"), "deleteFromEnte": MessageLookupByLibrary.simpleMessage("Elimina da ente"), + "deleteItemCount": m63, "deleteLocation": MessageLookupByLibrary.simpleMessage("Elimina posizione"), "deletePhotos": MessageLookupByLibrary.simpleMessage("Elimina foto"), @@ -553,6 +563,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Scaricamento in corso..."), "dropSupportEmail": m13, "duplicateFileCountWithStorageSaved": m14, + "duplicateItemsGroup": m64, "edit": MessageLookupByLibrary.simpleMessage("Modifica"), "editLocationTagTitle": MessageLookupByLibrary.simpleMessage("Modifica luogo"), @@ -566,6 +577,9 @@ class MessageLookup extends MessageLookupByLibrary { "empty": MessageLookupByLibrary.simpleMessage("Vuoto"), "emptyTrash": MessageLookupByLibrary.simpleMessage("Vuoi svuotare il cestino?"), + "enableMaps": MessageLookupByLibrary.simpleMessage("Abilita le Mappe"), + "enableMapsDesc": MessageLookupByLibrary.simpleMessage( + "Questo mostrerà le tue foto su una mappa del mondo.\n\nQuesta mappa è ospitata da Open Street Map e le posizioni esatte delle tue foto non sono mai condivise.\n\nPuoi disabilitare questa funzionalità in qualsiasi momento, dalle Impostazioni."), "encryptingBackup": MessageLookupByLibrary.simpleMessage("Crittografando il backup..."), "encryption": MessageLookupByLibrary.simpleMessage("Crittografia"), @@ -576,6 +590,8 @@ class MessageLookup extends MessageLookupByLibrary { "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": MessageLookupByLibrary.simpleMessage( "ente può criptare e preservare i file solo se concedi l\'accesso alle foto e ai video"), + "entePhotosPerm": MessageLookupByLibrary.simpleMessage( + "ente necessita del permesso per preservare le tue foto"), "enteSubscriptionPitch": MessageLookupByLibrary.simpleMessage( "ente conserva i tuoi ricordi, in modo che siano sempre a disposizione, anche se perdi il dispositivo."), "enteSubscriptionShareWithFamily": MessageLookupByLibrary.simpleMessage( @@ -680,6 +696,8 @@ class MessageLookup extends MessageLookupByLibrary { "Raggruppa foto nelle vicinanze"), "hidden": MessageLookupByLibrary.simpleMessage("Nascosti"), "hide": MessageLookupByLibrary.simpleMessage("Nascondi"), + "hostedAtOsmFrance": + MessageLookupByLibrary.simpleMessage("Ospitato presso OSM France"), "howItWorks": MessageLookupByLibrary.simpleMessage("Come funziona"), "howToViewShareeVerificationID": MessageLookupByLibrary.simpleMessage( "Chiedi di premere a lungo il loro indirizzo email nella schermata delle impostazioni e verificare che gli ID su entrambi i dispositivi corrispondano."), @@ -809,6 +827,8 @@ class MessageLookup extends MessageLookupByLibrary { "manageParticipants": MessageLookupByLibrary.simpleMessage("Gestisci"), "manageSubscription": MessageLookupByLibrary.simpleMessage("Gestisci abbonamento"), + "map": MessageLookupByLibrary.simpleMessage("Mappa"), + "maps": MessageLookupByLibrary.simpleMessage("Mappe"), "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"), "matrix": MessageLookupByLibrary.simpleMessage("Matrix"), "maxDeviceLimitSpikeHandling": m28, @@ -851,6 +871,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Nessun risultato trovato"), "nothingToSeeHere": MessageLookupByLibrary.simpleMessage("Nulla da vedere qui! 👀"), + "notifications": MessageLookupByLibrary.simpleMessage("Notifiche"), "ok": MessageLookupByLibrary.simpleMessage("Ok"), "onDevice": MessageLookupByLibrary.simpleMessage("Sul dispositivo"), "onEnte": MessageLookupByLibrary.simpleMessage( @@ -862,6 +883,8 @@ class MessageLookup extends MessageLookupByLibrary { "Oops! Qualcosa è andato storto"), "openTheItem": MessageLookupByLibrary.simpleMessage("• Apri la foto o il video"), + "openstreetmapContributors": MessageLookupByLibrary.simpleMessage( + "Collaboratori di OpenStreetMap"), "optionalAsShortAsYouLike": MessageLookupByLibrary.simpleMessage( "Facoltativo, breve quanto vuoi..."), "orPickAnExistingOne": MessageLookupByLibrary.simpleMessage( @@ -898,6 +921,8 @@ class MessageLookup extends MessageLookupByLibrary { "Le foto aggiunte da te verranno rimosse dall\'album"), "pickCenterPoint": MessageLookupByLibrary.simpleMessage( "Selezionare il punto centrale"), + "pinAlbum": MessageLookupByLibrary.simpleMessage("Fissa l\'album"), + "playStoreFreeTrialValidTill": m65, "playstoreSubscription": MessageLookupByLibrary.simpleMessage("Abbonamento su PlayStore"), "pleaseContactSupportAndWeWillBeHappyToHelp": @@ -1079,6 +1104,7 @@ class MessageLookup extends MessageLookupByLibrary { "setAPassword": MessageLookupByLibrary.simpleMessage("Imposta una password"), "setAs": MessageLookupByLibrary.simpleMessage("Imposta come"), + "setCover": MessageLookupByLibrary.simpleMessage("Imposta copertina"), "setLabel": MessageLookupByLibrary.simpleMessage("Imposta"), "setPasswordTitle": MessageLookupByLibrary.simpleMessage("Imposta password"), @@ -1107,6 +1133,10 @@ class MessageLookup extends MessageLookupByLibrary { "sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage( "Crea album condivisi e collaborativi con altri utenti ente, inclusi utenti su piani gratuiti."), "sharedByMe": MessageLookupByLibrary.simpleMessage("Condiviso da me"), + "sharedPhotoNotifications": + MessageLookupByLibrary.simpleMessage("Nuove foto condivise"), + "sharedPhotoNotificationsExplanation": MessageLookupByLibrary.simpleMessage( + "Ricevi notifiche quando qualcuno aggiunge una foto a un album condiviso, di cui fai parte"), "sharedWith": m47, "sharedWithMe": MessageLookupByLibrary.simpleMessage("Condivisi con me"), @@ -1272,6 +1302,7 @@ class MessageLookup extends MessageLookupByLibrary { "unhidingFilesToAlbum": MessageLookupByLibrary.simpleMessage("Mostra i file nell\'album"), "unlock": MessageLookupByLibrary.simpleMessage("Sblocca"), + "unpinAlbum": MessageLookupByLibrary.simpleMessage("Non fissare album"), "unselectAll": MessageLookupByLibrary.simpleMessage("Deseleziona tutto"), "update": MessageLookupByLibrary.simpleMessage("Aggiorna"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index c3e9ceb1b..85a50eb2a 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -300,6 +300,16 @@ class S { ); } + /// `ente needs permission to preserve your photos` + String get entePhotosPerm { + return Intl.message( + 'ente needs permission to preserve your photos', + name: 'entePhotosPerm', + desc: '', + args: [], + ); + } + /// `Ok` String get ok { return Intl.message( @@ -2908,6 +2918,28 @@ class S { ); } + /// `{count, plural, =1 {Delete {count} item} other {Delete {count} items}}` + String deleteItemCount(num count) { + return Intl.plural( + count, + one: 'Delete $count item', + other: 'Delete $count items', + name: 'deleteItemCount', + desc: '', + args: [count], + ); + } + + /// `{count} files, {formattedSize} each` + String duplicateItemsGroup(int count, String formattedSize) { + return Intl.message( + '$count files, $formattedSize each', + name: 'duplicateItemsGroup', + desc: 'Display the number of duplicate files and their size', + args: [count, formattedSize], + ); + } + /// `{count, plural, one{{count} year ago} other{{count} years ago}}` String yearsAgo(num count) { return Intl.plural( @@ -3402,6 +3434,36 @@ class S { ); } + /// `Notifications` + String get notifications { + return Intl.message( + 'Notifications', + name: 'notifications', + desc: '', + args: [], + ); + } + + /// `New shared photos` + String get sharedPhotoNotifications { + return Intl.message( + 'New shared photos', + name: 'sharedPhotoNotifications', + desc: '', + args: [], + ); + } + + /// `Receive notifications when someone adds a photo to a shared album that you're a part of` + String get sharedPhotoNotificationsExplanation { + return Intl.message( + 'Receive notifications when someone adds a photo to a shared album that you\'re a part of', + name: 'sharedPhotoNotificationsExplanation', + desc: '', + args: [], + ); + } + /// `Advanced` String get advanced { return Intl.message( @@ -3812,6 +3874,16 @@ class S { ); } + /// `Free trial valid till {endDate}.\nYou can choose a paid plan afterwards.` + String playStoreFreeTrialValidTill(Object endDate) { + return Intl.message( + 'Free trial valid till $endDate.\nYou can choose a paid plan afterwards.', + name: 'playStoreFreeTrialValidTill', + desc: '', + args: [endDate], + ); + } + /// `Your subscription will be cancelled on {endDate}` String subWillBeCancelledOn(Object endDate) { return Intl.message( @@ -7342,6 +7414,26 @@ class S { args: [], ); } + + /// `Unpin album` + String get unpinAlbum { + return Intl.message( + 'Unpin album', + name: 'unpinAlbum', + desc: '', + args: [], + ); + } + + /// `Pin album` + String get pinAlbum { + return Intl.message( + 'Pin album', + name: 'pinAlbum', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 2dff2202e..04faba6e5 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -213,7 +213,7 @@ "@custom": { "description": "Label for setting custom value for link expiry" }, - "after1Hour": "Nach 1. Stunde", + "after1Hour": "Nach 1 Stunde", "after1Day": "Nach einem Tag", "after1Week": "Nach 1 Woche", "after1Month": "Nach 1 Monat", @@ -708,11 +708,11 @@ "share": "Teilen", "unhideToAlbum": "Im Album anzeigen", "restoreToAlbum": "Album wiederherstellen", - "moveItem": "{count, plural, one{# Element} other{# Elemente}}", + "moveItem": "{count, plural, one{Element verschieben} other{Elemente verschieben}}", "@moveItem": { "description": "Page title while moving one or more items to an album" }, - "addItem": "{count, plural, one {Füge ein Element hinzu} other {Füge # Elemente hinzu}}", + "addItem": "{count, plural, one {Element hinzufügen} other {Elemente hinzufügen}}", "@addItem": { "description": "Page title while adding one or more items to album" }, diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8f0cde6ab..e50301ca2 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -24,6 +24,7 @@ "sendEmail": "Send email", "deleteRequestSLAText": "Your request will be processed within 72 hours.", "deleteEmailRequest": "Please send an email to account-deletion@ente.io from your registered email address.", + "entePhotosPerm": "ente needs permission to preserve your photos", "ok": "Ok", "createAccount": "Create account", "createNewAccount": "Create new account", @@ -417,6 +418,22 @@ "skip": "Skip", "updatingFolderSelection": "Updating folder selection...", "itemCount": "{count, plural, one{{count} item} other{{count} items}}", + "deleteItemCount": "{count, plural, =1 {Delete {count} item} other {Delete {count} items}}", + "duplicateItemsGroup": "{count} files, {formattedSize} each", + "@duplicateItemsGroup" : { + "description": "Display the number of duplicate files and their size", + "type": "text", + "placeholders": { + "count": { + "example": "12", + "type": "int" + }, + "formattedSize": { + "example": "2.3 MB", + "type": "String" + } + } + }, "yearsAgo": "{count, plural, one{{count} year ago} other{{count} years ago}}", "backupSettings": "Backup settings", "backupOverMobileData": "Backup over mobile data", @@ -490,6 +507,9 @@ }, "familyPlans": "Family plans", "referrals": "Referrals", + "notifications": "Notifications", + "sharedPhotoNotifications": "New shared photos", + "sharedPhotoNotificationsExplanation": "Receive notifications when someone adds a photo to a shared album that you're a part of", "advanced": "Advanced", "general": "General", "security": "Security", @@ -538,6 +558,7 @@ "faqs": "FAQs", "renewsOn": "Renews on {endDate}", "freeTrialValidTill": "Free trial valid till {endDate}", + "playStoreFreeTrialValidTill": "Free trial valid till {endDate}.\nYou can choose a paid plan afterwards.", "subWillBeCancelledOn": "Your subscription will be cancelled on {endDate}", "subscription": "Subscription", "paymentDetails": "Payment details", @@ -641,7 +662,6 @@ "description": "Button text for raising a support tickets in case of unhandled errors during backup", "type": "text" }, - "backupFailed": "Backup failed", "couldNotBackUpTryLater": "We could not backup your data.\nWe will retry later.", "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "ente can encrypt and preserve files only if you grant access to them", @@ -753,7 +773,7 @@ }, "deleteAll": "Delete All", "renameAlbum": "Rename album", - "setCover" : "Set cover", + "setCover": "Set cover", "@setCover": { "description": "Text to set cover photo for an album" }, @@ -955,7 +975,7 @@ "save": "Save", "centerPoint": "Center point", "pickCenterPoint": "Pick center point", - "useSelectedPhoto": "Use selected photo", + "useSelectedPhoto": "Use selected photo", "edit": "Edit", "deleteLocation": "Delete location", "rotateLeft": "Rotate left", @@ -982,13 +1002,13 @@ "@storageBreakupYou": { "description": "Label to indicate how much storage you are using when you are part of a family plan" }, - "storageUsageInfo" : "{usedAmount} {usedStorageUnit} of {totalAmount} {totalStorageUnit} used", - "@storageUsageInfo" :{ + "storageUsageInfo": "{usedAmount} {usedStorageUnit} of {totalAmount} {totalStorageUnit} used", + "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace" : "{freeAmount} {storageUnit} free", + "freeStorageSpace": "{freeAmount} {storageUnit} free", "appVersion": "Version: {versionValue}", - "verifyIDLabel" : "Verify", + "verifyIDLabel": "Verify", "fileInfoAddDescHint": "Add a description...", "editLocationTagTitle": "Edit location", "setLabel": "Set", @@ -1063,6 +1083,7 @@ "selectItemsToAdd": "Select items to add", "addSelected": "Add selected", "addFromDevice": "Add from device", - "addPhotos": "Add photos" + "addPhotos": "Add photos", + "unpinAlbum": "Unpin album", + "pinAlbum": "Pin album" } - diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index aea737a5b..ef9ee7be3 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -24,6 +24,7 @@ "sendEmail": "Invia email", "deleteRequestSLAText": "La tua richiesta verrà elaborata entro 72 ore.", "deleteEmailRequest": "Invia un'email a account-deletion@ente.io dal tuo indirizzo email registrato.", + "entePhotosPerm": "ente necessita del permesso per preservare le tue foto", "ok": "Ok", "createAccount": "Crea account", "createNewAccount": "Crea un nuovo account", @@ -417,6 +418,22 @@ "skip": "Salta", "updatingFolderSelection": "Aggiornamento della selezione delle cartelle...", "itemCount": "{count, plural, one{{count} elemento} other{{count} elementi}}", + "deleteItemCount": "{count, plural, one {}=1 {Elimina {count} elemento} other {Elimina {count} elementi}}", + "duplicateItemsGroup": "{count} file, {formattedSize} l'uno", + "@duplicateItemsGroup": { + "description": "Display the number of duplicate files and their size", + "type": "text", + "placeholders": { + "count": { + "example": "12", + "type": "int" + }, + "formattedSize": { + "example": "2.3 MB", + "type": "String" + } + } + }, "yearsAgo": "{count, plural, one{{count} anno fa} other{{count} anni fa}}", "backupSettings": "Impostazioni backup", "backupOverMobileData": "Backup su dati mobili", @@ -490,6 +507,9 @@ }, "familyPlans": "Piano famiglia", "referrals": "Invita un Amico", + "notifications": "Notifiche", + "sharedPhotoNotifications": "Nuove foto condivise", + "sharedPhotoNotificationsExplanation": "Ricevi notifiche quando qualcuno aggiunge una foto a un album condiviso, di cui fai parte", "advanced": "Avanzate", "general": "Generali", "security": "Sicurezza", @@ -538,6 +558,7 @@ "faqs": "FAQ", "renewsOn": "Si rinnova il {endDate}", "freeTrialValidTill": "La prova gratuita termina il {endDate}", + "playStoreFreeTrialValidTill": "Prova gratuita valida fino al {endDate}.\nPuoi scegliere un piano a pagamento in seguito.", "subWillBeCancelledOn": "L'abbonamento verrà cancellato il {endDate}", "subscription": "Abbonamento", "paymentDetails": "Dettagli di Pagamento", @@ -752,6 +773,10 @@ }, "deleteAll": "Elimina tutto", "renameAlbum": "Rinomina album", + "setCover": "Imposta copertina", + "@setCover": { + "description": "Text to set cover photo for an album" + }, "sortAlbumsBy": "Ordina per", "sortNewestFirst": "Prima le più nuove", "sortOldestFirst": "Prima le più vecchie", @@ -1044,5 +1069,16 @@ "iOSOkButton": "OK", "@iOSOkButton": { "description": "Message showed on a button that the user can click to leave the current dialog. It is used on iOS side. Maximum 30 characters." - } + }, + "openstreetmapContributors": "Collaboratori di OpenStreetMap", + "hostedAtOsmFrance": "Ospitato presso OSM France", + "map": "Mappa", + "@map": { + "description": "Label for the map view" + }, + "maps": "Mappe", + "enableMaps": "Abilita le Mappe", + "enableMapsDesc": "Questo mostrerà le tue foto su una mappa del mondo.\n\nQuesta mappa è ospitata da Open Street Map e le posizioni esatte delle tue foto non sono mai condivise.\n\nPuoi disabilitare questa funzionalità in qualsiasi momento, dalle Impostazioni.", + "unpinAlbum": "Non fissare album", + "pinAlbum": "Fissa l'album" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 954bc8bd7..0b2e15a2d 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -12,6 +12,7 @@ extension AppLocalizationsX on BuildContext { const List appSupportedLocales = [ Locale('en'), Locale('es'), + Locale('de'), Locale('it'), Locale("nl"), Locale("zh", "CN"), diff --git a/lib/main.dart b/lib/main.dart index 91c05c562..1d0d5153d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,6 @@ import 'package:photos/services/local_file_update_service.dart'; import 'package:photos/services/local_sync_service.dart'; import "package:photos/services/location_service.dart"; import 'package:photos/services/memories_service.dart'; -import 'package:photos/services/notification_service.dart'; import "package:photos/services/object_detection/object_detection_service.dart"; import 'package:photos/services/push_service.dart'; import 'package:photos/services/remote_sync_service.dart'; @@ -149,6 +148,7 @@ Future _init(bool isBackground, {String via = ''}) async { final SharedPreferences preferences = await SharedPreferences.getInstance(); await _logFGHeartBeatInfo(); _scheduleHeartBeat(preferences, isBackground); + AppLifecycleService.instance.init(preferences); if (isBackground) { AppLifecycleService.instance.onAppInBackground('init via: $via'); } else { @@ -157,7 +157,6 @@ Future _init(bool isBackground, {String via = ''}) async { // Start workers asynchronously. No need to wait for them to start Computer.shared().turnOn(workersCount: 4, verbose: kDebugMode); CryptoUtil.init(); - await NotificationService.instance.init(); await NetworkClient.instance.init(); await Configuration.instance.init(); await UserService.instance.init(); diff --git a/lib/models/collection.dart b/lib/models/collection.dart index bd35f0701..a31049382 100644 --- a/lib/models/collection.dart +++ b/lib/models/collection.dart @@ -13,6 +13,7 @@ class Collection { final String? keyDecryptionNonce; @Deprecated("Use collectionName instead") String? name; + // encryptedName & nameDecryptionNonce will be null for collections // created before we started encrypting collection name final String? encryptedName; @@ -28,6 +29,7 @@ class Collection { // un-encrypted. decryptName will be value either decrypted value for // encryptedName or name itself. String? decryptedName; + // decryptedPath will be null for collections now owned by user, deleted // collections, && collections which don't have a path. The path is used // to map local on-device album on mobile to remote collection on ente. @@ -98,6 +100,8 @@ class Collection { // hasSharees returns true if the collection is shared with other ente users bool get hasSharees => sharees != null && sharees!.isNotEmpty; + bool get isPinned => (magicMetadata.order ?? 0) != 0; + bool isHidden() { if (isDefaultHidden()) { return true; diff --git a/lib/models/file.dart b/lib/models/file.dart index 96afb4aef..9ac2a5829 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -26,6 +26,7 @@ class File extends EnteFile { int? creationTime; int? modificationTime; int? updationTime; + int? addedTime; Location? location; late FileType fileType; int? fileSubType; diff --git a/lib/models/metadata/collection_magic.dart b/lib/models/metadata/collection_magic.dart index 94388dc2a..96152e6f9 100644 --- a/lib/models/metadata/collection_magic.dart +++ b/lib/models/metadata/collection_magic.dart @@ -11,6 +11,8 @@ const subTypeKey = 'subType'; const muteKey = "mute"; +const orderKey = "order"; + class CollectionMagicMetadata { // 0 -> visible // 1 -> archived @@ -22,13 +24,22 @@ class CollectionMagicMetadata { // 2 -> Collections created for sharing selected files int? subType; - CollectionMagicMetadata({required this.visibility, this.subType}); + /* order is initially just used for pinned collections. + Later it can be used for custom sort order for if needed. + Higher the value, higher the preference of the collection to show up first. + */ + int? order; + + CollectionMagicMetadata({required this.visibility, this.subType, this.order}); Map toJson() { final result = {magicKeyVisibility: visibility}; if (subType != null) { result[subTypeKey] = subType!; } + if (order != null) { + result[orderKey] = order!; + } return result; } @@ -43,6 +54,7 @@ class CollectionMagicMetadata { return CollectionMagicMetadata( visibility: map[magicKeyVisibility] ?? visibleVisibility, subType: map[subTypeKey], + order: map[orderKey], ); } } diff --git a/lib/models/selected_files.dart b/lib/models/selected_files.dart index bc60095a9..97991714d 100644 --- a/lib/models/selected_files.dart +++ b/lib/models/selected_files.dart @@ -71,4 +71,11 @@ class SelectedFiles extends ChangeNotifier { files.clear(); notifyListeners(); } + + /// Retains only the files that are present in the [images] set. Takes the + /// intersection of the two sets. + void filesToRetain(Set images) { + files.retainAll(images); + notifyListeners(); + } } diff --git a/lib/models/user_details.dart b/lib/models/user_details.dart index 03ba7158b..ab1ea4c99 100644 --- a/lib/models/user_details.dart +++ b/lib/models/user_details.dart @@ -43,14 +43,11 @@ class UserDetails { } int getFreeStorage() { - return max( - isPartOfFamily() - ? (familyData!.storage - familyData!.getTotalUsage()) - : (subscription.storage - (usage)), - 0, - ); + return max(getTotalStorage() - getFamilyOrPersonalUsage(), 0); } + // getTotalStorage will return total storage available including the + // storage bonus int getTotalStorage() { return (isPartOfFamily() ? familyData!.storage : subscription.storage) + storageBonus; diff --git a/lib/services/app_lifecycle_service.dart b/lib/services/app_lifecycle_service.dart index 127c704a7..e75174da7 100644 --- a/lib/services/app_lifecycle_service.dart +++ b/lib/services/app_lifecycle_service.dart @@ -1,18 +1,26 @@ import 'package:logging/logging.dart'; import 'package:media_extension/media_extension_action_types.dart'; +import "package:shared_preferences/shared_preferences.dart"; class AppLifecycleService { + static const String keyLastAppOpenTime = "last_app_open_time"; + final _logger = Logger("AppLifecycleService"); bool isForeground = false; MediaExtentionAction mediaExtensionAction = MediaExtentionAction(action: IntentAction.main); + late SharedPreferences _preferences; static final AppLifecycleService instance = AppLifecycleService._privateConstructor(); AppLifecycleService._privateConstructor(); + void init(SharedPreferences preferences) { + _preferences = preferences; + } + void setMediaExtensionAction(MediaExtentionAction mediaExtensionAction) { _logger.info("App invoked via ${mediaExtensionAction.action}"); this.mediaExtensionAction = mediaExtensionAction; @@ -25,6 +33,14 @@ class AppLifecycleService { void onAppInBackground(String reason) { _logger.info("App in background $reason"); + _preferences.setInt( + keyLastAppOpenTime, + DateTime.now().microsecondsSinceEpoch, + ); isForeground = false; } + + int getLastAppOpenTime() { + return _preferences.getInt(keyLastAppOpenTime) ?? 0; + } } diff --git a/lib/services/filter/collection_ignore.dart b/lib/services/filter/collection_ignore.dart index e21fc8a23..2320e097a 100644 --- a/lib/services/filter/collection_ignore.dart +++ b/lib/services/filter/collection_ignore.dart @@ -1,6 +1,9 @@ import "package:photos/models/file.dart"; import "package:photos/services/filter/filter.dart"; +// CollectionsIgnoreFilter will filter out files that are in present in the +// given collections. This is useful for filtering out files that are in archive +// or hidden collections from home page and other places class CollectionsIgnoreFilter extends Filter { final Set collectionIDs; @@ -24,7 +27,16 @@ class CollectionsIgnoreFilter extends Filter { @override bool filter(File file) { - return file.isUploaded && - !_ignoredUploadIDs!.contains(file.uploadedFileID!); + if (!file.isUploaded) { + // if file is in one of the ignored collections, filter it out. This check + // avoids showing un-uploaded files that are going to be uploaded to one of + // the ignored collections + if (file.collectionID != null && + collectionIDs.contains(file.collectionID!)) { + return false; + } + return true; + } + return !_ignoredUploadIDs!.contains(file.uploadedFileID!); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 5ad7cd5bb..af1d67e3f 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,53 +1,123 @@ import 'dart:io'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import "package:photos/services/remote_sync_service.dart"; +import "package:shared_preferences/shared_preferences.dart"; class NotificationService { static final NotificationService instance = NotificationService._privateConstructor(); + static const String keyGrantedNotificationPermission = + "notification_permission_granted"; + static const String keyShouldShowNotificationsForSharedPhotos = + "notifications_enabled_shared_photos"; NotificationService._privateConstructor(); - final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = + + late SharedPreferences _preferences; + final FlutterLocalNotificationsPlugin _notificationsPlugin = FlutterLocalNotificationsPlugin(); - Future init() async { - if (!Platform.isAndroid) { - return; - } - const AndroidInitializationSettings initializationSettingsAndroid = - AndroidInitializationSettings('notification_icon'); + Future init( + void Function( + NotificationResponse notificationResponse, + ) + onNotificationTapped, + ) async { + _preferences = await SharedPreferences.getInstance(); + const androidSettings = AndroidInitializationSettings('notification_icon'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestSoundPermission: false, + requestBadgePermission: false, + requestCriticalPermission: false, + ); const InitializationSettings initializationSettings = InitializationSettings( - android: initializationSettingsAndroid, + android: androidSettings, + iOS: iosSettings, ); - await _flutterLocalNotificationsPlugin.initialize( + await _notificationsPlugin.initialize( initializationSettings, - onSelectNotification: selectNotification, + onDidReceiveNotificationResponse: onNotificationTapped, + ); + + final launchDetails = + await _notificationsPlugin.getNotificationAppLaunchDetails(); + if (launchDetails != null && + launchDetails.didNotificationLaunchApp && + launchDetails.notificationResponse != null) { + onNotificationTapped(launchDetails.notificationResponse!); + } + if (!hasGrantedPermissions() && + RemoteSyncService.instance.isFirstRemoteSyncDone()) { + await requestPermissions(); + } + } + + Future requestPermissions() async { + bool? result; + if (Platform.isIOS) { + result = await _notificationsPlugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions( + sound: true, + alert: true, + ); + } else { + result = await _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestPermission(); + } + if (result != null) { + _preferences.setBool(keyGrantedNotificationPermission, result); + } + } + + bool hasGrantedPermissions() { + final result = _preferences.getBool(keyGrantedNotificationPermission); + return result ?? false; + } + + bool shouldShowNotificationsForSharedPhotos() { + final result = + _preferences.getBool(keyShouldShowNotificationsForSharedPhotos); + return result ?? true; + } + + Future setShouldShowNotificationsForSharedPhotos(bool value) { + return _preferences.setBool( + keyShouldShowNotificationsForSharedPhotos, + value, ); } - Future selectNotification(String? payload) async {} - - Future showNotification(String title, String message) async { - if (!Platform.isAndroid) { - return; - } - const AndroidNotificationDetails androidPlatformChannelSpecifics = - AndroidNotificationDetails( - 'io.ente.photos', - 'ente', + Future showNotification( + String title, + String message, { + String channelID = "io.ente.photos", + String channelName = "ente", + String payload = "ente://home", + }) async { + final androidSpecs = AndroidNotificationDetails( + channelID, + channelName, channelDescription: 'ente alerts', importance: Importance.max, priority: Priority.high, showWhen: false, ); - const NotificationDetails platformChannelSpecifics = - NotificationDetails(android: androidPlatformChannelSpecifics); - await _flutterLocalNotificationsPlugin.show( - 0, + final iosSpecs = DarwinNotificationDetails(threadIdentifier: channelID); + final platformChannelSpecs = + NotificationDetails(android: androidSpecs, iOS: iosSpecs); + await _notificationsPlugin.show( + channelName.hashCode, title, message, - platformChannelSpecifics, + platformChannelSpecs, + payload: payload, ); } } diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 3a3f7f606..72572b5fb 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -26,6 +26,7 @@ import 'package:photos/services/collections_service.dart'; import "package:photos/services/feature_flag_service.dart"; import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/local_file_update_service.dart'; +import "package:photos/services/notification_service.dart"; import 'package:photos/services/sync_service.dart'; import 'package:photos/services/trash_sync_service.dart'; import 'package:photos/utils/diff_fetcher.dart'; @@ -170,7 +171,7 @@ class RemoteSyncService { _logger.info("Pulling remote diff"); final isFirstSync = !_collectionsService.hasSyncedCollections(); if (isFirstSync && !_isExistingSyncSilent) { - Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff)); + Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff)); } await _collectionsService.sync(); // check and reset user's collection syncTime in past for older clients @@ -183,13 +184,14 @@ class RemoteSyncService { await _markResetSyncTimeAsDone(); } - await _syncUpdatedCollections(); - unawaited(_localFileUpdateService.markUpdatedFilesForReUpload()); - } - - Future _syncUpdatedCollections() async { final idsToRemoteUpdationTimeMap = await _collectionsService.getCollectionIDsToBeSynced(); + await _syncUpdatedCollections(idsToRemoteUpdationTimeMap); + unawaited(_localFileUpdateService.markUpdatedFilesForReUpload()); + unawaited(_notifyNewFiles(idsToRemoteUpdationTimeMap.keys.toList())); + } + + Future _syncUpdatedCollections(final idsToRemoteUpdationTimeMap) async { for (final cid in idsToRemoteUpdationTimeMap.keys) { await _syncCollectionDiff( cid, @@ -621,7 +623,7 @@ class RemoteSyncService { ); } - /* _storeDiff maps each remoteDiff file to existing + /* _storeDiff maps each remoteFile to existing entries in files table. When match is found, it compares both file to perform relevant actions like [1] Clear local cache when required (Both Shared and Owned files) @@ -637,7 +639,7 @@ class RemoteSyncService { [Existing] ] */ - Future _storeDiff(List diff, int collectionID) async { + Future _storeDiff(List diff, int collectionID) async { int sharedFileNew = 0, sharedFileUpdated = 0, localUploadedFromDevice = 0, @@ -648,60 +650,60 @@ class RemoteSyncService { // this is required when same file is uploaded twice in the same // collection. Without this check, if both remote files are part of same // diff response, then we end up inserting one entry instead of two - // as we update the generatedID for remoteDiff to local file's genID + // as we update the generatedID for remoteFile to local file's genID final Set alreadyClaimedLocalFilesGenID = {}; final List toBeInserted = []; - for (File remoteDiff in diff) { + for (File remoteFile in diff) { // existingFile will be either set to existing collectionID+localID or // to the unclaimed aka not already linked to any uploaded file. File? existingFile; - if (remoteDiff.generatedID != null) { + if (remoteFile.generatedID != null) { // Case [1] Check and clear local cache when uploadedFile already exist // Note: Existing file can be null here if it's replaced by the time we // reach here - existingFile = await _db.getFile(remoteDiff.generatedID!); + existingFile = await _db.getFile(remoteFile.generatedID!); if (existingFile != null && - _shouldClearCache(remoteDiff, existingFile)) { + _shouldClearCache(remoteFile, existingFile)) { needsGalleryReload = true; - await clearCache(remoteDiff); + await clearCache(remoteFile); } } /* If file is not owned by the user, no further processing is required as Case [2,3,4] are only relevant to files owned by user */ - if (userID != remoteDiff.ownerID) { + if (userID != remoteFile.ownerID) { if (existingFile == null) { sharedFileNew++; - remoteDiff.localID = null; + remoteFile.localID = null; } else { sharedFileUpdated++; // if user has downloaded the file on the device, avoid removing the // localID reference. // [Todo-fix: Excluded shared file's localIDs during syncALL] - remoteDiff.localID = existingFile.localID; + remoteFile.localID = existingFile.localID; } - toBeInserted.add(remoteDiff); + toBeInserted.add(remoteFile); // end processing for file here, move to next file now continue; } - // If remoteDiff is not already synced (i.e. existingFile is null), check + // If remoteFile is not already synced (i.e. existingFile is null), check // if the remoteFile was uploaded from this device. // Note: DeviceFolder is ignored for iOS during matching - if (existingFile == null && remoteDiff.localID != null) { + if (existingFile == null && remoteFile.localID != null) { final localFileEntries = await _db.getUnlinkedLocalMatchesForRemoteFile( userID, - remoteDiff.localID!, - remoteDiff.fileType, - title: remoteDiff.title ?? '', - deviceFolder: remoteDiff.deviceFolder ?? '', + remoteFile.localID!, + remoteFile.fileType, + title: remoteFile.title ?? '', + deviceFolder: remoteFile.deviceFolder ?? '', ); if (localFileEntries.isEmpty) { // set remote file's localID as null because corresponding local file // does not exist [Case 2, do not retain localID of the remote file] - remoteDiff.localID = null; + remoteFile.localID = null; } else { // case 4: Check and schedule the file for update final int maxModificationTime = localFileEntries @@ -717,11 +719,11 @@ class RemoteSyncService { for the adjustments or just if the asset has been modified ever. https://stackoverflow.com/a/50093266/546896 */ - if (maxModificationTime > remoteDiff.modificationTime! && + if (maxModificationTime > remoteFile.modificationTime! && Platform.isAndroid) { localButUpdatedOnDevice++; await FileUpdationDB.instance.insertMultiple( - [remoteDiff.localID!], + [remoteFile.localID!], FileUpdationDB.modificationTimeUpdated, ); } @@ -738,17 +740,17 @@ class RemoteSyncService { existingFile = localFileEntries.first; localUploadedFromDevice++; alreadyClaimedLocalFilesGenID.add(existingFile.generatedID!); - remoteDiff.generatedID = existingFile.generatedID; + remoteFile.generatedID = existingFile.generatedID; } } } if (existingFile != null && - _shouldReloadHomeGallery(remoteDiff, existingFile)) { + _shouldReloadHomeGallery(remoteFile, existingFile)) { needsGalleryReload = true; } else { remoteNewFile++; } - toBeInserted.add(remoteDiff); + toBeInserted.add(remoteFile); } await _db.insertMultiple(toBeInserted); _logger.info( @@ -850,4 +852,38 @@ class RemoteSyncService { } }); } + + bool _shouldShowNotification(int collectionID) { + // TODO: Add option to opt out of notifications for a specific collection + // Screen: https://www.figma.com/file/SYtMyLBs5SAOkTbfMMzhqt/ente-Visual-Design?type=design&node-id=7689-52943&t=IyWOfh0Gsb0p7yVC-4 + return NotificationService.instance + .shouldShowNotificationsForSharedPhotos() && + isFirstRemoteSyncDone() && + !AppLifecycleService.instance.isForeground; + } + + Future _notifyNewFiles(List collectionIDs) async { + final userID = Configuration.instance.getUserID(); + final appOpenTime = AppLifecycleService.instance.getLastAppOpenTime(); + for (final collectionID in collectionIDs) { + final collection = _collectionsService.getCollectionByID(collectionID); + final files = + await _db.getNewFilesInCollection(collectionID, appOpenTime); + final sharedFileCount = + files.where((file) => file.ownerID != userID).length; + final collectedFileCount = files + .where((file) => file.pubMagicMetadata!.uploaderName != null) + .length; + final totalCount = sharedFileCount + collectedFileCount; + if (totalCount > 0 && _shouldShowNotification(collectionID)) { + NotificationService.instance.showNotification( + collection!.displayName, + totalCount.toString() + " new 📸", + channelID: "collection:" + collectionID.toString(), + channelName: collection.displayName, + payload: "ente://collection/?collectionID=" + collectionID.toString(), + ); + } + } + } } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index fc67a2cba..0ffd9e348 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -128,6 +128,13 @@ class UpdateService { return _packageInfo.packageName.startsWith("io.ente.photos.fdroid"); } + bool isPlayStoreFlavor() { + if (Platform.isIOS) { + return false; + } + return !isIndependentFlavor() && !isFdroidFlavor(); + } + // getRateDetails returns details about the place Tuple2 getRateDetails() { if (isFdroidFlavor() || isIndependentFlavor()) { diff --git a/lib/ui/account/delete_account_page.dart b/lib/ui/account/delete_account_page.dart index 2fecd80a4..eb6f52127 100644 --- a/lib/ui/account/delete_account_page.dart +++ b/lib/ui/account/delete_account_page.dart @@ -42,6 +42,7 @@ class _DeleteAccountPageState extends State { Widget build(BuildContext context) { _defaultSelection = S.of(context).selectReason; _dropdownValue ??= _defaultSelection; + final double dropDownTextSize = MediaQuery.of(context).size.width - 120; final colorScheme = getEnteColorScheme(context); return Scaffold( @@ -90,11 +91,15 @@ class _DeleteAccountPageState extends State { value: value, enabled: value != _defaultSelection, alignment: Alignment.centerLeft, - child: Text( - value, - style: value != _defaultSelection - ? getEnteTextTheme(context).small - : getEnteTextTheme(context).smallMuted, + child: SizedBox( + width: dropDownTextSize, + child: Text( + value, + style: value != _defaultSelection + ? getEnteTextTheme(context).small + : getEnteTextTheme(context).smallMuted, + overflow: TextOverflow.visible, + ), ), ); }).toList(), diff --git a/lib/ui/collections/collection_action_sheet.dart b/lib/ui/collections/collection_action_sheet.dart index 2be33e9cc..3e9ee9063 100644 --- a/lib/ui/collections/collection_action_sheet.dart +++ b/lib/ui/collections/collection_action_sheet.dart @@ -253,18 +253,27 @@ class _CollectionActionSheetState extends State { // action can to be performed includeCollab: widget.actionType == CollectionActionType.addFiles, ); - collections.removeWhere( - (element) => (element.type == CollectionType.favorites || - element.type == CollectionType.uncategorized || - element.isSharedFilesCollection()), - ); collections.sort((first, second) { return compareAsciiLowerCaseNatural( first.displayName, second.displayName, ); }); - return collections; + final List pinned = []; + final List unpinned = []; + for (final collection in collections) { + if (collection.isSharedFilesCollection() || + collection.type == CollectionType.favorites || + collection.type == CollectionType.uncategorized) { + continue; + } + if (collection.isPinned) { + pinned.add(collection); + } else { + unpinned.add(collection); + } + } + return pinned + unpinned; } void _removeIncomingCollections(List items) { diff --git a/lib/ui/collections/device/device_folders_vertical_grid_view.dart b/lib/ui/collections/device/device_folders_vertical_grid_view.dart index af5609568..e9004888f 100644 --- a/lib/ui/collections/device/device_folders_vertical_grid_view.dart +++ b/lib/ui/collections/device/device_folders_vertical_grid_view.dart @@ -95,7 +95,7 @@ class _DeviceFolderVerticalGridViewState gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisItemCount, crossAxisSpacing: 16.0, - childAspectRatio: thumbnailSize / (thumbnailSize + 10), + childAspectRatio: thumbnailSize / (thumbnailSize + 22), ), ), ); diff --git a/lib/ui/components/bottom_action_bar/bottom_action_bar_widget.dart b/lib/ui/components/bottom_action_bar/bottom_action_bar_widget.dart index cff343f15..9667155c2 100644 --- a/lib/ui/components/bottom_action_bar/bottom_action_bar_widget.dart +++ b/lib/ui/components/bottom_action_bar/bottom_action_bar_widget.dart @@ -17,6 +17,7 @@ class BottomActionBarWidget extends StatelessWidget { final VoidCallback? onCancel; final bool hasSmallerBottomPadding; final GalleryType type; + final Color? backgroundColor; BottomActionBarWidget({ required this.expandedMenu, @@ -26,6 +27,7 @@ class BottomActionBarWidget extends StatelessWidget { this.text, this.iconButtons, this.onCancel, + this.backgroundColor, super.key, }); @@ -42,7 +44,7 @@ class BottomActionBarWidget extends StatelessWidget { : 0; return Container( decoration: BoxDecoration( - color: colorScheme.backgroundElevated, + color: backgroundColor ?? colorScheme.backgroundElevated2, boxShadow: shadowFloatFaintLight, ), padding: EdgeInsets.only( diff --git a/lib/ui/home/grant_permissions_widget.dart b/lib/ui/home/grant_permissions_widget.dart index 60eb82bff..a2fb4365c 100644 --- a/lib/ui/home/grant_permissions_widget.dart +++ b/lib/ui/home/grant_permissions_widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:photo_manager/photo_manager.dart'; import "package:photos/generated/l10n.dart"; import 'package:photos/services/sync_service.dart'; +import "package:styled_text/styled_text.dart"; class GrantPermissionsWidget extends StatelessWidget { const GrantPermissionsWidget({Key? key}) : super(key: key); @@ -49,24 +50,20 @@ class GrantPermissionsWidget extends StatelessWidget { const SizedBox(height: 36), Padding( padding: const EdgeInsets.fromLTRB(40, 0, 40, 0), - child: RichText( - text: TextSpan( - style: Theme.of(context) - .textTheme - .headlineSmall! - .copyWith(fontWeight: FontWeight.w700), - children: [ - const TextSpan(text: 'ente '), - TextSpan( - text: "needs permission to ", - style: Theme.of(context) - .textTheme - .headlineSmall! - .copyWith(fontWeight: FontWeight.w400), - ), - const TextSpan(text: 'preserve your photos'), - ], - ), + child: StyledText( + text: S.of(context).entePhotosPerm, + style: Theme.of(context) + .textTheme + .headlineSmall! + .copyWith(fontWeight: FontWeight.w700), + tags: { + 'i': StyledTextTag( + style: Theme.of(context) + .textTheme + .headlineSmall! + .copyWith(fontWeight: FontWeight.w400), + ), + }, ), ), ], diff --git a/lib/ui/home/home_gallery_widget.dart b/lib/ui/home/home_gallery_widget.dart index 81195ca48..ffe06f7a5 100644 --- a/lib/ui/home/home_gallery_widget.dart +++ b/lib/ui/home/home_gallery_widget.dart @@ -83,6 +83,7 @@ class HomeGalleryWidget extends StatelessWidget { scrollBottomSafeArea: bottomSafeArea + 180, ); return Stack( + alignment: Alignment.bottomCenter, children: [ gallery, FileSelectionOverlayBar(GalleryType.homepage, selectedFiles) diff --git a/lib/ui/home/landing_page_widget.dart b/lib/ui/home/landing_page_widget.dart index 58941083f..79267c796 100644 --- a/lib/ui/home/landing_page_widget.dart +++ b/lib/ui/home/landing_page_widget.dart @@ -293,11 +293,13 @@ class FeatureItemWidget extends StatelessWidget { Text( featureTitleFirstLine, style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, ), const Padding(padding: EdgeInsets.all(2)), Text( featureTitleSecondLine, style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, ), const Padding(padding: EdgeInsets.all(12)), Text( diff --git a/lib/ui/home/loading_photos_widget.dart b/lib/ui/home/loading_photos_widget.dart index a5aa25cc9..d07a7e99a 100644 --- a/lib/ui/home/loading_photos_widget.dart +++ b/lib/ui/home/loading_photos_widget.dart @@ -41,9 +41,9 @@ class _LoadingPhotosWidgetState extends State { } else { routeToPage( context, - const BackupFolderSelectionPage( + BackupFolderSelectionPage( isOnboarding: true, - buttonText: "Start backup", + buttonText: S.of(context).startBackup, ), ); } diff --git a/lib/ui/huge_listview/huge_listview.dart b/lib/ui/huge_listview/huge_listview.dart index 056039e1c..c4aba3af5 100644 --- a/lib/ui/huge_listview/huge_listview.dart +++ b/lib/ui/huge_listview/huge_listview.dart @@ -62,6 +62,8 @@ class HugeListView extends StatefulWidget { final bool disableScroll; + final bool isScrollablePositionedList; + const HugeListView({ Key? key, this.controller, @@ -80,6 +82,7 @@ class HugeListView extends StatefulWidget { this.isDraggableScrollbarEnabled = true, this.thumbPadding, this.disableScroll = false, + this.isScrollablePositionedList = true, }) : super(key: key); @override @@ -96,7 +99,9 @@ class HugeListViewState extends State> { void initState() { super.initState(); - listener.itemPositions.addListener(_sendScroll); + widget.isScrollablePositionedList + ? listener.itemPositions.addListener(_sendScroll) + : null; } @override @@ -131,52 +136,56 @@ class HugeListViewState extends State> { return widget.emptyResultBuilder!(context); } - return LayoutBuilder( - builder: (context, constraints) { - return DraggableScrollbar( - key: scrollKey, - totalCount: widget.totalCount, - initialScrollIndex: widget.startIndex, - onChange: (position) { - final int currentIndex = _currentFirst(); - final int floorIndex = (position * widget.totalCount).floor(); - final int cielIndex = (position * widget.totalCount).ceil(); - int nextIndexToJump; - if (floorIndex != currentIndex && floorIndex > currentIndex) { - nextIndexToJump = floorIndex; - } else if (cielIndex != currentIndex && cielIndex < currentIndex) { - nextIndexToJump = floorIndex; - } else { - return; - } - if (lastIndexJump != nextIndexToJump) { - lastIndexJump = nextIndexToJump; - widget.controller?.jumpTo(index: nextIndexToJump); - } - }, - labelTextBuilder: widget.labelTextBuilder, - backgroundColor: widget.thumbBackgroundColor, - drawColor: widget.thumbDrawColor, - heightScrollThumb: widget.thumbHeight, - bottomSafeArea: widget.bottomSafeArea, - currentFirstIndex: _currentFirst(), - isEnabled: widget.isDraggableScrollbarEnabled, - padding: widget.thumbPadding, - child: ScrollablePositionedList.builder( - physics: widget.disableScroll - ? const NeverScrollableScrollPhysics() - : null, - itemScrollController: widget.controller, - itemPositionsListener: listener, + return widget.isScrollablePositionedList + ? DraggableScrollbar( + key: scrollKey, + totalCount: widget.totalCount, initialScrollIndex: widget.startIndex, + onChange: (position) { + final int currentIndex = _currentFirst(); + final int floorIndex = (position * widget.totalCount).floor(); + final int cielIndex = (position * widget.totalCount).ceil(); + int nextIndexToJump; + if (floorIndex != currentIndex && floorIndex > currentIndex) { + nextIndexToJump = floorIndex; + } else if (cielIndex != currentIndex && + cielIndex < currentIndex) { + nextIndexToJump = floorIndex; + } else { + return; + } + if (lastIndexJump != nextIndexToJump) { + lastIndexJump = nextIndexToJump; + widget.controller?.jumpTo(index: nextIndexToJump); + } + }, + labelTextBuilder: widget.labelTextBuilder, + backgroundColor: widget.thumbBackgroundColor, + drawColor: widget.thumbDrawColor, + heightScrollThumb: widget.thumbHeight, + bottomSafeArea: widget.bottomSafeArea, + currentFirstIndex: _currentFirst(), + isEnabled: widget.isDraggableScrollbarEnabled, + padding: widget.thumbPadding, + child: ScrollablePositionedList.builder( + physics: widget.disableScroll + ? const NeverScrollableScrollPhysics() + : null, + itemScrollController: widget.controller, + itemPositionsListener: listener, + initialScrollIndex: widget.startIndex, + itemCount: max(widget.totalCount, 0), + itemBuilder: (context, index) { + return widget.itemBuilder(context, index); + }, + ), + ) + : ListView.builder( itemCount: max(widget.totalCount, 0), itemBuilder: (context, index) { return widget.itemBuilder(context, index); }, - ), - ); - }, - ); + ); } /// Jump to the [position] in the list. [position] is between 0.0 (first item) and 1.0 (last item), practically currentIndex / totalCount. diff --git a/lib/ui/map/image_tile.dart b/lib/ui/map/image_tile.dart deleted file mode 100644 index d912e1bc5..000000000 --- a/lib/ui/map/image_tile.dart +++ /dev/null @@ -1,65 +0,0 @@ -import "package:flutter/foundation.dart"; -import "package:flutter/material.dart"; -import "package:photos/models/file.dart"; -import "package:photos/models/file_load_result.dart"; -import "package:photos/ui/viewer/file/detail_page.dart"; -import "package:photos/ui/viewer/file/thumbnail_widget.dart"; -import "package:photos/utils/navigation_util.dart"; - -class ImageTile extends StatelessWidget { - final File image; - final int index; - final List visibleImages; - const ImageTile({ - super.key, - required this.image, - required this.index, - required this.visibleImages, - }); - - void onTap(BuildContext context, File image, int index) { - if (kDebugMode) { - debugPrint('size of visibleImages: ${visibleImages.length}'); - } - - final page = DetailPage( - DetailPageConfiguration( - List.unmodifiable(visibleImages), - ( - creationStartTime, - creationEndTime, { - limit, - asc, - }) async { - final result = FileLoadResult(visibleImages, false); - return result; - }, - index, - 'Map', - ), - ); - - routeToPage( - context, - page, - forceCustomPageRoute: true, - ); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onTap(context, image, index), - child: Padding( - padding: const EdgeInsets.fromLTRB(2, 0, 2, 4), - child: SizedBox( - width: 112, - child: ClipRRect( - borderRadius: BorderRadius.circular(2), - child: ThumbnailWidget(image), - ), - ), - ), - ); - } -} diff --git a/lib/ui/map/map_pull_up_gallery.dart b/lib/ui/map/map_pull_up_gallery.dart new file mode 100644 index 000000000..db64fb31a --- /dev/null +++ b/lib/ui/map/map_pull_up_gallery.dart @@ -0,0 +1,228 @@ +import "dart:async"; + +import "package:defer_pointer/defer_pointer.dart"; +import "package:flutter/material.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/files_updated_event.dart"; +import "package:photos/events/local_photos_updated_event.dart"; +import "package:photos/models/file.dart"; +import "package:photos/models/file_load_result.dart"; +import "package:photos/models/gallery_type.dart"; +import "package:photos/models/selected_files.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/viewer/actions/file_selection_overlay_bar.dart"; +import "package:photos/ui/viewer/gallery/gallery.dart"; + +class MapPullUpGallery extends StatefulWidget { + final StreamController> visibleImages; + final double bottomUnsafeArea; + final double bottomSheetDraggableAreaHeight; + static const gridCrossAxisSpacing = 4.0; + static const gridMainAxisSpacing = 4.0; + static const gridPadding = 2.0; + static const gridCrossAxisCount = 4; + const MapPullUpGallery( + this.visibleImages, + this.bottomSheetDraggableAreaHeight, + this.bottomUnsafeArea, { + Key? key, + }) : super(key: key); + + @override + State createState() => _MapPullUpGalleryState(); +} + +class _MapPullUpGalleryState extends State { + final _selectedFiles = SelectedFiles(); + + @override + Widget build(BuildContext context) { + final Logger logger = Logger("_MapPullUpGalleryState"); + final screenHeight = MediaQuery.of(context).size.height; + final unsafeAreaProportion = widget.bottomUnsafeArea / screenHeight; + final double initialChildSize = 0.25 + unsafeAreaProportion; + + Widget? cachedScrollableContent; + + return DeferredPointerHandler( + child: Stack( + alignment: Alignment.bottomCenter, + clipBehavior: Clip.none, + children: [ + DraggableScrollableSheet( + expand: false, + initialChildSize: initialChildSize, + minChildSize: initialChildSize, + maxChildSize: 0.8, + snap: true, + snapSizes: const [0.5], + builder: (context, scrollController) { + //Must use cached widget here to avoid rebuilds when DraggableScrollableSheet + //is snapped to it's initialChildSize + cachedScrollableContent ??= + cacheScrollableContent(scrollController, context, logger); + return cachedScrollableContent!; + }, + ), + DeferPointer( + child: FileSelectionOverlayBar( + GalleryType.searchResults, + _selectedFiles, + backgroundColor: getEnteColorScheme(context).backgroundElevated2, + ), + ), + ], + ), + ); + } + + Widget cacheScrollableContent( + ScrollController scrollController, + BuildContext context, + logger, + ) { + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + color: colorScheme.backgroundElevated, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + DraggableHeader( + scrollController: scrollController, + bottomSheetDraggableAreaHeight: + widget.bottomSheetDraggableAreaHeight, + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeInOutExpo, + switchOutCurve: Curves.easeInOutExpo, + child: StreamBuilder>( + stream: widget.visibleImages.stream, + builder: ( + BuildContext context, + AsyncSnapshot> snapshot, + ) { + if (!snapshot.hasData) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.2, + child: const EnteLoadingWidget(), + ); + } + + final images = snapshot.data!; + logger.info("Visible images: ${images.length}"); + //To retain only selected files that are in view (visible) + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _selectedFiles.filesToRetain(images.toSet()); + }); + + if (images.isEmpty) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.2, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "No photos found here", + style: textTheme.large, + ), + const SizedBox(height: 4), + Text( + "Zoom out to see photos", + style: textTheme.smallFaint, + ) + ], + ), + ), + ), + ); + } + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeInOutExpo, + switchOutCurve: Curves.easeInOutExpo, + child: Gallery( + key: ValueKey(images), + asyncLoader: ( + creationStartTime, + creationEndTime, { + limit, + asc, + }) async { + FileLoadResult result; + result = FileLoadResult(images, false); + return result; + }, + reloadEvent: Bus.instance.on(), + removalEventTypes: const { + EventType.deletedFromRemote, + EventType.deletedFromEverywhere, + }, + tagPrefix: "map_gallery", + showSelectAllByDefault: true, + selectedFiles: _selectedFiles, + isScrollablePositionedList: false, + ), + ); + }, + ), + ), + ) + ], + ), + ); + } +} + +class DraggableHeader extends StatelessWidget { + const DraggableHeader({ + Key? key, + required this.scrollController, + required this.bottomSheetDraggableAreaHeight, + }) : super(key: key); + static const indicatorHeight = 4.0; + final ScrollController scrollController; + final double bottomSheetDraggableAreaHeight; + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return SingleChildScrollView( + physics: const ClampingScrollPhysics(), + controller: scrollController, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + color: colorScheme.backgroundElevated2, + ), + child: Center( + child: Padding( + padding: EdgeInsets.symmetric( + vertical: + bottomSheetDraggableAreaHeight / 2 - indicatorHeight / 2, + ), + child: Container( + height: indicatorHeight, + width: 72, + decoration: BoxDecoration( + color: colorScheme.fillBase, + borderRadius: const BorderRadius.all(Radius.circular(2)), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/map/map_screen.dart b/lib/ui/map/map_screen.dart index 6d3df8c7a..4eaf74f44 100644 --- a/lib/ui/map/map_screen.dart +++ b/lib/ui/map/map_screen.dart @@ -1,19 +1,18 @@ import "dart:async"; import "dart:isolate"; -import "dart:math"; +import "package:collection/collection.dart"; import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import "package:latlong2/latlong.dart"; import "package:logging/logging.dart"; import "package:photos/models/file.dart"; -import "package:photos/models/location/location.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/common/loading_widget.dart"; import "package:photos/ui/map/image_marker.dart"; -import 'package:photos/ui/map/image_tile.dart'; import "package:photos/ui/map/map_isolate.dart"; +import "package:photos/ui/map/map_pull_up_gallery.dart"; import "package:photos/ui/map/map_view.dart"; import "package:photos/utils/toast_util.dart"; @@ -40,14 +39,16 @@ class _MapScreenState extends State { StreamController>.broadcast(); MapController mapController = MapController(); bool isLoading = true; - double initialZoom = 4.0; + double initialZoom = 4.5; double maxZoom = 18.0; - double minZoom = 0.0; + double minZoom = 2.8; int debounceDuration = 500; LatLng center = LatLng(46.7286, 4.8614); final Logger _logger = Logger("_MapScreenState"); StreamSubscription? _mapMoveSubscription; Isolate? isolate; + static const bottomSheetDraggableAreaHeight = 32.0; + List? prevMessage; @override void initState() { @@ -72,30 +73,21 @@ class _MapScreenState extends State { } Future processFiles(List files) async { - late double minLat, maxLat, minLon, maxLon; final List tempMarkers = []; bool hasAnyLocation = false; + File? mostRecentFile; for (var file in files) { - if (kDebugMode && !file.hasLocation) { - final rand = Random(); - file.location = Location( - latitude: 46.7286 + rand.nextDouble() * 0.1, - longitude: 4.8614 + rand.nextDouble() * 0.1, - ); - } if (file.hasLocation && file.location != null) { - if (!hasAnyLocation) { - minLat = file.location!.latitude!; - minLon = file.location!.longitude!; - maxLat = file.location!.latitude!; - maxLon = file.location!.longitude!; - hasAnyLocation = true; + hasAnyLocation = true; + + if (mostRecentFile == null) { + mostRecentFile = file; } else { - minLat = min(minLat, file.location!.latitude!); - minLon = min(minLon, file.location!.longitude!); - maxLat = max(maxLat, file.location!.latitude!); - maxLon = max(maxLon, file.location!.longitude!); + if ((mostRecentFile.creationTime ?? 0) < (file.creationTime ?? 0)) { + mostRecentFile = file; + } } + tempMarkers.add( ImageMarker( latitude: file.location!.latitude!, @@ -108,22 +100,12 @@ class _MapScreenState extends State { if (hasAnyLocation) { center = LatLng( - minLat + (maxLat - minLat) / 2, - minLon + (maxLon - minLon) / 2, + mostRecentFile!.location!.latitude!, + mostRecentFile.location!.longitude!, ); - final latRange = maxLat - minLat; - final lonRange = maxLon - minLon; - final latZoom = log(360.0 / latRange) / log(2); - final lonZoom = log(180.0 / lonRange) / log(2); - - initialZoom = min(latZoom, lonZoom); - if (initialZoom <= minZoom) initialZoom = minZoom + 1; - if (initialZoom >= (maxZoom - 1)) initialZoom = maxZoom - 1; if (kDebugMode) { debugPrint("Info for map: center $center, initialZoom $initialZoom"); - debugPrint("Info for map: minLat $minLat, maxLat $maxLat"); - debugPrint("Info for map: minLon $minLon, maxLon $maxLon"); } } else { showShortToast(context, "No images with location"); @@ -159,7 +141,11 @@ class _MapScreenState extends State { _mapMoveSubscription = receivePort.listen((dynamic message) async { if (message is List) { - visibleImages.sink.add(message); + if (!message.equals(prevMessage ?? [])) { + visibleImages.sink.add(message); + } + + prevMessage = message; } else { _mapMoveSubscription?.cancel(); isolate?.kill(); @@ -188,98 +174,42 @@ class _MapScreenState extends State { @override Widget build(BuildContext context) { - final textTheme = getEnteTextTheme(context); final colorScheme = getEnteColorScheme(context); + final bottomUnsafeArea = MediaQuery.of(context).padding.bottom; return Container( color: colorScheme.backgroundBase, - child: SafeArea( - top: false, + child: Theme( + data: Theme.of(context).copyWith( + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Colors.transparent, + ), + ), child: Scaffold( body: Stack( children: [ - Column( - children: [ - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(6), - bottomRight: Radius.circular(6), - ), - child: MapView( - key: ValueKey( - 'image-marker-count-${imageMarkers.length}', - ), - controller: mapController, - imageMarkers: imageMarkers, - updateVisibleImages: calculateVisibleMarkers, - center: center, - initialZoom: initialZoom, - minZoom: minZoom, - maxZoom: maxZoom, - debounceDuration: debounceDuration, + LayoutBuilder( + builder: (context, constrains) { + return SizedBox( + height: constrains.maxHeight * 0.75 + + bottomSheetDraggableAreaHeight - + bottomUnsafeArea, + child: MapView( + key: ValueKey( + 'image-marker-count-${imageMarkers.length}', ), + controller: mapController, + imageMarkers: imageMarkers, + updateVisibleImages: calculateVisibleMarkers, + center: center, + initialZoom: initialZoom, + minZoom: minZoom, + maxZoom: maxZoom, + debounceDuration: debounceDuration, + bottomSheetDraggableAreaHeight: + bottomSheetDraggableAreaHeight, ), - ), - const SizedBox(height: 4), - ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(2), - topRight: Radius.circular(2), - ), - child: SizedBox( - height: 116, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeInOutExpo, - switchOutCurve: Curves.easeInOutExpo, - child: StreamBuilder>( - stream: visibleImages.stream, - builder: ( - BuildContext context, - AsyncSnapshot> snapshot, - ) { - if (!snapshot.hasData) { - return const Text("Loading..."); - } - final images = snapshot.data!; - _logger.info("Visible images: ${images.length}"); - if (images.isEmpty) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "No photos found here", - style: textTheme.large, - ), - const SizedBox(height: 4), - Text( - "Zoom out to see photos", - style: textTheme.smallFaint, - ) - ], - ); - } - return ListView.builder( - itemCount: images.length, - scrollDirection: Axis.horizontal, - padding: - const EdgeInsets.symmetric(horizontal: 2), - physics: const BouncingScrollPhysics(), - itemBuilder: (context, index) { - final image = images[index]; - return ImageTile( - image: image, - visibleImages: images, - index: index, - ); - }, - ); - }, - ), - ), - ), - ), - ], + ); + }, ), isLoading ? EnteLoadingWidget( @@ -289,6 +219,11 @@ class _MapScreenState extends State { : const SizedBox.shrink(), ], ), + bottomSheet: MapPullUpGallery( + visibleImages, + bottomSheetDraggableAreaHeight, + bottomUnsafeArea, + ), ), ), ); diff --git a/lib/ui/map/map_view.dart b/lib/ui/map/map_view.dart index 2626be4f1..3ed78a9ff 100644 --- a/lib/ui/map/map_view.dart +++ b/lib/ui/map/map_view.dart @@ -1,5 +1,3 @@ -import "dart:async"; - import "package:flutter/material.dart"; import "package:flutter_map/flutter_map.dart"; import "package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart"; @@ -10,6 +8,7 @@ import 'package:photos/ui/map/map_gallery_tile.dart'; import 'package:photos/ui/map/map_gallery_tile_badge.dart'; import "package:photos/ui/map/map_marker.dart"; import "package:photos/ui/map/tile/layers.dart"; +import "package:photos/utils/debouncer.dart"; class MapView extends StatefulWidget { final List imageMarkers; @@ -20,6 +19,7 @@ class MapView extends StatefulWidget { final double maxZoom; final double initialZoom; final int debounceDuration; + final double bottomSheetDraggableAreaHeight; const MapView({ Key? key, @@ -31,6 +31,7 @@ class MapView extends StatefulWidget { required this.maxZoom, required this.initialZoom, required this.debounceDuration, + required this.bottomSheetDraggableAreaHeight, }) : super(key: key); @override @@ -38,9 +39,9 @@ class MapView extends StatefulWidget { } class _MapViewState extends State { - Timer? _debounceTimer; - bool _isDebouncing = false; late List _markers; + final _debouncer = + Debouncer(const Duration(milliseconds: 300), executionInterval: 750); @override void initState() { @@ -50,23 +51,15 @@ class _MapViewState extends State { @override void dispose() { - _debounceTimer?.cancel(); - _debounceTimer = null; super.dispose(); } void onChange(LatLngBounds bounds) { - if (!_isDebouncing) { - _isDebouncing = true; - _debounceTimer?.cancel(); - _debounceTimer = Timer( - Duration(milliseconds: widget.debounceDuration), - () { - widget.updateVisibleImages(bounds); - _isDebouncing = false; - }, - ); - } + _debouncer.run( + () async { + widget.updateVisibleImages(bounds); + }, + ); } @override @@ -81,13 +74,24 @@ class _MapViewState extends State { maxZoom: widget.maxZoom, enableMultiFingerGestureRace: true, zoom: widget.initialZoom, + maxBounds: LatLngBounds( + LatLng(-90, -180), + LatLng(90, 180), + ), onPositionChanged: (position, hasGesture) { if (position.bounds != null) { onChange(position.bounds!); } }, ), - nonRotatedChildren: const [OSMFranceTileAttributes()], + nonRotatedChildren: [ + Padding( + padding: EdgeInsets.only( + bottom: widget.bottomSheetDraggableAreaHeight, + ), + child: const OSMFranceTileAttributes(), + ) + ], children: [ const OSMFranceTileLayer(), MarkerClusterLayerWidget( @@ -97,13 +101,11 @@ class _MapViewState extends State { showPolygon: false, size: const Size(75, 75), fitBoundsOptions: const FitBoundsOptions( - padding: EdgeInsets.all(1), + padding: EdgeInsets.all(80), ), markers: _markers, onClusterTap: (_) { - if (!_isDebouncing) { - onChange(widget.controller.bounds!); - } + onChange(widget.controller.bounds!); }, builder: (context, List markers) { final index = int.parse( @@ -143,7 +145,7 @@ class _MapViewState extends State { ), ), Positioned( - bottom: 10, + bottom: widget.bottomSheetDraggableAreaHeight + 10, right: 10, child: Column( children: [ diff --git a/lib/ui/map/tile/attribution/map_attribution.dart b/lib/ui/map/tile/attribution/map_attribution.dart index 258459623..d1c20953e 100644 --- a/lib/ui/map/tile/attribution/map_attribution.dart +++ b/lib/ui/map/tile/attribution/map_attribution.dart @@ -3,6 +3,7 @@ import "dart:async"; import "package:flutter/material.dart"; import "package:flutter_map/plugin_api.dart"; import "package:photos/extensions/list.dart"; +import "package:photos/theme/ente_theme.dart"; // Credit: This code is based on the Rich Attribution widget from the flutter_map class MapAttributionWidget extends StatefulWidget { @@ -123,14 +124,16 @@ class MapAttributionWidgetState extends State { } WidgetsBinding.instance.addPostFrameCallback( - (_) => WidgetsBinding.instance.addPostFrameCallback( - (_) => setState( - () => persistentAttributionSize = - (persistentAttributionKey.currentContext!.findRenderObject() - as RenderBox) - .size, - ), - ), + (_) => WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState( + () => persistentAttributionSize = + (persistentAttributionKey.currentContext!.findRenderObject() + as RenderBox) + .size, + ); + } + }), ); } @@ -182,6 +185,7 @@ class MapAttributionWidgetState extends State { icon: Icon( Icons.info_outlined, size: widget.permanentHeight, + color: getEnteColorScheme(context).backgroundElevated, ), ))( context, diff --git a/lib/ui/map/tile/layers.dart b/lib/ui/map/tile/layers.dart index ef5bb1f0a..4a60415a4 100644 --- a/lib/ui/map/tile/layers.dart +++ b/lib/ui/map/tile/layers.dart @@ -34,7 +34,7 @@ class OSMFranceTileLayer extends StatelessWidget { fallbackUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', subdomains: const ['a', 'b', 'c'], tileProvider: CachedNetworkTileProvider(), - backgroundColor: Colors.transparent, + backgroundColor: const Color.fromARGB(255, 246, 246, 246), userAgentPackageName: _userAgent, panBuffer: 1, ); diff --git a/lib/ui/payment/store_subscription_page.dart b/lib/ui/payment/store_subscription_page.dart index f83cb7c7b..4e0f8dc4b 100644 --- a/lib/ui/payment/store_subscription_page.dart +++ b/lib/ui/payment/store_subscription_page.dart @@ -13,6 +13,7 @@ import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/services/billing_service.dart'; +import "package:photos/services/update_service.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; @@ -405,7 +406,7 @@ class _StoreSubscriptionPageState extends State { ], ), ), - _isFreePlanUser() + _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() ? Text( S.of(context).twoMonthsFreeOnYearlyPlans, style: getEnteTextTheme(context).miniMuted, diff --git a/lib/ui/payment/stripe_subscription_page.dart b/lib/ui/payment/stripe_subscription_page.dart index ea7cf65c9..efbbc1f8a 100644 --- a/lib/ui/payment/stripe_subscription_page.dart +++ b/lib/ui/payment/stripe_subscription_page.dart @@ -9,6 +9,7 @@ import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/services/billing_service.dart'; +import "package:photos/services/update_service.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/theme/colors.dart"; import 'package:photos/theme/ente_theme.dart'; @@ -541,7 +542,7 @@ class _StripeSubscriptionPageState extends State { ], ), ), - _isFreePlanUser() + _isFreePlanUser() && !UpdateService.instance.isPlayStoreFlavor() ? Text( S.of(context).twoMonthsFreeOnYearlyPlans, style: getEnteTextTheme(context).miniMuted, diff --git a/lib/ui/payment/subscription_common_widgets.dart b/lib/ui/payment/subscription_common_widgets.dart index b068ab8b5..a2991aa5c 100644 --- a/lib/ui/payment/subscription_common_widgets.dart +++ b/lib/ui/payment/subscription_common_widgets.dart @@ -3,6 +3,7 @@ import "package:intl/intl.dart"; import 'package:photos/ente_theme_data.dart'; import "package:photos/generated/l10n.dart"; import 'package:photos/models/subscription.dart'; +import "package:photos/services/update_service.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/captioned_text_widget.dart"; import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart"; @@ -101,7 +102,9 @@ class ValidityWidget extends StatelessWidget { var message = S.of(context).renewsOn(endDate); if (currentSubscription!.productID == freeProductID) { - message = S.of(context).freeTrialValidTill(endDate); + message = UpdateService.instance.isPlayStoreFlavor() + ? S.of(context).playStoreFreeTrialValidTill(endDate) + : S.of(context).freeTrialValidTill(endDate); } else if (currentSubscription!.attributes?.isCancelled ?? false) { message = S.of(context).subWillBeCancelledOn(endDate); } @@ -110,6 +113,7 @@ class ValidityWidget extends StatelessWidget { child: Text( message, style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, ), ); } diff --git a/lib/ui/settings/general_section_widget.dart b/lib/ui/settings/general_section_widget.dart index c82ea1013..79bc755a5 100644 --- a/lib/ui/settings/general_section_widget.dart +++ b/lib/ui/settings/general_section_widget.dart @@ -12,6 +12,7 @@ import "package:photos/ui/growth/referral_screen.dart"; import 'package:photos/ui/settings/advanced_settings_screen.dart'; import 'package:photos/ui/settings/common_settings.dart'; import "package:photos/ui/settings/language_picker.dart"; +import "package:photos/ui/settings/notification_settings_screen.dart"; import 'package:photos/utils/navigation_util.dart'; class GeneralSectionWidget extends StatelessWidget { @@ -82,6 +83,18 @@ class GeneralSectionWidget extends StatelessWidget { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).notifications, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + _onNotificationsTapped(context); + }, + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).advanced, @@ -104,6 +117,13 @@ class GeneralSectionWidget extends StatelessWidget { BillingService.instance.launchFamilyPortal(context, userDetails); } + void _onNotificationsTapped(BuildContext context) { + routeToPage( + context, + const NotificationSettingsScreen(), + ); + } + void _onAdvancedTapped(BuildContext context) { routeToPage( context, diff --git a/lib/ui/settings/notification_settings_screen.dart b/lib/ui/settings/notification_settings_screen.dart new file mode 100644 index 000000000..e596bb2b3 --- /dev/null +++ b/lib/ui/settings/notification_settings_screen.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import "package:photos/generated/l10n.dart"; +import "package:photos/services/notification_service.dart"; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/buttons/icon_button_widget.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_section_description_widget.dart'; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import 'package:photos/ui/components/toggle_switch_widget.dart'; + +class NotificationSettingsScreen extends StatelessWidget { + const NotificationSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: S.of(context).notifications, + ), + actionIcons: [ + IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.secondary, + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).sharedPhotoNotifications, + ), + menuItemColor: colorScheme.fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => + NotificationService.instance + .hasGrantedPermissions() && + NotificationService.instance + .shouldShowNotificationsForSharedPhotos(), + onChanged: () async { + await NotificationService.instance + .requestPermissions(); + await NotificationService.instance + .setShouldShowNotificationsForSharedPhotos( + !NotificationService.instance + .shouldShowNotificationsForSharedPhotos(), + ); + }, + ), + singleBorderRadius: 8, + alignCaptionedTextToLeft: true, + isGestureDetectorDisabled: true, + ), + MenuSectionDescriptionWidget( + content: S + .of(context) + .sharedPhotoNotificationsExplanation, + ) + ], + ) + ], + ), + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/tabs/home_widget.dart b/lib/ui/tabs/home_widget.dart index eec9fcb72..dcf21ddf5 100644 --- a/lib/ui/tabs/home_widget.dart +++ b/lib/ui/tabs/home_widget.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import "package:flutter_local_notifications/flutter_local_notifications.dart"; import 'package:logging/logging.dart'; import 'package:media_extension/media_extension_action_types.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; @@ -13,6 +14,8 @@ import 'package:photos/core/event_bus.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/events/account_configured_event.dart'; import 'package:photos/events/backup_folders_updated_event.dart'; +import "package:photos/events/collection_updated_event.dart"; +import "package:photos/events/files_updated_event.dart"; import 'package:photos/events/permission_granted_event.dart'; import 'package:photos/events/subscription_purchased_event.dart'; import 'package:photos/events/sync_status_update_event.dart'; @@ -20,11 +23,13 @@ import 'package:photos/events/tab_changed_event.dart'; import 'package:photos/events/trigger_logout_event.dart'; import 'package:photos/events/user_logged_out_event.dart'; import "package:photos/generated/l10n.dart"; +import "package:photos/models/collection_items.dart"; import 'package:photos/models/selected_files.dart'; import 'package:photos/services/app_lifecycle_service.dart'; import 'package:photos/services/collections_service.dart'; import "package:photos/services/entity_service.dart"; 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/user_details_state.dart'; @@ -46,7 +51,9 @@ import 'package:photos/ui/settings/app_update_dialog.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/utils/dialog_util.dart'; +import "package:photos/utils/navigation_util.dart"; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:uni_links/uni_links.dart'; @@ -79,6 +86,7 @@ class _HomeWidgetState extends State { StreamSubscription? _intentDataStreamSubscription; List? _sharedFiles; bool _shouldRenderCreateCollectionSheet = false; + bool _showShowBackupHook = false; late StreamSubscription _tabChangedEventSubscription; late StreamSubscription @@ -89,6 +97,7 @@ class _HomeWidgetState extends State { late StreamSubscription _firstImportEvent; late StreamSubscription _backupFoldersUpdatedEvent; late StreamSubscription _accountConfiguredEvent; + late StreamSubscription _collectionUpdatedEvent; @override void initState() { @@ -162,6 +171,18 @@ class _HomeWidgetState extends State { setState(() {}); } }); + _collectionUpdatedEvent = Bus.instance.on().listen( + (event) async { + // only reset state if backup hook is shown. This is to ensure that + // during first sync, we don't keep showing backup hook if user has + // files + if (mounted && + _showShowBackupHook && + event.type == EventType.addedOrUpdated) { + setState(() {}); + } + }, + ); _initDeepLinks(); UpdateService.instance.shouldUpdate().then((shouldUpdate) { if (shouldUpdate) { @@ -189,6 +210,8 @@ class _HomeWidgetState extends State { ), ); + NotificationService.instance.init(_onDidReceiveNotificationResponse); + super.initState(); } @@ -236,6 +259,7 @@ class _HomeWidgetState extends State { _backupFoldersUpdatedEvent.cancel(); _accountConfiguredEvent.cancel(); _intentDataStreamSubscription?.cancel(); + _collectionUpdatedEvent.cancel(); super.dispose(); } @@ -346,7 +370,7 @@ class _HomeWidgetState extends State { }); } - final bool showBackupFolderHook = + _showShowBackupHook = !Configuration.instance.hasSelectedAnyBackupFolder() && !LocalSyncService.instance.hasGrantedLimitedPermissions() && CollectionsService.instance.getActiveCollections().isEmpty; @@ -368,7 +392,7 @@ class _HomeWidgetState extends State { openDrawer: Scaffold.of(context).openDrawer, physics: const BouncingScrollPhysics(), children: [ - showBackupFolderHook + _showShowBackupHook ? const StartBackupHookWidget(headerWidget: _headerWidget) : HomeGalleryWidget( header: _headerWidget, @@ -475,4 +499,29 @@ class _HomeWidgetState extends State { // Do not show change dialog again UpdateService.instance.hideChangeLog().ignore(); } + + void _onDidReceiveNotificationResponse( + NotificationResponse notificationResponse, + ) async { + final String? payload = notificationResponse.payload; + if (payload != null) { + debugPrint('notification payload: $payload'); + final collectionID = Uri.parse(payload).queryParameters["collectionID"]; + if (collectionID != null) { + final collection = CollectionsService.instance + .getCollectionByID(int.parse(collectionID))!; + final thumbnail = + await CollectionsService.instance.getCover(collection); + routeToPage( + context, + CollectionPage( + CollectionWithThumbnail( + collection, + thumbnail, + ), + ), + ); + } + } + } } diff --git a/lib/ui/tabs/user_collections_tab.dart b/lib/ui/tabs/user_collections_tab.dart index 5ebd4e153..fb6fadbfd 100644 --- a/lib/ui/tabs/user_collections_tab.dart +++ b/lib/ui/tabs/user_collections_tab.dart @@ -9,7 +9,6 @@ import 'package:photos/core/event_bus.dart'; import 'package:photos/events/collection_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/events/user_logged_out_event.dart'; -import 'package:photos/extensions/list.dart'; import "package:photos/generated/l10n.dart"; import 'package:photos/models/collection.dart'; import 'package:photos/services/collections_service.dart'; @@ -90,27 +89,13 @@ class _UserCollectionsTabState extends State Future> _getCollections() async { final List collections = CollectionsService.instance.getCollectionsForUI(); - - // Remove uncategorized collection - collections.removeWhere( - (t) => t.type == CollectionType.uncategorized, - ); - final ListMatch favMathResult = collections.splitMatch( - (element) => element.type == CollectionType.favorites, - ); - - // Hide fav collection if it's empty - if (!FavoritesService.instance.hasFavorites()) { - favMathResult.matched.clear(); - } - + final bool hasFavorites = FavoritesService.instance.hasFavorites(); late Map collectionIDToNewestPhotoTime; if (sortKey == AlbumSortKey.newestPhoto) { collectionIDToNewestPhotoTime = await CollectionsService.instance.getCollectionIDToNewestFileTime(); } - - favMathResult.unmatched.sort( + collections.sort( (first, second) { if (sortKey == AlbumSortKey.albumName) { return compareAsciiLowerCaseNatural( @@ -127,14 +112,28 @@ class _UserCollectionsTabState extends State } }, ); - // This is a way to identify collection which were automatically created - // during create link flow for selected files - final ListMatch potentialSharedLinkCollection = - favMathResult.unmatched.splitMatch( - (e) => (e.isSharedFilesCollection()), - ); + final List favorites = []; + final List pinned = []; + final List rest = []; + for (final collection in collections) { + if (collection.type == CollectionType.uncategorized || + collection.isSharedFilesCollection() || + collection.isHidden()) { + continue; + } + if (collection.type == CollectionType.favorites) { + // Hide fav collection if it's empty + if (hasFavorites) { + favorites.add(collection); + } + } else if (collection.isPinned) { + pinned.add(collection); + } else { + rest.add(collection); + } + } - return favMathResult.matched + potentialSharedLinkCollection.unmatched; + return favorites + pinned + rest; } Widget _getCollectionsGalleryWidget(List collections) { diff --git a/lib/ui/tools/debug/path_storage_viewer.dart b/lib/ui/tools/debug/path_storage_viewer.dart index 28de6ad1d..b68dcb98f 100644 --- a/lib/ui/tools/debug/path_storage_viewer.dart +++ b/lib/ui/tools/debug/path_storage_viewer.dart @@ -84,11 +84,14 @@ class _PathStorageViewerState extends State { subTitleColor: getEnteColorScheme(context).textFaint, ), trailingWidget: stat != null - ? Text( - formatBytes(stat.size), - style: getEnteTextTheme(context) - .small - .copyWith(color: getEnteColorScheme(context).textFaint), + ? Padding( + padding: const EdgeInsets.only(left: 12.0), + child: Text( + formatBytes(stat.size), + style: getEnteTextTheme(context) + .small + .copyWith(color: getEnteColorScheme(context).textFaint), + ), ) : SizedBox.fromSize( size: const Size.square(14), diff --git a/lib/ui/tools/deduplicate_page.dart b/lib/ui/tools/deduplicate_page.dart index 31cff80cc..d8d5e8a84 100644 --- a/lib/ui/tools/deduplicate_page.dart +++ b/lib/ui/tools/deduplicate_page.dart @@ -350,12 +350,7 @@ class _DeduplicatePageState extends State { } Widget _getDeleteButton() { - String text; - if (_selectedFiles.length == 1) { - text = "Delete 1 item"; - } else { - text = "Delete " + _selectedFiles.length.toString() + " items"; - } + final String text = S.of(context).deleteItemCount(_selectedFiles.length); int size = 0; for (final file in _selectedFiles) { size += _fileSizeMap[file.uploadedFileID]!; @@ -416,10 +411,10 @@ class _DeduplicatePageState extends State { Padding( padding: const EdgeInsets.fromLTRB(2, 4, 4, 12), child: Text( - duplicates.files.length.toString() + - " files, " + - formatBytes(duplicates.size) + - " each", + S.of(context).duplicateItemsGroup( + duplicates.files.length, + formatBytes(duplicates.size), + ), style: Theme.of(context).textTheme.titleSmall, ), ), diff --git a/lib/ui/viewer/actions/file_selection_overlay_bar.dart b/lib/ui/viewer/actions/file_selection_overlay_bar.dart index fcd31b3b0..d39be2ba0 100644 --- a/lib/ui/viewer/actions/file_selection_overlay_bar.dart +++ b/lib/ui/viewer/actions/file_selection_overlay_bar.dart @@ -19,12 +19,14 @@ class FileSelectionOverlayBar extends StatefulWidget { final SelectedFiles selectedFiles; final Collection? collection; final DeviceCollection? deviceCollection; + final Color? backgroundColor; const FileSelectionOverlayBar( this.galleryType, this.selectedFiles, { this.collection, this.deviceCollection, + this.backgroundColor, Key? key, }) : super(key: key); @@ -35,14 +37,14 @@ class FileSelectionOverlayBar extends StatefulWidget { class _FileSelectionOverlayBarState extends State { final GlobalKey shareButtonKey = GlobalKey(); - final ValueNotifier _bottomPosition = ValueNotifier(-150.0); + final ValueNotifier _hasSelectedFilesNotifier = ValueNotifier(false); late bool showDeleteOption; @override void initState() { + super.initState(); showDeleteOption = widget.galleryType.showDeleteIconOption(); widget.selectedFiles.addListener(_selectedFilesListener); - super.initState(); } @override @@ -125,15 +127,17 @@ class _FileSelectionOverlayBarState extends State { ), ); return ValueListenableBuilder( - valueListenable: _bottomPosition, + valueListenable: _hasSelectedFilesNotifier, builder: (context, value, child) { - return AnimatedPositioned( - curve: Curves.easeInOutExpo, - bottom: _bottomPosition.value, - right: 0, - left: 0, + return AnimatedCrossFade( + firstCurve: Curves.easeInOutExpo, + secondCurve: Curves.easeInOutExpo, + sizeCurve: Curves.easeInOutExpo, + crossFadeState: _hasSelectedFilesNotifier.value + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, duration: const Duration(milliseconds: 400), - child: BottomActionBarWidget( + firstChild: BottomActionBarWidget( selectedFiles: widget.selectedFiles, hasSmallerBottomPadding: true, type: widget.galleryType, @@ -151,7 +155,9 @@ class _FileSelectionOverlayBarState extends State { } }, iconButtons: iconsButton, + backgroundColor: widget.backgroundColor, ), + secondChild: const SizedBox(width: double.infinity), ); }, ); @@ -167,8 +173,6 @@ class _FileSelectionOverlayBarState extends State { } _selectedFilesListener() { - widget.selectedFiles.files.isNotEmpty - ? _bottomPosition.value = 0.0 - : _bottomPosition.value = -150.0; + _hasSelectedFilesNotifier.value = widget.selectedFiles.files.isNotEmpty; } } diff --git a/lib/ui/viewer/file/file_icons_widget.dart b/lib/ui/viewer/file/file_icons_widget.dart index 5042aea4e..9516d70c8 100644 --- a/lib/ui/viewer/file/file_icons_widget.dart +++ b/lib/ui/viewer/file/file_icons_widget.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; +import "package:flutter/cupertino.dart"; import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; import "package:photos/generated/l10n.dart"; @@ -80,6 +82,19 @@ class ArchiveOverlayIcon extends StatelessWidget { } } +class PinOverlayIcon extends StatelessWidget { + const PinOverlayIcon({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const _BottomRightOverlayIcon( + CupertinoIcons.pin, + color: fixedStrokeMutedWhite, + rotationAngle: 45 * math.pi / 180, + ); + } +} + class LivePhotoOverlayIcon extends StatelessWidget { const LivePhotoOverlayIcon({Key? key}) : super(key: key); @@ -243,9 +258,13 @@ class _BottomRightOverlayIcon extends StatelessWidget { /// smaller thumbnails). final double baseSize; + // Overridable rotation angle. Default is null, which means no rotation. + final double? rotationAngle; + const _BottomRightOverlayIcon( this.icon, { Key? key, + this.rotationAngle, this.baseSize = 24, this.color = Colors.white, // fixed }) : super(key: key); @@ -284,11 +303,20 @@ class _BottomRightOverlayIcon extends StatelessWidget { alignment: Alignment.bottomRight, child: Padding( padding: EdgeInsets.only(bottom: inset, right: inset), - child: Icon( - icon, - size: size, - color: color, - ), + child: rotationAngle == null + ? Icon( + icon, + size: size, + color: color, + ) + : Transform.rotate( + angle: rotationAngle!, // rotate by 45 degrees + child: Icon( + icon, + size: size, + color: color, + ), + ), ), ), ); diff --git a/lib/ui/viewer/file/thumbnail_widget.dart b/lib/ui/viewer/file/thumbnail_widget.dart index 37bf69f14..62fbb3894 100644 --- a/lib/ui/viewer/file/thumbnail_widget.dart +++ b/lib/ui/viewer/file/thumbnail_widget.dart @@ -24,6 +24,7 @@ class ThumbnailWidget extends StatefulWidget { final BoxFit fit; final bool shouldShowSyncStatus; final bool shouldShowArchiveStatus; + final bool shouldShowPinIcon; final bool showFavForAlbumOnly; final bool shouldShowLivePhotoOverlay; final Duration? diskLoadDeferDuration; @@ -38,6 +39,7 @@ class ThumbnailWidget extends StatefulWidget { this.shouldShowSyncStatus = true, this.shouldShowLivePhotoOverlay = false, this.shouldShowArchiveStatus = false, + this.shouldShowPinIcon = false, this.showFavForAlbumOnly = false, this.shouldShowOwnerAvatar = false, this.diskLoadDeferDuration, @@ -182,6 +184,9 @@ class _ThumbnailWidgetState extends State { if (widget.shouldShowArchiveStatus) { viewChildren.add(const ArchiveOverlayIcon()); } + if (widget.shouldShowPinIcon) { + viewChildren.add(const PinOverlayIcon()); + } return Stack( fit: StackFit.expand, diff --git a/lib/ui/viewer/file/video_widget.dart b/lib/ui/viewer/file/video_widget.dart index 12230a847..41f872bd5 100644 --- a/lib/ui/viewer/file/video_widget.dart +++ b/lib/ui/viewer/file/video_widget.dart @@ -12,6 +12,7 @@ import 'package:photos/models/file.dart'; import 'package:photos/services/files_service.dart'; import 'package:photos/ui/viewer/file/thumbnail_widget.dart'; import 'package:photos/ui/viewer/file/video_controls.dart'; +import "package:photos/utils/dialog_util.dart"; import 'package:photos/utils/file_util.dart'; import 'package:photos/utils/toast_util.dart'; import 'package:video_player/video_player.dart'; @@ -135,12 +136,29 @@ class _VideoWidgetState extends State { } else { videoPlayerController = VideoPlayerController.file(file!); } - return _videoPlayerController = videoPlayerController + + debugPrint("videoPlayerController: $videoPlayerController"); + _videoPlayerController = videoPlayerController ..initialize().whenComplete(() { if (mounted) { setState(() {}); } - }); + }).onError( + (error, stackTrace) { + if (mounted) { + if (error is Exception) { + showErrorDialogForException( + context: context, + exception: error, + message: "Failed to play video\n ${error.toString()}", + ); + } else { + showToast(context, "Failed to play video"); + } + } + }, + ); + return videoPlayerController; } @override diff --git a/lib/ui/viewer/gallery/component/grid/place_holder_grid_view_widget.dart b/lib/ui/viewer/gallery/component/grid/place_holder_grid_view_widget.dart index 583eb6f29..38181bf17 100644 --- a/lib/ui/viewer/gallery/component/grid/place_holder_grid_view_widget.dart +++ b/lib/ui/viewer/gallery/component/grid/place_holder_grid_view_widget.dart @@ -1,5 +1,6 @@ import "dart:math"; +import "package:flutter/foundation.dart"; import 'package:flutter/material.dart'; import "package:photos/theme/ente_theme.dart"; @@ -19,7 +20,11 @@ class PlaceHolderGridViewWidget extends StatelessWidget { @override Widget build(BuildContext context) { final Color faintColor = getEnteColorScheme(context).fillFaint; - final int limitCount = min(count, columns * 5); + int limitCount = count; + if (kDebugMode) { + limitCount = min(count, columns * 5); + } + final key = '$limitCount:$columns'; if (!_gridViewCache.containsKey(key)) { _gridViewCache[key] = GridView.builder( diff --git a/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart b/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart index 24ddf6a65..464153bb4 100644 --- a/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart +++ b/lib/ui/viewer/gallery/component/multiple_groups_gallery_view.dart @@ -40,6 +40,7 @@ class MultipleGroupsGalleryView extends StatelessWidget { final String logTag; final Logger logger; final bool showSelectAllByDefault; + final bool isScrollablePositionedList; const MultipleGroupsGalleryView({ required this.hugeListViewKey, @@ -60,6 +61,7 @@ class MultipleGroupsGalleryView extends StatelessWidget { required this.logTag, required this.logger, required this.showSelectAllByDefault, + required this.isScrollablePositionedList, super.key, }); @@ -72,6 +74,7 @@ class MultipleGroupsGalleryView extends StatelessWidget { totalCount: groupedFiles.length, isDraggableScrollbarEnabled: groupedFiles.length > 10, disableScroll: disableScroll, + isScrollablePositionedList: isScrollablePositionedList, waitBuilder: (_) { return const EnteLoadingWidget(); }, diff --git a/lib/ui/viewer/gallery/gallery.dart b/lib/ui/viewer/gallery/gallery.dart index e9fe21359..fda42110d 100644 --- a/lib/ui/viewer/gallery/gallery.dart +++ b/lib/ui/viewer/gallery/gallery.dart @@ -49,6 +49,7 @@ class Gallery extends StatefulWidget { // will select if even when no other item is selected. final bool inSelectionMode; final bool showSelectAllByDefault; + final bool isScrollablePositionedList; // add a Function variable to get sort value in bool final SortAscFn? sortAsyncFn; @@ -73,6 +74,7 @@ class Gallery extends StatefulWidget { this.inSelectionMode = false, this.sortAsyncFn, this.showSelectAllByDefault = true, + this.isScrollablePositionedList = true, Key? key, }) : super(key: key); @@ -247,6 +249,7 @@ class _GalleryState extends State { footer: widget.footer, selectedFiles: widget.selectedFiles, showSelectAllByDefault: widget.showSelectAllByDefault, + isScrollablePositionedList: widget.isScrollablePositionedList, ), ); } diff --git a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index f59fe0fb9..29698a46b 100644 --- a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; +import "package:flutter/cupertino.dart"; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; @@ -66,6 +68,7 @@ enum AlbumPopupAction { freeUpSpace, setCover, addPhotos, + pinAlbum, } class _GalleryAppBarWidgetState extends State { @@ -368,6 +371,31 @@ class _GalleryAppBarWidgetState extends State { ), ), ); + + items.add( + PopupMenuItem( + value: AlbumPopupAction.pinAlbum, + child: Row( + children: [ + widget.collection!.isPinned + ? const Icon(CupertinoIcons.pin_slash) + : Transform.rotate( + angle: 45 * math.pi / 180, // rotate by 45 degrees + child: const Icon(CupertinoIcons.pin), + ), + const Padding( + padding: EdgeInsets.all(8), + ), + Text( + widget.collection!.isPinned + ? S.of(context).unpinAlbum + : S.of(context).pinAlbum, + ), + ], + ), + ), + ); + items.add( PopupMenuItem( value: AlbumPopupAction.ownedArchive, @@ -467,6 +495,13 @@ class _GalleryAppBarWidgetState extends State { onSelected: (AlbumPopupAction value) async { if (value == AlbumPopupAction.rename) { await _renameAlbum(context); + } else if (value == AlbumPopupAction.pinAlbum) { + await updateOrder( + context, + widget.collection!, + widget.collection!.isPinned ? 0 : 1, + ); + if (mounted) setState(() {}); } else if (value == AlbumPopupAction.ownedArchive) { await changeCollectionVisibility( context, diff --git a/lib/ui/viewer/location/location_screen.dart b/lib/ui/viewer/location/location_screen.dart index 387debe95..9a1d0b97d 100644 --- a/lib/ui/viewer/location/location_screen.dart +++ b/lib/ui/viewer/location/location_screen.dart @@ -203,6 +203,7 @@ class _LocationGalleryWidgetState extends State { builder: (context, snapshot) { if (snapshot.hasData) { return Stack( + alignment: Alignment.bottomCenter, children: [ Gallery( loadingWidget: Column( diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart index fe1d300cb..42c2f58d8 100644 --- a/lib/utils/debouncer.dart +++ b/lib/utils/debouncer.dart @@ -1,15 +1,26 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import "package:photos/models/typedefs.dart"; class Debouncer { final Duration _duration; final ValueNotifier _debounceActiveNotifier = ValueNotifier(false); + + /// If executionInterval is not null, then the debouncer will execute the + /// current callback it has in run() method repeatedly in the given interval. + final int? executionInterval; Timer? _debounceTimer; - Debouncer(this._duration); + Debouncer(this._duration, {this.executionInterval}); + + final Stopwatch _stopwatch = Stopwatch(); + + void run(FutureVoidCallback fn) { + if (executionInterval != null) { + runCallbackIfIntervalTimeElapses(fn); + } - void run(Future Function() fn) { if (isActive()) { _debounceTimer!.cancel(); } @@ -26,6 +37,14 @@ class Debouncer { } } + runCallbackIfIntervalTimeElapses(FutureVoidCallback fn) { + _stopwatch.isRunning ? null : _stopwatch.start(); + if (_stopwatch.elapsedMilliseconds > executionInterval!) { + _stopwatch.reset(); + fn(); + } + } + bool isActive() => _debounceTimer != null && _debounceTimer!.isActive; ValueNotifier get debounceActiveNotifier { diff --git a/lib/utils/dialog_util.dart b/lib/utils/dialog_util.dart index edb9f986f..10c8eb39a 100644 --- a/lib/utils/dialog_util.dart +++ b/lib/utils/dialog_util.dart @@ -46,8 +46,10 @@ Future showErrorDialogForException({ required Exception exception, bool isDismissible = true, String apiErrorPrefix = "It looks like something went wrong.", + String? message, }) async { - String errorMessage = S.of(context).tempErrorContactSupportIfPersists; + String errorMessage = + message ?? S.of(context).tempErrorContactSupportIfPersists; if (exception is DioError && exception.response != null && exception.response!.data["code"] != null) { diff --git a/lib/utils/diff_fetcher.dart b/lib/utils/diff_fetcher.dart index 312bee818..535faa525 100644 --- a/lib/utils/diff_fetcher.dart +++ b/lib/utils/diff_fetcher.dart @@ -53,6 +53,7 @@ class DiffFetcher { .getUploadedFile(file.uploadedFileID!, file.collectionID!); if (existingFile != null) { file.generatedID = existingFile.generatedID; + file.addedTime = existingFile.addedTime; } } file.ownerID = item["ownerID"]; diff --git a/lib/utils/file_uploader.dart b/lib/utils/file_uploader.dart index 76fb9fb9e..0f5f78f5e 100644 --- a/lib/utils/file_uploader.dart +++ b/lib/utils/file_uploader.dart @@ -25,10 +25,12 @@ import 'package:photos/models/file.dart'; import 'package:photos/models/file_type.dart'; import "package:photos/models/metadata/file_magic.dart"; import 'package:photos/models/upload_url.dart'; +import "package:photos/models/user_details.dart"; import 'package:photos/services/collections_service.dart'; import "package:photos/services/file_magic_service.dart"; import 'package:photos/services/local_sync_service.dart'; import 'package:photos/services/sync_service.dart'; +import "package:photos/services/user_service.dart"; import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/file_download_util.dart'; import 'package:photos/utils/file_uploader_util.dart'; @@ -42,6 +44,7 @@ class FileUploader { static const kMaximumUploadAttempts = 4; static const kBlockedUploadsPollFrequency = Duration(seconds: 2); static const kFileUploadTimeout = Duration(minutes: 50); + static const k20MBStorageBuffer = 20 * 1024 * 1024; final _logger = Logger("FileUploader"); final _dio = NetworkClient.instance.getDio(); @@ -396,6 +399,7 @@ class FileUploader { if (io.File(encryptedFilePath).existsSync()) { await io.File(encryptedFilePath).delete(); } + await _checkIfWithinStorageLimit(mediaUploadData!.sourceFile!); final encryptedFile = io.File(encryptedFilePath); final EncryptionResult fileAttributes = await CryptoUtil.encryptFile( mediaUploadData!.sourceFile!.path, @@ -703,6 +707,39 @@ class FileUploader { await _uploadLocks.releaseLock(lockKey, _processType.toString()); } + /* + _checkIfWithinStorageLimit verifies if the file size for encryption and upload + is within the storage limit. It throws StorageLimitExceededError if the limit + is exceeded. This check is best effort and may not be completely accurate + due to UserDetail cache. It prevents infinite loops when clients attempt to + upload files that exceed the server's storage limit + buffer. + Note: Local storageBuffer is 20MB, server storageBuffer is 50MB, and an + additional 30MB is reserved for thumbnails and encryption overhead. + */ + Future _checkIfWithinStorageLimit(io.File fileToBeUploaded) async { + try { + final UserDetails? userDetails = + UserService.instance.getCachedUserDetails(); + if (userDetails == null) { + return; + } + // add k20MBStorageBuffer to the free storage + final num freeStorage = userDetails.getFreeStorage() + k20MBStorageBuffer; + final num fileSize = await fileToBeUploaded.length(); + if (fileSize > freeStorage) { + _logger.warning('Storage limit exceeded fileSize $fileSize and ' + 'freeStorage $freeStorage'); + throw StorageLimitExceededError(); + } + } catch (e) { + if (e is StorageLimitExceededError) { + rethrow; + } else { + _logger.severe('Error checking storage limit', e); + } + } + } + Future _onInvalidFileError(File file, InvalidFileError e) async { final String ext = file.title == null ? "no title" : extension(file.title!); _logger.severe( diff --git a/lib/utils/magic_util.dart b/lib/utils/magic_util.dart index 1139df6e2..32a71daba 100644 --- a/lib/utils/magic_util.dart +++ b/lib/utils/magic_util.dart @@ -9,6 +9,7 @@ import 'package:photos/events/force_reload_home_gallery_event.dart'; import "package:photos/generated/l10n.dart"; import 'package:photos/models/collection.dart'; import 'package:photos/models/file.dart'; +import "package:photos/models/metadata/collection_magic.dart"; import "package:photos/models/metadata/common_keys.dart"; import "package:photos/models/metadata/file_magic.dart"; import 'package:photos/services/collections_service.dart'; @@ -105,6 +106,26 @@ Future changeSortOrder( } } +Future updateOrder( + BuildContext context, + Collection collection, + int order, +) async { + try { + final Map update = { + orderKey: order, + }; + await CollectionsService.instance.updateMagicMetadata(collection, update); + Bus.instance.fire( + CollectionMetaEvent(collection.id, CollectionMetaEventType.orderChanged), + ); + } catch (e, s) { + _logger.severe("failed to update order", e, s); + showShortToast(context, S.of(context).somethingWentWrong); + rethrow; + } +} + Future changeCoverPhoto( BuildContext context, Collection collection, diff --git a/pubspec.lock b/pubspec.lock index 4c5938939..5cd4f6a85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -379,6 +379,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + defer_pointer: + dependency: "direct main" + description: + name: defer_pointer + sha256: d69e6f8c1d0f052d2616cc1db3782e0ea73f42e4c6f6122fd1a548dfe79faf02 + url: "https://pub.dev" + source: hosted + version: "0.0.2" device_info: dependency: "direct main" description: @@ -713,26 +721,26 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "57d0012730780fe137260dd180e072c18a73fbeeb924cdc029c18aaa0f338d64" + sha256: f222919a34545931e47b06000836b5101baeffb0e6eb5a4691d2d42851740dd9 url: "https://pub.dev" source: hosted - version: "9.9.1" + version: "12.0.4" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: b472bfc173791b59ede323661eae20f7fff0b6908fea33dd720a6ef5d576bae8 + sha256: "6af440e3962eeab8459602c309d7d4ab9e62f05d5cfe58195a28f846a0b5d523" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "21bceee103a66a53b30ea9daf677f990e5b9e89b62f222e60dd241cd08d63d3a" + sha256: "5ec1feac5f7f7d9266759488bc5f76416152baba9aa1b26fe572246caa00d1ab" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -2026,10 +2034,10 @@ packages: dependency: transitive description: name: timezone - sha256: "57b35f6e8ef731f18529695bffc62f92c6189fac2e52c12d478dec1931afb66e" + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.9.2" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9d5eee30f..063c2bdbd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.7.69+469 +version: 0.7.75+476 environment: sdk: '>=2.17.0 <3.0.0' @@ -36,6 +36,7 @@ dependencies: connectivity_plus: ^3.0.3 crypto: ^3.0.2 cupertino_icons: ^1.0.0 + defer_pointer: ^0.0.2 device_info: ^2.0.2 dio: ^4.0.6 dots_indicator: ^2.0.0 @@ -62,7 +63,7 @@ dependencies: flutter_image_compress: ^1.1.0 flutter_inappwebview: ^5.5.0+2 flutter_launcher_icons: ^0.9.3 - flutter_local_notifications: ^9.5.3+1 + flutter_local_notifications: ^12.0.4 flutter_localizations: sdk: flutter flutter_map: ^4.0.0 diff --git a/thirdparty/chewie b/thirdparty/chewie deleted file mode 160000 index f56c85bdc..000000000 --- a/thirdparty/chewie +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f56c85bdcbaa0405f3e62a4408a7a5acbd31deb1 diff --git a/thirdparty/plugins b/thirdparty/plugins deleted file mode 160000 index db016cc95..000000000 --- a/thirdparty/plugins +++ /dev/null @@ -1 +0,0 @@ -Subproject commit db016cc95c6337766617d644585a835f7693a7df