diff --git a/mobile/apps/locker/ios/Podfile.lock b/mobile/apps/locker/ios/Podfile.lock index 1413d14b2b..174a4f586e 100644 --- a/mobile/apps/locker/ios/Podfile.lock +++ b/mobile/apps/locker/ios/Podfile.lock @@ -188,37 +188,37 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6 - cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba - device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 + cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c + device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 - file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa - flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 - flutter_local_authentication: 1172a4dd88f6306dadce067454e2c4caf07977bb - flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f - listen_sharing_intent: 74a842adcbcf7bedf7bbc938c749da9155141b9a - local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3 - objective_c: 77e887b5ba1827970907e10e832eec1683f3431d - open_file_ios: 461db5853723763573e140de3193656f91990d9e + flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58 + flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 + flutter_local_authentication: 989278c681612f1ee0e36019e149137f114b9d7f + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 + listen_sharing_intent: fe0b9a59913cc124dd6cbd55cd9f881de5f75759 + local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 + objective_c: 89e720c30d716b036faf9c9684022048eee1eee2 + open_file_ios: 5ff7526df64e4394b4fe207636b67a95e83078bb OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4 SDWebImage: f29024626962457f3470184232766516dee8dfea Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 - sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sodium_libs: 6c6d0e83f4ee427c6464caa1f1bdc2abf3ca0b7f + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d PODFILE CHECKSUM: d2d3220ea22664a259778d9e314054751db31361 diff --git a/mobile/apps/locker/lib/core/constants.dart b/mobile/apps/locker/lib/core/constants.dart index c5b9c6f8a5..b397e43ef0 100644 --- a/mobile/apps/locker/lib/core/constants.dart +++ b/mobile/apps/locker/lib/core/constants.dart @@ -18,6 +18,9 @@ final tempDirCleanUpInterval = kDebugMode ? const Duration(hours: 1).inMicroseconds : const Duration(hours: 6).inMicroseconds; +// Note: 0 indicates no device limit +const publicLinkDeviceLimits = [0, 50, 25, 10, 5, 2, 1]; + const uploadTempFilePrefix = "upload_file_"; const blackThumbnailBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' diff --git a/mobile/apps/locker/lib/core/errors.dart b/mobile/apps/locker/lib/core/errors.dart index 6722276ad3..8391574b5a 100644 --- a/mobile/apps/locker/lib/core/errors.dart +++ b/mobile/apps/locker/lib/core/errors.dart @@ -17,6 +17,8 @@ class WiFiUnavailableError extends Error {} class SilentlyCancelUploadsError extends Error {} +class SharingNotPermittedForFreeAccountsError extends Error {} + class InvalidFileError extends ArgumentError { final InvalidReason reason; diff --git a/mobile/apps/locker/lib/extensions/user_extension.dart b/mobile/apps/locker/lib/extensions/user_extension.dart new file mode 100644 index 0000000000..7976f569b4 --- /dev/null +++ b/mobile/apps/locker/lib/extensions/user_extension.dart @@ -0,0 +1,12 @@ +import "package:ente_sharing/models/user.dart"; + +extension UserExtension on User { + //Some initial users have name in name field. + String? get displayName => + // ignore: deprecated_member_use_from_same_package + ((name?.isEmpty ?? true) ? null : name); + + String get nameOrEmail { + return email.substring(0, email.indexOf("@")); + } +} diff --git a/mobile/apps/locker/lib/l10n/app_en.arb b/mobile/apps/locker/lib/l10n/app_en.arb index 6c9b5580dc..4cb86d4026 100644 --- a/mobile/apps/locker/lib/l10n/app_en.arb +++ b/mobile/apps/locker/lib/l10n/app_en.arb @@ -349,5 +349,158 @@ "mastodon": "Mastodon", "matrix": "Matrix", "discord": "Discord", - "reddit": "Reddit" + "reddit": "Reddit", + "allowDownloads": "Allow downloads", + "sharedByYou": "Shared by you", + "sharedWithYou": "Shared with you", + "manageLink": "Manage link", + "linkExpiry": "Link expiry", + "linkNeverExpires": "Never", + "linkExpired": "Expired", + "linkEnabled": "Enabled", + "setAPassword": "Set a password", + "lockButtonLabel": "Lock", + "enterPassword": "Enter password", + "removeLink": "Remove link", + "sendLink": "Send link", + "setPasswordTitle": "Set password", + "resetPasswordTitle": "Reset password", + "allowAddingFiles": "Allow adding files", + "disableDownloadWarningTitle": "Please note", + "disableDownloadWarningBody": "Viewers can still take screenshots or save a copy of your files using external tools.", + "allowAddFilesDescription": "Allow people with the link to also add files to the shared collection.", + "after1Hour": "After 1 hour", + "after1Day": "After 1 day", + "after1Week": "After 1 week", + "after1Month": "After 1 month", + "after1Year": "After 1 year", + "never": "Never", + "custom": "Custom", + "selectTime": "Select time", + "selectDate": "Select date", + "previous": "Previous", + "done": "Done", + "next": "Next", + "noDeviceLimit": "None", + "linkDeviceLimit": "Device limit", + "expiredLinkInfo": "This link has expired. Please select a new expiry time or disable link expiry.", + "linkExpiresOn": "Link will expire on {expiryTime}", + "shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Share with specific people} =1 {Shared with 1 person} other {Shared with {numberOfPeople} people}}", + "@shareWithPeopleSectionTitle": { + "placeholders": { + "numberOfPeople": { + "type": "int", + "example": "2" + } + } + }, + "linkHasExpired": "Link has expired", + "publicLinkEnabled": "Public link enabled", + "shareALink": "Share a link", + "addViewer": "Add viewer", + "addCollaborator": "Add collaborator", + "addANewEmail": "Add a new email", + "orPickAnExistingOne": "Or pick an existing one", + "sharedCollectionSectionDescription": "Create shared and collaborative collections with other Ente users, including users on free plans.", + "createPublicLink": "Create public link", + "addParticipants": "Add participants", + "add": "Add", + "collaboratorsCanAddFilesToTheSharedCollection": "Collaborators can add files to the shared collection.", + "enterEmail": "Enter email", + "viewersSuccessfullyAdded": "{count, plural, =0 {Added 0 viewers} =1 {Added 1 viewer} other {Added {count} viewers}}", + "@viewersSuccessfullyAdded": { + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + }, + "description": "Number of viewers that were successfully added to a collection." + }, + "collaboratorsSuccessfullyAdded": "{count, plural, =0 {Added 0 collaborator} =1 {Added 1 collaborator} other {Added {count} collaborators}}", + "@collaboratorsSuccessfullyAdded": { + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + }, + "description": "Number of collaborators that were successfully added to a collection." + }, + "addViewers": "{count, plural, =0 {Add viewer} =1 {Add viewer} other {Add viewers}}", + "addCollaborators": "{count, plural, =0 {Add collaborator} =1 {Add collaborator} other {Add collaborators}}", + "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.", + "sharing": "Sharing...", + "invalidEmailAddress": "Invalid email address", + "enterValidEmail": "Please enter a valid email address.", + "oops": "Oops", + "youCannotShareWithYourself": "You cannot share with yourself", + "inviteToEnte": "Invite to Ente", + "sendInvite": "Send invite", + "shareTextRecommendUsingEnte": "Download Ente so we can easily share original quality files\n\nhttps://ente.io", + "thisIsYourVerificationId": "This is your Verification ID", + "someoneSharingAlbumsWithYouShouldSeeTheSameId": "Someone sharing albums with you should see the same ID on their device.", + "howToViewShareeVerificationID": "Please ask them to long-press their email address on the settings screen, and verify that the IDs on both devices match.", + "thisIsPersonVerificationId": "This is {email}'s Verification ID", + "@thisIsPersonVerificationId": { + "placeholders": { + "email": { + "type": "String", + "example": "someone@ente.io" + } + } + }, + "verificationId": "Verification ID", + "verifyEmailID": "Verify {email}", + "emailNoEnteAccount": "{email} does not have an Ente account.\n\nSend them an invite to share files.", + "shareMyVerificationID": "Here's my verification ID: {verificationID} for ente.io.", + "shareTextConfirmOthersVerificationID": "Hey, can you confirm that this is your ente.io verification ID: {verificationID}", + "passwordLock": "Password lock", + "manage": "Manage", + "addedAs": "Added as", + "removeParticipant": "Remove participant", + "yesConvertToViewer": "Yes, convert to viewer", + "changePermissions": "Change permissions", + "cannotAddMoreFilesAfterBecomingViewer": "{name} will no longer be able to add files to the collection after becoming a viewer.", + "@cannotAddMoreFilesAfterBecomingViewer": { + "description": "Warning message when changing a collaborator to viewer", + "placeholders": { + "name": { + "type": "String", + "example": "John" + } + } + }, + "removeWithQuestionMark": "Remove?", + "removeParticipantBody": "{userEmail} will be removed from this shared collection\n\nAny files added by them will also be removed from the collection", + "yesRemove": "Yes, remove", + "remove": "Remove", + "viewer": "Viewer", + "collaborator": "Collaborator", + "collaboratorsCanAddFilesToTheSharedAlbum": "Collaborators can add files to the shared collection.", + "albumParticipantsCount": "{count, plural, =0 {No Participants} =1 {1 Participant} other {{count} Participants}}", + "@albumParticipantsCount": { + "description": "The count of participants in an album", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "addMore": "Add more", + "you": "You", + "albumOwner": "Owner", + "typeOfCollectionTypeIsNotSupportedForRename": "Type of collection {collectionType} is not supported for rename", + "@typeOfCollectionTypeIsNotSupportedForRename": { + "placeholders": { + "collectionType": { + "type": "String", + "example": "no network" + } + } + }, + "leaveCollection": "Leave collection", + "filesAddedByYouWillBeRemovedFromTheCollection": "Files added by you will be removed from the collection", + "leaveSharedCollection": "Leave shared collection?" } diff --git a/mobile/apps/locker/lib/l10n/app_localizations.dart b/mobile/apps/locker/lib/l10n/app_localizations.dart index 01e10b9054..0dfaaefd87 100644 --- a/mobile/apps/locker/lib/l10n/app_localizations.dart +++ b/mobile/apps/locker/lib/l10n/app_localizations.dart @@ -1017,6 +1017,564 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Reddit'** String get reddit; + + /// No description provided for @allowDownloads. + /// + /// In en, this message translates to: + /// **'Allow downloads'** + String get allowDownloads; + + /// No description provided for @sharedByYou. + /// + /// In en, this message translates to: + /// **'Shared by you'** + String get sharedByYou; + + /// No description provided for @sharedWithYou. + /// + /// In en, this message translates to: + /// **'Shared with you'** + String get sharedWithYou; + + /// No description provided for @manageLink. + /// + /// In en, this message translates to: + /// **'Manage link'** + String get manageLink; + + /// No description provided for @linkExpiry. + /// + /// In en, this message translates to: + /// **'Link expiry'** + String get linkExpiry; + + /// No description provided for @linkNeverExpires. + /// + /// In en, this message translates to: + /// **'Never'** + String get linkNeverExpires; + + /// No description provided for @linkExpired. + /// + /// In en, this message translates to: + /// **'Expired'** + String get linkExpired; + + /// No description provided for @linkEnabled. + /// + /// In en, this message translates to: + /// **'Enabled'** + String get linkEnabled; + + /// No description provided for @setAPassword. + /// + /// In en, this message translates to: + /// **'Set a password'** + String get setAPassword; + + /// No description provided for @lockButtonLabel. + /// + /// In en, this message translates to: + /// **'Lock'** + String get lockButtonLabel; + + /// No description provided for @enterPassword. + /// + /// In en, this message translates to: + /// **'Enter password'** + String get enterPassword; + + /// No description provided for @removeLink. + /// + /// In en, this message translates to: + /// **'Remove link'** + String get removeLink; + + /// No description provided for @sendLink. + /// + /// In en, this message translates to: + /// **'Send link'** + String get sendLink; + + /// No description provided for @setPasswordTitle. + /// + /// In en, this message translates to: + /// **'Set password'** + String get setPasswordTitle; + + /// No description provided for @resetPasswordTitle. + /// + /// In en, this message translates to: + /// **'Reset password'** + String get resetPasswordTitle; + + /// No description provided for @allowAddingFiles. + /// + /// In en, this message translates to: + /// **'Allow adding files'** + String get allowAddingFiles; + + /// No description provided for @disableDownloadWarningTitle. + /// + /// In en, this message translates to: + /// **'Please note'** + String get disableDownloadWarningTitle; + + /// No description provided for @disableDownloadWarningBody. + /// + /// In en, this message translates to: + /// **'Viewers can still take screenshots or save a copy of your files using external tools.'** + String get disableDownloadWarningBody; + + /// No description provided for @allowAddFilesDescription. + /// + /// In en, this message translates to: + /// **'Allow people with the link to also add files to the shared collection.'** + String get allowAddFilesDescription; + + /// No description provided for @after1Hour. + /// + /// In en, this message translates to: + /// **'After 1 hour'** + String get after1Hour; + + /// No description provided for @after1Day. + /// + /// In en, this message translates to: + /// **'After 1 day'** + String get after1Day; + + /// No description provided for @after1Week. + /// + /// In en, this message translates to: + /// **'After 1 week'** + String get after1Week; + + /// No description provided for @after1Month. + /// + /// In en, this message translates to: + /// **'After 1 month'** + String get after1Month; + + /// No description provided for @after1Year. + /// + /// In en, this message translates to: + /// **'After 1 year'** + String get after1Year; + + /// No description provided for @never. + /// + /// In en, this message translates to: + /// **'Never'** + String get never; + + /// No description provided for @custom. + /// + /// In en, this message translates to: + /// **'Custom'** + String get custom; + + /// No description provided for @selectTime. + /// + /// In en, this message translates to: + /// **'Select time'** + String get selectTime; + + /// No description provided for @selectDate. + /// + /// In en, this message translates to: + /// **'Select date'** + String get selectDate; + + /// No description provided for @previous. + /// + /// In en, this message translates to: + /// **'Previous'** + String get previous; + + /// No description provided for @done. + /// + /// In en, this message translates to: + /// **'Done'** + String get done; + + /// No description provided for @next. + /// + /// In en, this message translates to: + /// **'Next'** + String get next; + + /// No description provided for @noDeviceLimit. + /// + /// In en, this message translates to: + /// **'None'** + String get noDeviceLimit; + + /// No description provided for @linkDeviceLimit. + /// + /// In en, this message translates to: + /// **'Device limit'** + String get linkDeviceLimit; + + /// No description provided for @expiredLinkInfo. + /// + /// In en, this message translates to: + /// **'This link has expired. Please select a new expiry time or disable link expiry.'** + String get expiredLinkInfo; + + /// No description provided for @linkExpiresOn. + /// + /// In en, this message translates to: + /// **'Link will expire on {expiryTime}'** + String linkExpiresOn(Object expiryTime); + + /// No description provided for @shareWithPeopleSectionTitle. + /// + /// In en, this message translates to: + /// **'{numberOfPeople, plural, =0 {Share with specific people} =1 {Shared with 1 person} other {Shared with {numberOfPeople} people}}'** + String shareWithPeopleSectionTitle(int numberOfPeople); + + /// No description provided for @linkHasExpired. + /// + /// In en, this message translates to: + /// **'Link has expired'** + String get linkHasExpired; + + /// No description provided for @publicLinkEnabled. + /// + /// In en, this message translates to: + /// **'Public link enabled'** + String get publicLinkEnabled; + + /// No description provided for @shareALink. + /// + /// In en, this message translates to: + /// **'Share a link'** + String get shareALink; + + /// No description provided for @addViewer. + /// + /// In en, this message translates to: + /// **'Add viewer'** + String get addViewer; + + /// No description provided for @addCollaborator. + /// + /// In en, this message translates to: + /// **'Add collaborator'** + String get addCollaborator; + + /// No description provided for @addANewEmail. + /// + /// In en, this message translates to: + /// **'Add a new email'** + String get addANewEmail; + + /// No description provided for @orPickAnExistingOne. + /// + /// In en, this message translates to: + /// **'Or pick an existing one'** + String get orPickAnExistingOne; + + /// No description provided for @sharedCollectionSectionDescription. + /// + /// In en, this message translates to: + /// **'Create shared and collaborative collections with other Ente users, including users on free plans.'** + String get sharedCollectionSectionDescription; + + /// No description provided for @createPublicLink. + /// + /// In en, this message translates to: + /// **'Create public link'** + String get createPublicLink; + + /// No description provided for @addParticipants. + /// + /// In en, this message translates to: + /// **'Add participants'** + String get addParticipants; + + /// No description provided for @add. + /// + /// In en, this message translates to: + /// **'Add'** + String get add; + + /// No description provided for @collaboratorsCanAddFilesToTheSharedCollection. + /// + /// In en, this message translates to: + /// **'Collaborators can add files to the shared collection.'** + String get collaboratorsCanAddFilesToTheSharedCollection; + + /// No description provided for @enterEmail. + /// + /// In en, this message translates to: + /// **'Enter email'** + String get enterEmail; + + /// Number of viewers that were successfully added to a collection. + /// + /// In en, this message translates to: + /// **'{count, plural, =0 {Added 0 viewers} =1 {Added 1 viewer} other {Added {count} viewers}}'** + String viewersSuccessfullyAdded(int count); + + /// Number of collaborators that were successfully added to a collection. + /// + /// In en, this message translates to: + /// **'{count, plural, =0 {Added 0 collaborator} =1 {Added 1 collaborator} other {Added {count} collaborators}}'** + String collaboratorsSuccessfullyAdded(int count); + + /// No description provided for @addViewers. + /// + /// In en, this message translates to: + /// **'{count, plural, =0 {Add viewer} =1 {Add viewer} other {Add viewers}}'** + String addViewers(num count); + + /// No description provided for @addCollaborators. + /// + /// In en, this message translates to: + /// **'{count, plural, =0 {Add collaborator} =1 {Add collaborator} other {Add collaborators}}'** + String addCollaborators(num count); + + /// No description provided for @longPressAnEmailToVerifyEndToEndEncryption. + /// + /// In en, this message translates to: + /// **'Long press an email to verify end to end encryption.'** + String get longPressAnEmailToVerifyEndToEndEncryption; + + /// No description provided for @sharing. + /// + /// In en, this message translates to: + /// **'Sharing...'** + String get sharing; + + /// No description provided for @invalidEmailAddress. + /// + /// In en, this message translates to: + /// **'Invalid email address'** + String get invalidEmailAddress; + + /// No description provided for @enterValidEmail. + /// + /// In en, this message translates to: + /// **'Please enter a valid email address.'** + String get enterValidEmail; + + /// No description provided for @oops. + /// + /// In en, this message translates to: + /// **'Oops'** + String get oops; + + /// No description provided for @youCannotShareWithYourself. + /// + /// In en, this message translates to: + /// **'You cannot share with yourself'** + String get youCannotShareWithYourself; + + /// No description provided for @inviteToEnte. + /// + /// In en, this message translates to: + /// **'Invite to Ente'** + String get inviteToEnte; + + /// No description provided for @sendInvite. + /// + /// In en, this message translates to: + /// **'Send invite'** + String get sendInvite; + + /// No description provided for @shareTextRecommendUsingEnte. + /// + /// In en, this message translates to: + /// **'Download Ente so we can easily share original quality files\n\nhttps://ente.io'** + String get shareTextRecommendUsingEnte; + + /// No description provided for @thisIsYourVerificationId. + /// + /// In en, this message translates to: + /// **'This is your Verification ID'** + String get thisIsYourVerificationId; + + /// No description provided for @someoneSharingAlbumsWithYouShouldSeeTheSameId. + /// + /// In en, this message translates to: + /// **'Someone sharing albums with you should see the same ID on their device.'** + String get someoneSharingAlbumsWithYouShouldSeeTheSameId; + + /// No description provided for @howToViewShareeVerificationID. + /// + /// In en, this message translates to: + /// **'Please ask them to long-press their email address on the settings screen, and verify that the IDs on both devices match.'** + String get howToViewShareeVerificationID; + + /// No description provided for @thisIsPersonVerificationId. + /// + /// In en, this message translates to: + /// **'This is {email}\'s Verification ID'** + String thisIsPersonVerificationId(String email); + + /// No description provided for @verificationId. + /// + /// In en, this message translates to: + /// **'Verification ID'** + String get verificationId; + + /// No description provided for @verifyEmailID. + /// + /// In en, this message translates to: + /// **'Verify {email}'** + String verifyEmailID(Object email); + + /// No description provided for @emailNoEnteAccount. + /// + /// In en, this message translates to: + /// **'{email} does not have an Ente account.\n\nSend them an invite to share files.'** + String emailNoEnteAccount(Object email); + + /// No description provided for @shareMyVerificationID. + /// + /// In en, this message translates to: + /// **'Here\'s my verification ID: {verificationID} for ente.io.'** + String shareMyVerificationID(Object verificationID); + + /// No description provided for @shareTextConfirmOthersVerificationID. + /// + /// In en, this message translates to: + /// **'Hey, can you confirm that this is your ente.io verification ID: {verificationID}'** + String shareTextConfirmOthersVerificationID(Object verificationID); + + /// No description provided for @passwordLock. + /// + /// In en, this message translates to: + /// **'Password lock'** + String get passwordLock; + + /// No description provided for @manage. + /// + /// In en, this message translates to: + /// **'Manage'** + String get manage; + + /// No description provided for @addedAs. + /// + /// In en, this message translates to: + /// **'Added as'** + String get addedAs; + + /// No description provided for @removeParticipant. + /// + /// In en, this message translates to: + /// **'Remove participant'** + String get removeParticipant; + + /// No description provided for @yesConvertToViewer. + /// + /// In en, this message translates to: + /// **'Yes, convert to viewer'** + String get yesConvertToViewer; + + /// No description provided for @changePermissions. + /// + /// In en, this message translates to: + /// **'Change permissions'** + String get changePermissions; + + /// Warning message when changing a collaborator to viewer + /// + /// In en, this message translates to: + /// **'{name} will no longer be able to add files to the collection after becoming a viewer.'** + String cannotAddMoreFilesAfterBecomingViewer(String name); + + /// No description provided for @removeWithQuestionMark. + /// + /// In en, this message translates to: + /// **'Remove?'** + String get removeWithQuestionMark; + + /// No description provided for @removeParticipantBody. + /// + /// In en, this message translates to: + /// **'{userEmail} will be removed from this shared collection\n\nAny files added by them will also be removed from the collection'** + String removeParticipantBody(Object userEmail); + + /// No description provided for @yesRemove. + /// + /// In en, this message translates to: + /// **'Yes, remove'** + String get yesRemove; + + /// No description provided for @remove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get remove; + + /// No description provided for @viewer. + /// + /// In en, this message translates to: + /// **'Viewer'** + String get viewer; + + /// No description provided for @collaborator. + /// + /// In en, this message translates to: + /// **'Collaborator'** + String get collaborator; + + /// No description provided for @collaboratorsCanAddFilesToTheSharedAlbum. + /// + /// In en, this message translates to: + /// **'Collaborators can add files to the shared collection.'** + String get collaboratorsCanAddFilesToTheSharedAlbum; + + /// The count of participants in an album + /// + /// In en, this message translates to: + /// **'{count, plural, =0 {No Participants} =1 {1 Participant} other {{count} Participants}}'** + String albumParticipantsCount(int count); + + /// No description provided for @addMore. + /// + /// In en, this message translates to: + /// **'Add more'** + String get addMore; + + /// No description provided for @you. + /// + /// In en, this message translates to: + /// **'You'** + String get you; + + /// No description provided for @albumOwner. + /// + /// In en, this message translates to: + /// **'Owner'** + String get albumOwner; + + /// No description provided for @typeOfCollectionTypeIsNotSupportedForRename. + /// + /// In en, this message translates to: + /// **'Type of collection {collectionType} is not supported for rename'** + String typeOfCollectionTypeIsNotSupportedForRename(String collectionType); + + /// No description provided for @leaveCollection. + /// + /// In en, this message translates to: + /// **'Leave collection'** + String get leaveCollection; + + /// No description provided for @filesAddedByYouWillBeRemovedFromTheCollection. + /// + /// In en, this message translates to: + /// **'Files added by you will be removed from the collection'** + String get filesAddedByYouWillBeRemovedFromTheCollection; + + /// No description provided for @leaveSharedCollection. + /// + /// In en, this message translates to: + /// **'Leave shared collection?'** + String get leaveSharedCollection; } class _AppLocalizationsDelegate diff --git a/mobile/apps/locker/lib/l10n/app_localizations_en.dart b/mobile/apps/locker/lib/l10n/app_localizations_en.dart index b9110908c7..58e7ab5bde 100644 --- a/mobile/apps/locker/lib/l10n/app_localizations_en.dart +++ b/mobile/apps/locker/lib/l10n/app_localizations_en.dart @@ -534,4 +534,366 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reddit => 'Reddit'; + + @override + String get allowDownloads => 'Allow downloads'; + + @override + String get sharedByYou => 'Shared by you'; + + @override + String get sharedWithYou => 'Shared with you'; + + @override + String get manageLink => 'Manage link'; + + @override + String get linkExpiry => 'Link expiry'; + + @override + String get linkNeverExpires => 'Never'; + + @override + String get linkExpired => 'Expired'; + + @override + String get linkEnabled => 'Enabled'; + + @override + String get setAPassword => 'Set a password'; + + @override + String get lockButtonLabel => 'Lock'; + + @override + String get enterPassword => 'Enter password'; + + @override + String get removeLink => 'Remove link'; + + @override + String get sendLink => 'Send link'; + + @override + String get setPasswordTitle => 'Set password'; + + @override + String get resetPasswordTitle => 'Reset password'; + + @override + String get allowAddingFiles => 'Allow adding files'; + + @override + String get disableDownloadWarningTitle => 'Please note'; + + @override + String get disableDownloadWarningBody => + 'Viewers can still take screenshots or save a copy of your files using external tools.'; + + @override + String get allowAddFilesDescription => + 'Allow people with the link to also add files to the shared collection.'; + + @override + String get after1Hour => 'After 1 hour'; + + @override + String get after1Day => 'After 1 day'; + + @override + String get after1Week => 'After 1 week'; + + @override + String get after1Month => 'After 1 month'; + + @override + String get after1Year => 'After 1 year'; + + @override + String get never => 'Never'; + + @override + String get custom => 'Custom'; + + @override + String get selectTime => 'Select time'; + + @override + String get selectDate => 'Select date'; + + @override + String get previous => 'Previous'; + + @override + String get done => 'Done'; + + @override + String get next => 'Next'; + + @override + String get noDeviceLimit => 'None'; + + @override + String get linkDeviceLimit => 'Device limit'; + + @override + String get expiredLinkInfo => + 'This link has expired. Please select a new expiry time or disable link expiry.'; + + @override + String linkExpiresOn(Object expiryTime) { + return 'Link will expire on $expiryTime'; + } + + @override + String shareWithPeopleSectionTitle(int numberOfPeople) { + String _temp0 = intl.Intl.pluralLogic( + numberOfPeople, + locale: localeName, + other: 'Shared with $numberOfPeople people', + one: 'Shared with 1 person', + zero: 'Share with specific people', + ); + return '$_temp0'; + } + + @override + String get linkHasExpired => 'Link has expired'; + + @override + String get publicLinkEnabled => 'Public link enabled'; + + @override + String get shareALink => 'Share a link'; + + @override + String get addViewer => 'Add viewer'; + + @override + String get addCollaborator => 'Add collaborator'; + + @override + String get addANewEmail => 'Add a new email'; + + @override + String get orPickAnExistingOne => 'Or pick an existing one'; + + @override + String get sharedCollectionSectionDescription => + 'Create shared and collaborative collections with other Ente users, including users on free plans.'; + + @override + String get createPublicLink => 'Create public link'; + + @override + String get addParticipants => 'Add participants'; + + @override + String get add => 'Add'; + + @override + String get collaboratorsCanAddFilesToTheSharedCollection => + 'Collaborators can add files to the shared collection.'; + + @override + String get enterEmail => 'Enter email'; + + @override + String viewersSuccessfullyAdded(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Added $count viewers', + one: 'Added 1 viewer', + zero: 'Added 0 viewers', + ); + return '$_temp0'; + } + + @override + String collaboratorsSuccessfullyAdded(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Added $count collaborators', + one: 'Added 1 collaborator', + zero: 'Added 0 collaborator', + ); + return '$_temp0'; + } + + @override + String addViewers(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Add viewers', + one: 'Add viewer', + zero: 'Add viewer', + ); + return '$_temp0'; + } + + @override + String addCollaborators(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Add collaborators', + one: 'Add collaborator', + zero: 'Add collaborator', + ); + return '$_temp0'; + } + + @override + String get longPressAnEmailToVerifyEndToEndEncryption => + 'Long press an email to verify end to end encryption.'; + + @override + String get sharing => 'Sharing...'; + + @override + String get invalidEmailAddress => 'Invalid email address'; + + @override + String get enterValidEmail => 'Please enter a valid email address.'; + + @override + String get oops => 'Oops'; + + @override + String get youCannotShareWithYourself => 'You cannot share with yourself'; + + @override + String get inviteToEnte => 'Invite to Ente'; + + @override + String get sendInvite => 'Send invite'; + + @override + String get shareTextRecommendUsingEnte => + 'Download Ente so we can easily share original quality files\n\nhttps://ente.io'; + + @override + String get thisIsYourVerificationId => 'This is your Verification ID'; + + @override + String get someoneSharingAlbumsWithYouShouldSeeTheSameId => + 'Someone sharing albums with you should see the same ID on their device.'; + + @override + String get howToViewShareeVerificationID => + 'Please ask them to long-press their email address on the settings screen, and verify that the IDs on both devices match.'; + + @override + String thisIsPersonVerificationId(String email) { + return 'This is $email\'s Verification ID'; + } + + @override + String get verificationId => 'Verification ID'; + + @override + String verifyEmailID(Object email) { + return 'Verify $email'; + } + + @override + String emailNoEnteAccount(Object email) { + return '$email does not have an Ente account.\n\nSend them an invite to share files.'; + } + + @override + String shareMyVerificationID(Object verificationID) { + return 'Here\'s my verification ID: $verificationID for ente.io.'; + } + + @override + String shareTextConfirmOthersVerificationID(Object verificationID) { + return 'Hey, can you confirm that this is your ente.io verification ID: $verificationID'; + } + + @override + String get passwordLock => 'Password lock'; + + @override + String get manage => 'Manage'; + + @override + String get addedAs => 'Added as'; + + @override + String get removeParticipant => 'Remove participant'; + + @override + String get yesConvertToViewer => 'Yes, convert to viewer'; + + @override + String get changePermissions => 'Change permissions'; + + @override + String cannotAddMoreFilesAfterBecomingViewer(String name) { + return '$name will no longer be able to add files to the collection after becoming a viewer.'; + } + + @override + String get removeWithQuestionMark => 'Remove?'; + + @override + String removeParticipantBody(Object userEmail) { + return '$userEmail will be removed from this shared collection\n\nAny files added by them will also be removed from the collection'; + } + + @override + String get yesRemove => 'Yes, remove'; + + @override + String get remove => 'Remove'; + + @override + String get viewer => 'Viewer'; + + @override + String get collaborator => 'Collaborator'; + + @override + String get collaboratorsCanAddFilesToTheSharedAlbum => + 'Collaborators can add files to the shared collection.'; + + @override + String albumParticipantsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Participants', + one: '1 Participant', + zero: 'No Participants', + ); + return '$_temp0'; + } + + @override + String get addMore => 'Add more'; + + @override + String get you => 'You'; + + @override + String get albumOwner => 'Owner'; + + @override + String typeOfCollectionTypeIsNotSupportedForRename(String collectionType) { + return 'Type of collection $collectionType is not supported for rename'; + } + + @override + String get leaveCollection => 'Leave collection'; + + @override + String get filesAddedByYouWillBeRemovedFromTheCollection => + 'Files added by you will be removed from the collection'; + + @override + String get leaveSharedCollection => 'Leave shared collection?'; } diff --git a/mobile/apps/locker/lib/services/collections/collections_api_client.dart b/mobile/apps/locker/lib/services/collections/collections_api_client.dart index bb2c2003a4..e5bf870f06 100644 --- a/mobile/apps/locker/lib/services/collections/collections_api_client.dart +++ b/mobile/apps/locker/lib/services/collections/collections_api_client.dart @@ -1,16 +1,23 @@ +import "dart:async"; import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:ente_crypto_dart/ente_crypto_dart.dart'; +import "package:ente_events/event_bus.dart"; import 'package:ente_network/network.dart'; +import "package:ente_sharing/collection_sharing_service.dart"; +import "package:ente_sharing/models/user.dart"; import 'package:locker/core/errors.dart'; +import "package:locker/events/collections_updated_event.dart"; +import "package:locker/services/collections/collections_db.dart"; import "package:locker/services/collections/collections_service.dart"; import 'package:locker/services/collections/models/collection.dart'; import 'package:locker/services/collections/models/collection_file_item.dart'; import 'package:locker/services/collections/models/collection_magic.dart'; import 'package:locker/services/collections/models/diff.dart'; +import "package:locker/services/collections/models/public_url.dart"; import 'package:locker/services/configuration.dart'; import "package:locker/services/files/sync/metadata_updater_service.dart"; import 'package:locker/services/files/sync/models/file.dart'; @@ -29,7 +36,11 @@ class CollectionApiClient { final _enteDio = Network.instance.enteDio; final _config = Configuration.instance; - Future init() async {} + late CollectionDB _db; + + Future init() async { + _db = CollectionDB.instance; + } Future> getCollections(int sinceTime) async { try { @@ -161,6 +172,18 @@ class CollectionApiClient { } } + Future leaveCollection(Collection collection) async { + await CollectionSharingService.instance.leaveCollection(collection.id); + await _handleCollectionDeletion(collection); + } + + Future _handleCollectionDeletion(Collection collection) async { + await _db.deleteCollection(collection); + final deletedCollection = collection.copyWith(isDeleted: true); + await _updateCollectionInDB(deletedCollection); + await CollectionService.instance.sync(); + } + Future move( EnteFile file, Collection fromCollection, @@ -394,6 +417,86 @@ class CollectionApiClient { return collection; }); } + + Future createShareUrl( + Collection collection, { + bool enableCollect = false, + }) async { + final response = await CollectionSharingService.instance.createShareUrl( + collection.id, + enableCollect, + ); + + collection.publicURLs.add(PublicURL.fromMap(response.data["result"])); + await _updateCollectionInDB(collection); + Bus.instance.fire(CollectionsUpdatedEvent()); + } + + Future disableShareUrl(Collection collection) async { + await CollectionSharingService.instance.disableShareUrl(collection.id); + collection.publicURLs.clear(); + await _updateCollectionInDB(collection); + Bus.instance.fire(CollectionsUpdatedEvent()); + } + + Future updateShareUrl( + Collection collection, + Map prop, + ) async { + prop.putIfAbsent('collectionID', () => collection.id); + + final response = await CollectionSharingService.instance.updateShareUrl( + collection.id, + prop, + ); + // remove existing url information + collection.publicURLs.clear(); + collection.publicURLs.add(PublicURL.fromMap(response.data["result"])); + await _updateCollectionInDB(collection); + Bus.instance.fire(CollectionsUpdatedEvent()); + } + + Future> share( + int collectionID, + String email, + String publicKey, + CollectionParticipantRole role, + ) async { + final collectionKey = + CollectionService.instance.getCollectionKey(collectionID); + final encryptedKey = CryptoUtil.sealSync( + collectionKey, + CryptoUtil.base642bin(publicKey), + ); + + final sharees = await CollectionSharingService.instance.share( + collectionID, + email, + publicKey, + role.toStringVal(), + collectionKey, + encryptedKey, + ); + + final collection = CollectionService.instance.getFromCache(collectionID); + final updatedCollection = collection!.copyWith(sharees: sharees); + await _updateCollectionInDB(updatedCollection); + return sharees; + } + + Future> unshare(int collectionID, String email) async { + final sharees = + await CollectionSharingService.instance.unshare(collectionID, email); + final collection = CollectionService.instance.getFromCache(collectionID); + final updatedCollection = collection!.copyWith(sharees: sharees); + await _updateCollectionInDB(updatedCollection); + return sharees; + } + + Future _updateCollectionInDB(Collection collection) async { + await _db.updateCollections([collection]); + CollectionService.instance.updateCollectionCache(collection); + } } class CreateRequest { diff --git a/mobile/apps/locker/lib/services/collections/collections_db.dart b/mobile/apps/locker/lib/services/collections/collections_db.dart index 6a1b22a6c9..e1e2f80b83 100644 --- a/mobile/apps/locker/lib/services/collections/collections_db.dart +++ b/mobile/apps/locker/lib/services/collections/collections_db.dart @@ -1,9 +1,9 @@ import 'dart:convert'; import "package:ente_base/models/database.dart"; +import "package:ente_sharing/models/user.dart"; import 'package:locker/services/collections/models/collection.dart'; import 'package:locker/services/collections/models/public_url.dart'; -import 'package:locker/services/collections/models/user.dart'; import 'package:locker/services/files/sync/models/file.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; diff --git a/mobile/apps/locker/lib/services/collections/collections_service.dart b/mobile/apps/locker/lib/services/collections/collections_service.dart index 9813e68e49..84a6ca7906 100644 --- a/mobile/apps/locker/lib/services/collections/collections_service.dart +++ b/mobile/apps/locker/lib/services/collections/collections_service.dart @@ -4,10 +4,15 @@ import 'dart:typed_data'; import 'package:ente_events/event_bus.dart'; import 'package:ente_events/models/signed_in_event.dart'; +import "package:ente_sharing/models/user.dart"; +import "package:fast_base58/fast_base58.dart"; +import "package:flutter/foundation.dart"; import 'package:locker/events/collections_updated_event.dart'; import "package:locker/services/collections/collections_api_client.dart"; import "package:locker/services/collections/collections_db.dart"; import 'package:locker/services/collections/models/collection.dart'; +import "package:locker/services/collections/models/collection_items.dart"; +import "package:locker/services/collections/models/public_url.dart"; import 'package:locker/services/configuration.dart'; import 'package:locker/services/files/sync/models/file.dart'; import 'package:locker/services/trash/models/trash_item_request.dart'; @@ -16,8 +21,6 @@ import "package:locker/utils/crypto_helper.dart"; import 'package:logging/logging.dart'; class CollectionService { - CollectionService._privateConstructor(); - static final CollectionService instance = CollectionService._privateConstructor(); @@ -36,7 +39,16 @@ class CollectionService { }; final _logger = Logger("CollectionService"); - final _apiClient = CollectionApiClient.instance; + + late CollectionApiClient _apiClient; + late CollectionDB _db; + + final _collectionIDToCollections = {}; + + CollectionService._privateConstructor() { + _db = CollectionDB.instance; + _apiClient = CollectionApiClient.instance; + } Future init() async { if (Configuration.instance.hasConfiguredAccount()) { @@ -50,41 +62,45 @@ class CollectionService { } Future sync() async { - final updatedCollections = await CollectionApiClient.instance - .getCollections(CollectionDB.instance.getSyncTime()); + final updatedCollections = + await CollectionApiClient.instance.getCollections(_db.getSyncTime()); if (updatedCollections.isEmpty) { _logger.info("No collections to sync."); return; } - await CollectionDB.instance.updateCollections(updatedCollections); - await CollectionDB.instance - .setSyncTime(updatedCollections.last.updationTime); + await _db.updateCollections(updatedCollections); + // Update the cache with new/updated collections + for (final collection in updatedCollections) { + _collectionIDToCollections[collection.id] = collection; + } + await _db.setSyncTime(updatedCollections.last.updationTime); + final List fileFutures = []; for (final collection in updatedCollections) { if (collection.isDeleted) { - await CollectionDB.instance.deleteCollection(collection); + await _db.deleteCollection(collection); + _collectionIDToCollections.remove(collection.id); continue; } - final syncTime = - CollectionDB.instance.getCollectionSyncTime(collection.id); + final syncTime = _db.getCollectionSyncTime(collection.id); fileFutures.add( - CollectionApiClient.instance - .getFiles(collection, syncTime) - .then((diff) async { + _apiClient.getFiles(collection, syncTime).then((diff) async { if (diff.updatedFiles.isNotEmpty) { - await CollectionDB.instance.addFilesToCollection( + await _db.addFilesToCollection( collection, diff.updatedFiles, ); } if (diff.deletedFiles.isNotEmpty) { - await CollectionDB.instance.deleteFilesFromCollection( + await _db.deleteFilesFromCollection( collection, diff.deletedFiles, ); } - await CollectionDB.instance - .setCollectionSyncTime(collection.id, diff.latestUpdatedAtTime); + await _db.setCollectionSyncTime( + collection.id, + diff.latestUpdatedAtTime, + ); }).catchError((e) { _logger.warning( "Failed to fetch files for collection ${collection.id}: $e", @@ -100,7 +116,7 @@ class CollectionService { bool hasCompletedFirstSync() { return Configuration.instance.hasConfiguredAccount() && - CollectionDB.instance.getSyncTime() > 0; + _db.getSyncTime() > 0; } Future createCollection( @@ -120,17 +136,37 @@ class CollectionService { } Future> getCollections() async { - return CollectionDB.instance.getCollections(); + return _db.getCollections(); + } + + Future getSharedCollections() async { + final List outgoing = []; + final List incoming = []; + final List quickLinks = []; + + final List collections = await getCollections(); + + for (final c in collections) { + if (c.owner.id == Configuration.instance.getUserID()) { + if (c.hasSharees || c.hasLink && !c.isQuickLinkCollection()) { + outgoing.add(c); + } else if (c.isQuickLinkCollection()) { + quickLinks.add(c); + } + } else { + incoming.add(c); + } + } + return SharedCollections(outgoing, incoming, quickLinks); } Future> getCollectionsForFile(EnteFile file) async { - return CollectionDB.instance.getCollectionsForFile(file); + return _db.getCollectionsForFile(file); } Future> getFilesInCollection(Collection collection) async { try { - final files = - await CollectionDB.instance.getFilesInCollection(collection); + final files = await _db.getFilesInCollection(collection); return files; } catch (e) { _logger.severe( @@ -142,7 +178,7 @@ class CollectionService { Future> getAllFiles() async { try { - final allFiles = await CollectionDB.instance.getAllFiles(); + final allFiles = await _db.getAllFiles(); return allFiles; } catch (e) { _logger.severe("Failed to fetch all files: $e"); @@ -178,7 +214,7 @@ class CollectionService { Future rename(Collection collection, String newName) async { try { - await CollectionApiClient.instance.rename( + await _apiClient.rename( collection, newName, ); @@ -212,6 +248,10 @@ class CollectionService { }).catchError((error) { _logger.severe("Failed to initialize collections: $error"); }); + final collections = await _db.getCollections(); + for (final collection in collections) { + _collectionIDToCollections[collection.id] = collection; + } } Future _getOrCreateImportantCollection() async { @@ -313,12 +353,17 @@ class CollectionService { } Future getCollection(int collectionID) async { - return await CollectionDB.instance.getCollection(collectionID); + if (_collectionIDToCollections.containsKey(collectionID)) { + return _collectionIDToCollections[collectionID]!; + } + final collection = await _db.getCollection(collectionID); + _collectionIDToCollections[collectionID] = collection; + return collection; } - Future getCollectionKey(int collectionID) async { - final collection = await getCollection(collectionID); - final collectionKey = CryptoHelper.instance.getCollectionKey(collection); + Uint8List getCollectionKey(int collectionID) { + final collection = _collectionIDToCollections[collectionID]; + final collectionKey = CryptoHelper.instance.getCollectionKey(collection!); return collectionKey; } @@ -340,4 +385,94 @@ class CollectionService { rethrow; } } + + // getActiveCollections returns list of collections which are not deleted yet + List getActiveCollections() { + return _collectionIDToCollections.values + .toList() + .where((element) => !element.isDeleted) + .toList(); + } + + /// Returns Contacts(Users) that are relevant to the account owner. + /// Note: "User" refers to the account owner in the points below. + /// This includes: + /// - Collaborators and viewers of collections owned by user + /// - Owners of collections shared to user. + /// - All collaborators of collections in which user is a collaborator or + /// a viewer. + List getRelevantContacts() { + final List relevantUsers = []; + final existingEmails = {}; + final int ownerID = Configuration.instance.getUserID()!; + final String ownerEmail = Configuration.instance.getEmail()!; + existingEmails.add(ownerEmail); + + for (final c in getActiveCollections()) { + // Add collaborators and viewers of collections owned by user + if (c.owner.id == ownerID) { + for (final User u in c.sharees) { + if (u.id != null && u.email.isNotEmpty) { + if (!existingEmails.contains(u.email)) { + relevantUsers.add(u); + existingEmails.add(u.email); + } + } + } + } else if (c.owner.id != null && c.owner.email.isNotEmpty) { + // Add owners of collections shared with user + if (!existingEmails.contains(c.owner.email)) { + relevantUsers.add(c.owner); + existingEmails.add(c.owner.email); + } + // Add collaborators of collections shared with user where user is a + // viewer or a collaborator + for (final User u in c.sharees) { + if (u.id != null && + u.email.isNotEmpty && + u.email == ownerEmail && + (u.isCollaborator || u.isViewer)) { + for (final User u in c.sharees) { + if (u.id != null && u.email.isNotEmpty && u.isCollaborator) { + if (!existingEmails.contains(u.email)) { + relevantUsers.add(u); + existingEmails.add(u.email); + } + } + } + break; + } + } + } + } + + return relevantUsers; + } + + String getPublicUrl(Collection c) { + final PublicURL url = c.publicURLs.firstOrNull!; + final Uri publicUrl = Uri.parse(url.url); + + final cKey = getCollectionKey(c.id); + final String collectionKey = Base58Encode(cKey); + final String urlValue = "${publicUrl.toString()}#$collectionKey"; + return urlValue; + } + + void clearCache() { + _collectionIDToCollections.clear(); + } + + // Methods for managing collection cache + void updateCollectionCache(Collection collection) { + _collectionIDToCollections[collection.id] = collection; + } + + void removeFromCache(int collectionId) { + _collectionIDToCollections.remove(collectionId); + } + + Collection? getFromCache(int collectionId) { + return _collectionIDToCollections[collectionId]; + } } diff --git a/mobile/apps/locker/lib/services/collections/models/collection.dart b/mobile/apps/locker/lib/services/collections/models/collection.dart index 2338bc11d9..35a16971c6 100644 --- a/mobile/apps/locker/lib/services/collections/models/collection.dart +++ b/mobile/apps/locker/lib/services/collections/models/collection.dart @@ -1,9 +1,9 @@ import 'dart:core'; +import "package:ente_sharing/models/user.dart"; import 'package:flutter/foundation.dart'; import 'package:locker/services/collections/models/collection_magic.dart'; -import 'package:locker/services/collections/models/public_url.dart'; -import 'package:locker/services/collections/models/user.dart'; +import 'package:locker/services/collections/models/public_url.dart'; import 'package:locker/services/files/sync/models/common_keys.dart'; class Collection { diff --git a/mobile/apps/locker/lib/services/collections/models/collection_items.dart b/mobile/apps/locker/lib/services/collections/models/collection_items.dart new file mode 100644 index 0000000000..438e7bb86c --- /dev/null +++ b/mobile/apps/locker/lib/services/collections/models/collection_items.dart @@ -0,0 +1,9 @@ +import "package:locker/services/collections/models/collection.dart"; + +class SharedCollections { + final List outgoing; + final List incoming; + final List quickLinks; + + SharedCollections(this.outgoing, this.incoming, this.quickLinks); +} diff --git a/mobile/apps/locker/lib/services/collections/models/collection_view_type.dart b/mobile/apps/locker/lib/services/collections/models/collection_view_type.dart new file mode 100644 index 0000000000..5386236902 --- /dev/null +++ b/mobile/apps/locker/lib/services/collections/models/collection_view_type.dart @@ -0,0 +1,33 @@ +import "package:flutter/material.dart"; +import "package:locker/services/collections/models/collection.dart"; + +enum CollectionViewType { + ownedCollection, + sharedCollection, + hiddenOwnedCollection, + hiddenSection, + quickLink, + uncategorized, + favorite +} + + +CollectionViewType getCollectionViewType(Collection c, int userID) { + if (!c.isOwner(userID)) { + return CollectionViewType.sharedCollection; + } + if (c.isDefaultHidden()) { + return CollectionViewType.hiddenSection; + } else if (c.type == CollectionType.uncategorized) { + return CollectionViewType.uncategorized; + } else if (c.type == CollectionType.favorites) { + return CollectionViewType.favorite; + } else if (c.isQuickLinkCollection()) { + return CollectionViewType.quickLink; + } else if (c.isHidden()) { + return CollectionViewType.hiddenOwnedCollection; + } + debugPrint("Unknown collection type for collection ${c.id}, falling back to " + "default"); + return CollectionViewType.ownedCollection; +} diff --git a/mobile/apps/locker/lib/ui/collections/collection_flex_grid_view.dart b/mobile/apps/locker/lib/ui/collections/collection_flex_grid_view.dart new file mode 100644 index 0000000000..823f502ef1 --- /dev/null +++ b/mobile/apps/locker/lib/ui/collections/collection_flex_grid_view.dart @@ -0,0 +1,115 @@ +import "dart:math"; + +import "package:ente_ui/theme/ente_theme.dart"; +import "package:flutter/material.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/ui/pages/collection_page.dart"; + +class CollectionFlexGridViewWidget extends StatefulWidget { + final List collections; + final Map collectionFileCounts; + const CollectionFlexGridViewWidget({ + super.key, + required this.collections, + required this.collectionFileCounts, + }); + + @override + State createState() => + _CollectionFlexGridViewWidgetState(); +} + +class _CollectionFlexGridViewWidgetState + extends State { + late List _displayedCollections; + late Map _collectionFileCounts; + + @override + void initState() { + super.initState(); + _displayedCollections = widget.collections; + _collectionFileCounts = widget.collectionFileCounts; + } + + @override + Widget build(BuildContext context) { + return MediaQuery.removePadding( + context: context, + removeBottom: true, + removeTop: true, + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 2.2, + ), + itemCount: min(_displayedCollections.length, 4), + itemBuilder: (context, index) { + final collection = _displayedCollections[index]; + final collectionName = collection.name ?? 'Unnamed Collection'; + + return GestureDetector( + onTap: () => _navigateToCollection(collection), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: getEnteColorScheme(context).fillFaint, + ), + padding: const EdgeInsets.all(12), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + collectionName, + style: getEnteTextTheme(context).body.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + const SizedBox(height: 4), + Text( + context.l10n + .items(_collectionFileCounts[collection.id] ?? 0), + style: getEnteTextTheme(context).small.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.left, + ), + ], + ), + if (collection.type == CollectionType.favorites) + Positioned( + top: 0, + right: 0, + child: Icon( + Icons.star, + color: getEnteColorScheme(context).primary500, + size: 18, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _navigateToCollection(Collection collection) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => CollectionPage(collection: collection), + ), + ); + } +} diff --git a/mobile/apps/locker/lib/ui/collections/section_title.dart b/mobile/apps/locker/lib/ui/collections/section_title.dart new file mode 100644 index 0000000000..81f9056b24 --- /dev/null +++ b/mobile/apps/locker/lib/ui/collections/section_title.dart @@ -0,0 +1,67 @@ +import "package:ente_ui/theme/ente_theme.dart"; +import 'package:flutter/material.dart'; + +class SectionTitle extends StatelessWidget { + final String? title; + final bool mutedTitle; + final Widget? titleWithBrand; + final EdgeInsetsGeometry? padding; + + const SectionTitle({ + this.title, + this.titleWithBrand, + this.mutedTitle = false, + super.key, + this.padding, + }); + + @override + Widget build(BuildContext context) { + Widget child; + if (titleWithBrand != null) { + child = titleWithBrand!; + } else if (title != null) { + child = Text( + title!, + style: getEnteTextTheme(context).h3Bold, + ); + } else { + child = const SizedBox.shrink(); + } + return child; + } +} + +class SectionOptions extends StatelessWidget { + final Widget title; + final Widget? trailingWidget; + final VoidCallback? onTap; + + const SectionOptions( + this.title, { + this.trailingWidget, + super.key, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + if (trailingWidget != null) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + title, + trailingWidget!, + ], + ), + ); + } else { + return Container( + child: title, + ); + } + } +} diff --git a/mobile/apps/locker/lib/ui/components/button/copy_button.dart b/mobile/apps/locker/lib/ui/components/button/copy_button.dart new file mode 100644 index 0000000000..4688ca536d --- /dev/null +++ b/mobile/apps/locker/lib/ui/components/button/copy_button.dart @@ -0,0 +1,53 @@ +import "package:ente_ui/theme/ente_theme.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:locker/l10n/l10n.dart"; + +class CopyButton extends StatefulWidget { + final String url; + + const CopyButton({ + super.key, + required this.url, + }); + + @override + State createState() => _CopyButtonState(); +} + +class _CopyButtonState extends State { + bool _isCopied = false; + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + + return IconButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: widget.url)); + setState(() { + _isCopied = true; + }); + // Reset the state after 2 seconds + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _isCopied = false; + }); + } + }); + }, + icon: Icon( + _isCopied ? Icons.check : Icons.copy, + size: 16, + color: _isCopied ? colorScheme.primary500 : colorScheme.primary500, + ), + iconSize: 16, + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(4), + tooltip: _isCopied + ? context.l10n.linkCopiedToClipboard + : context.l10n.copyLink, + ); + } +} diff --git a/mobile/apps/locker/lib/ui/components/collection_row_widget.dart b/mobile/apps/locker/lib/ui/components/collection_row_widget.dart new file mode 100644 index 0000000000..5fb2f1c857 --- /dev/null +++ b/mobile/apps/locker/lib/ui/components/collection_row_widget.dart @@ -0,0 +1,210 @@ +import "package:ente_events/event_bus.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:flutter/material.dart"; +import "package:locker/events/collections_updated_event.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/services/collections/models/collection_view_type.dart"; +import "package:locker/services/configuration.dart"; +import "package:locker/ui/components/item_list_view.dart"; +import "package:locker/ui/pages/collection_page.dart"; +import "package:locker/utils/collection_actions.dart"; +import "package:locker/utils/date_time_util.dart"; + +class CollectionRowWidget extends StatelessWidget { + final Collection collection; + final List? overflowActions; + final bool isLastItem; + + const CollectionRowWidget({ + super.key, + required this.collection, + this.overflowActions, + this.isLastItem = false, + }); + + @override + Widget build(BuildContext context) { + final updateTime = + DateTime.fromMicrosecondsSinceEpoch(collection.updationTime); + + return InkWell( + onTap: () => _openCollection(context), + child: Container( + padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2), + decoration: BoxDecoration( + border: isLastItem + ? null + : Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withAlpha(30), + width: 0.5, + ), + ), + ), + child: Row( + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.folder_open, + color: collection.type == CollectionType.favorites + ? getEnteColorScheme(context).primary500 + : Colors.grey, + size: 20, + ), + const SizedBox(width: 12), + Flexible( + child: Text( + collection.name ?? 'Unnamed Collection', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: getEnteTextTheme(context).body, + ), + ), + ], + ), + ], + ), + ), + Expanded( + flex: 1, + child: Text( + formatDate(context, updateTime), + style: getEnteTextTheme(context).small.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + PopupMenuButton( + onSelected: (value) => _handleMenuAction(context, value), + icon: const Icon( + Icons.more_vert, + size: 20, + ), + itemBuilder: (BuildContext context) { + return _buildPopupMenuItems(context); + }, + ), + ], + ), + ), + ); + } + + List> _buildPopupMenuItems(BuildContext context) { + final collectionViewType = + getCollectionViewType(collection, Configuration.instance.getUserID()!); + if (overflowActions != null && overflowActions!.isNotEmpty) { + return overflowActions! + .map( + (action) => PopupMenuItem( + value: action.id, + child: Row( + children: [ + Icon(action.icon, size: 16), + const SizedBox(width: 8), + Text(action.label), + ], + ), + ), + ) + .toList(); + } else { + return [ + if (collectionViewType == CollectionViewType.ownedCollection || + collectionViewType == CollectionViewType.hiddenOwnedCollection || + collectionViewType == CollectionViewType.quickLink) + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit, size: 16), + const SizedBox(width: 8), + Text(context.l10n.edit), + ], + ), + ), + if (collectionViewType == CollectionViewType.ownedCollection || + collectionViewType == CollectionViewType.hiddenOwnedCollection || + collectionViewType == CollectionViewType.quickLink) + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, size: 16), + const SizedBox(width: 8), + Text(context.l10n.delete), + ], + ), + ), + if (collectionViewType == CollectionViewType.sharedCollection) + PopupMenuItem( + value: 'leave_collection', + child: Row( + children: [ + const Icon(Icons.logout), + const SizedBox(width: 12), + Text(context.l10n.leaveCollection), + ], + ), + ), + ]; + } + } + + void _handleMenuAction(BuildContext context, String action) { + if (overflowActions != null && overflowActions!.isNotEmpty) { + final customAction = overflowActions!.firstWhere( + (a) => a.id == action, + orElse: () => throw StateError('Action not found'), + ); + customAction.onTap(context, null, collection); + } else { + switch (action) { + case 'edit': + _editCollection(context); + break; + case 'delete': + _deleteCollection(context); + break; + case 'leave_collection': + _leaveCollection(context); + break; + } + } + } + + void _editCollection(BuildContext context) { + CollectionActions.editCollection(context, collection); + } + + void _deleteCollection(BuildContext context) { + CollectionActions.deleteCollection(context, collection); + } + + void _openCollection(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => CollectionPage(collection: collection), + ), + ); + } + + Future _leaveCollection(BuildContext context) async { + await CollectionActions.leaveCollection( + context, + collection, + onSuccess: () { + Bus.instance.fire(CollectionsUpdatedEvent()); + }, + ); + } +} diff --git a/mobile/apps/locker/lib/ui/components/file_row_widget.dart b/mobile/apps/locker/lib/ui/components/file_row_widget.dart new file mode 100644 index 0000000000..4bba351516 --- /dev/null +++ b/mobile/apps/locker/lib/ui/components/file_row_widget.dart @@ -0,0 +1,572 @@ +import "dart:io"; + +import "package:ente_ui/components/buttons/button_widget.dart"; +import "package:ente_ui/components/buttons/models/button_type.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_ui/utils/dialog_util.dart"; +import "package:ente_utils/share_utils.dart"; +import "package:flutter/material.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/collections_service.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/services/configuration.dart"; +import "package:locker/services/files/download/file_downloader.dart"; +import "package:locker/services/files/links/links_service.dart"; +import "package:locker/services/files/sync/metadata_updater_service.dart"; +import "package:locker/services/files/sync/models/file.dart"; +import "package:locker/ui/components/button/copy_button.dart"; +import "package:locker/ui/components/file_edit_dialog.dart"; +import "package:locker/ui/components/item_list_view.dart"; +import "package:locker/utils/date_time_util.dart"; +import "package:locker/utils/file_icon_utils.dart"; +import "package:locker/utils/snack_bar_utils.dart"; +import "package:open_file/open_file.dart"; + +class FileRowWidget extends StatelessWidget { + final EnteFile file; + final List collections; + final List? overflowActions; + final bool isLastItem; + + const FileRowWidget({ + super.key, + required this.file, + required this.collections, + this.overflowActions, + this.isLastItem = false, + }); + + @override + Widget build(BuildContext context) { + final updateTime = file.updationTime != null + ? DateTime.fromMicrosecondsSinceEpoch(file.updationTime!) + : (file.modificationTime != null + ? DateTime.fromMillisecondsSinceEpoch(file.modificationTime!) + : (file.creationTime != null + ? DateTime.fromMillisecondsSinceEpoch(file.creationTime!) + : DateTime.now())); + + return InkWell( + onTap: () => _openFile(context), + child: Container( + padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2), + decoration: BoxDecoration( + border: isLastItem + ? null + : Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withOpacity(0.3), + width: 0.5, + ), + ), + ), + child: Row( + children: [ + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + FileIconUtils.getFileIcon(file.displayName), + color: + FileIconUtils.getFileIconColor(file.displayName), + size: 20, + ), + const SizedBox(width: 12), + Flexible( + child: Text( + file.displayName, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: getEnteTextTheme(context).body, + ), + ), + ], + ), + ], + ), + ), + ), + Expanded( + flex: 1, + child: Text( + formatDate(context, updateTime), + style: getEnteTextTheme(context).small.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + PopupMenuButton( + onSelected: (value) => _handleMenuAction(context, value), + icon: const Icon( + Icons.more_vert, + size: 20, + ), + itemBuilder: (BuildContext context) { + if (overflowActions != null && overflowActions!.isNotEmpty) { + return overflowActions! + .map( + (action) => PopupMenuItem( + value: action.id, + child: Row( + children: [ + Icon(action.icon, size: 16), + const SizedBox(width: 8), + Text(action.label), + ], + ), + ), + ) + .toList(); + } else { + return [ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit, size: 16), + const SizedBox(width: 8), + Text(context.l10n.edit), + ], + ), + ), + PopupMenuItem( + value: 'share_link', + child: Row( + children: [ + const Icon(Icons.share, size: 16), + const SizedBox(width: 8), + Text(context.l10n.share), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, size: 16), + const SizedBox(width: 8), + Text(context.l10n.delete), + ], + ), + ), + ]; + } + }, + ), + ], + ), + ), + ); + } + + void _handleMenuAction(BuildContext context, String action) { + if (overflowActions != null && overflowActions!.isNotEmpty) { + final customAction = overflowActions!.firstWhere( + (a) => a.id == action, + orElse: () => throw StateError('Action not found'), + ); + customAction.onTap(context, file, null); + } else { + switch (action) { + case 'edit': + _showEditDialog(context); + break; + case 'share_link': + _shareLink(context); + break; + case 'delete': + _showDeleteConfirmationDialog(context); + break; + } + } + } + + Future _shareLink(BuildContext context) async { + final dialog = createProgressDialog( + context, + context.l10n.creatingShareLink, + isDismissible: false, + ); + + try { + await dialog.show(); + + // Get or create the share link + final shareableLink = await LinksService.instance.getOrCreateLink(file); + + await dialog.hide(); + + // Show the link dialog with copy and delete options + if (context.mounted) { + await _showShareLinkDialog( + context, + shareableLink.fullURL!, + shareableLink.linkID, + ); + } + } catch (e) { + await dialog.hide(); + + if (context.mounted) { + SnackBarUtils.showWarningSnackBar( + context, + '${context.l10n.failedToCreateShareLink}: ${e.toString()}', + ); + } + } + } + + Future _showShareLinkDialog( + BuildContext context, + String url, + String linkID, + ) async { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + // Capture the root context (with Scaffold) before showing dialog + final rootContext = context; + + await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: Text( + dialogContext.l10n.share, + style: textTheme.largeBold, + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dialogContext.l10n.shareThisLink, + style: textTheme.body, + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.fillFaint, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.strokeFaint), + ), + child: Row( + children: [ + Expanded( + child: SelectableText( + url, + style: textTheme.small, + ), + ), + const SizedBox(width: 8), + CopyButton(url: url), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await _deleteShareLink(rootContext, file.uploadedFileID!); + }, + child: Text( + dialogContext.l10n.deleteLink, + style: + textTheme.body.copyWith(color: colorScheme.warning500), + ), + ), + TextButton( + onPressed: () async { + Navigator.of(dialogContext).pop(); + // Use system share sheet to share the URL + await shareText( + url, + context: rootContext, + ); + }, + child: Text( + dialogContext.l10n.shareLink, + style: + textTheme.body.copyWith(color: colorScheme.primary500), + ), + ), + ], + ); + }, + ); + }, + ); + } + + Future _deleteShareLink(BuildContext context, int fileID) async { + final result = await showChoiceDialog( + context, + title: context.l10n.deleteShareLinkDialogTitle, + body: context.l10n.deleteShareLinkConfirmation, + firstButtonLabel: context.l10n.delete, + secondButtonLabel: context.l10n.cancel, + firstButtonType: ButtonType.critical, + isCritical: true, + ); + if (result?.action == ButtonAction.first && context.mounted) { + final dialog = createProgressDialog( + context, + context.l10n.deletingShareLink, + isDismissible: false, + ); + + try { + await dialog.show(); + await LinksService.instance.deleteLink(fileID); + await dialog.hide(); + + if (context.mounted) { + SnackBarUtils.showInfoSnackBar( + context, + context.l10n.shareLinkDeletedSuccessfully, + ); + } + } catch (e) { + await dialog.hide(); + + if (context.mounted) { + SnackBarUtils.showWarningSnackBar( + context, + '${context.l10n.failedToDeleteShareLink}: ${e.toString()}', + ); + } + } + } + } + + Future _showDeleteConfirmationDialog(BuildContext context) async { + final result = await showChoiceDialog( + context, + title: context.l10n.deleteFile, + body: context.l10n.deleteFileConfirmation(file.displayName), + firstButtonLabel: context.l10n.delete, + secondButtonLabel: context.l10n.cancel, + firstButtonType: ButtonType.critical, + isCritical: true, + ); + + if (result?.action == ButtonAction.first && context.mounted) { + await _deleteFile(context); + } + } + + Future _deleteFile(BuildContext context) async { + final dialog = createProgressDialog( + context, + context.l10n.deletingFile, + isDismissible: false, + ); + + try { + await dialog.show(); + + final collections = + await CollectionService.instance.getCollectionsForFile(file); + if (collections.isNotEmpty) { + await CollectionService.instance.trashFile(file, collections.first); + } + + await dialog.hide(); + + SnackBarUtils.showInfoSnackBar( + context, + context.l10n.fileDeletedSuccessfully, + ); + } catch (e) { + await dialog.hide(); + + SnackBarUtils.showWarningSnackBar( + context, + context.l10n.failedToDeleteFile(e.toString()), + ); + } + } + + Future _showEditDialog(BuildContext context) async { + final allCollections = await CollectionService.instance.getCollections(); + allCollections.removeWhere( + (c) => c.type == CollectionType.uncategorized, + ); + + final result = await showFileEditDialog( + context, + file: file, + collections: allCollections, + ); + + if (result != null && context.mounted) { + List currentCollections; + try { + currentCollections = + await CollectionService.instance.getCollectionsForFile(file); + } catch (e) { + currentCollections = []; + } + + final currentCollectionsSet = currentCollections.toSet(); + + final newCollectionsSet = result.selectedCollections.toSet(); + + final collectionsToAdd = + newCollectionsSet.difference(currentCollectionsSet).toList(); + + final collectionsToRemove = + currentCollectionsSet.difference(newCollectionsSet).toList(); + + final currentTitle = file.displayName; + final currentCaption = file.caption ?? ''; + final hasMetadataChanged = + result.title != currentTitle || result.caption != currentCaption; + + if (hasMetadataChanged || currentCollectionsSet != newCollectionsSet) { + final dialog = createProgressDialog( + context, + context.l10n.pleaseWait, + isDismissible: false, + ); + await dialog.show(); + + try { + final List> apiCalls = []; + for (final collection in collectionsToAdd) { + apiCalls.add( + CollectionService.instance.addToCollection(collection, file), + ); + } + await Future.wait(apiCalls); + apiCalls.clear(); + + for (final collection in collectionsToRemove) { + apiCalls.add( + CollectionService.instance + .move(file, collection, newCollectionsSet.first), + ); + } + if (hasMetadataChanged) { + apiCalls.add( + MetadataUpdaterService.instance + .editFileNameAndCaption(file, result.title, result.caption), + ); + } + await Future.wait(apiCalls); + + await dialog.hide(); + + SnackBarUtils.showInfoSnackBar( + context, + context.l10n.fileUpdatedSuccessfully, + ); + } catch (e) { + await dialog.hide(); + + SnackBarUtils.showWarningSnackBar( + context, + context.l10n.failedToUpdateFile(e.toString()), + ); + } + } else { + SnackBarUtils.showWarningSnackBar( + context, + context.l10n.noChangesWereMade, + ); + } + } + } + + Future _openFile(BuildContext context) async { + if (file.localPath != null) { + final localFile = File(file.localPath!); + if (await localFile.exists()) { + await _launchFile(context, localFile, file.displayName); + return; + } + } + + final String cachedFilePath = + "${Configuration.instance.getCacheDirectory()}${file.displayName}"; + final File cachedFile = File(cachedFilePath); + if (await cachedFile.exists()) { + await _launchFile(context, cachedFile, file.displayName); + return; + } + + final dialog = createProgressDialog( + context, + context.l10n.downloading, + isDismissible: false, + ); + + try { + await dialog.show(); + final fileKey = await CollectionService.instance.getFileKey(file); + final decryptedFile = await downloadAndDecrypt( + file, + fileKey, + progressCallback: (downloaded, total) { + if (total > 0 && downloaded >= 0) { + final percentage = + ((downloaded / total) * 100).clamp(0, 100).round(); + dialog.update( + message: context.l10n.downloadingProgress(percentage), + ); + } else { + dialog.update(message: context.l10n.downloading); + } + }, + shouldUseCache: true, + ); + + await dialog.hide(); + + if (decryptedFile != null) { + await _launchFile(context, decryptedFile, file.displayName); + } else { + await showErrorDialog( + context, + context.l10n.downloadFailed, + context.l10n.failedToDownloadOrDecrypt, + ); + } + } catch (e) { + await dialog.hide(); + await showErrorDialog( + context, + context.l10n.errorOpeningFile, + context.l10n.errorOpeningFileMessage(e.toString()), + ); + } + } + + Future _launchFile( + BuildContext context, + File file, + String fileName, + ) async { + try { + await OpenFile.open(file.path); + } catch (e) { + await showErrorDialog( + context, + context.l10n.errorOpeningFile, + context.l10n.couldNotOpenFile(e.toString()), + ); + } + } +} diff --git a/mobile/apps/locker/lib/ui/components/item_list_view.dart b/mobile/apps/locker/lib/ui/components/item_list_view.dart index 5da2fe18e2..927d05ce96 100644 --- a/mobile/apps/locker/lib/ui/components/item_list_view.dart +++ b/mobile/apps/locker/lib/ui/components/item_list_view.dart @@ -1,28 +1,12 @@ -import 'dart:io'; -import 'package:ente_ui/components/buttons/button_widget.dart'; -import 'package:ente_ui/components/buttons/models/button_type.dart'; import 'package:ente_ui/theme/ente_theme.dart'; -import 'package:ente_ui/utils/dialog_util.dart'; -import 'package:ente_utils/share_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; import 'package:locker/l10n/l10n.dart'; -import 'package:locker/services/collections/collections_service.dart'; import 'package:locker/services/collections/models/collection.dart'; -import 'package:locker/services/configuration.dart'; -import 'package:locker/services/files/download/file_downloader.dart'; -import 'package:locker/services/files/links/links_service.dart'; -import 'package:locker/services/files/sync/metadata_updater_service.dart'; import 'package:locker/services/files/sync/models/file.dart'; -import 'package:locker/ui/components/file_edit_dialog.dart'; -import 'package:locker/ui/pages/collection_page.dart'; -import 'package:locker/utils/collection_actions.dart'; +import "package:locker/ui/components/collection_row_widget.dart"; +import "package:locker/ui/components/file_row_widget.dart"; import 'package:locker/utils/collection_sort_util.dart'; -import 'package:locker/utils/date_time_util.dart'; -import 'package:locker/utils/file_icon_utils.dart'; -import 'package:locker/utils/snack_bar_utils.dart'; -import 'package:open_file/open_file.dart'; class OverflowMenuAction { final String id; @@ -400,767 +384,6 @@ class ListItemWidget extends StatelessWidget { } } -class CollectionRowWidget extends StatelessWidget { - final Collection collection; - final List? overflowActions; - final bool isLastItem; - - const CollectionRowWidget({ - super.key, - required this.collection, - this.overflowActions, - this.isLastItem = false, - }); - - @override - Widget build(BuildContext context) { - final updateTime = - DateTime.fromMicrosecondsSinceEpoch(collection.updationTime); - - return InkWell( - onTap: () => _openCollection(context), - child: Container( - padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2), - decoration: BoxDecoration( - border: isLastItem - ? null - : Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor.withOpacity(0.3), - width: 0.5, - ), - ), - ), - child: Row( - children: [ - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.folder_open, - color: collection.type == CollectionType.favorites - ? getEnteColorScheme(context).primary500 - : Colors.grey, - size: 20, - ), - const SizedBox(width: 12), - Flexible( - child: Text( - collection.name ?? 'Unnamed Collection', - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: getEnteTextTheme(context).body, - ), - ), - ], - ), - ], - ), - ), - Expanded( - flex: 1, - child: Text( - formatDate(context, updateTime), - style: getEnteTextTheme(context).small.copyWith( - color: Theme.of(context).textTheme.bodySmall?.color, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - PopupMenuButton( - onSelected: (value) => _handleMenuAction(context, value), - icon: const Icon( - Icons.more_vert, - size: 20, - ), - itemBuilder: (BuildContext context) { - if (overflowActions != null && overflowActions!.isNotEmpty) { - return overflowActions! - .map( - (action) => PopupMenuItem( - value: action.id, - child: Row( - children: [ - Icon(action.icon, size: 16), - const SizedBox(width: 8), - Text(action.label), - ], - ), - ), - ) - .toList(); - } else { - return [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit, size: 16), - const SizedBox(width: 8), - Text(context.l10n.edit), - ], - ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete, size: 16), - const SizedBox(width: 8), - Text(context.l10n.delete), - ], - ), - ), - ]; - } - }, - ), - ], - ), - ), - ); - } - - void _handleMenuAction(BuildContext context, String action) { - if (overflowActions != null && overflowActions!.isNotEmpty) { - final customAction = overflowActions!.firstWhere( - (a) => a.id == action, - orElse: () => throw StateError('Action not found'), - ); - customAction.onTap(context, null, collection); - } else { - switch (action) { - case 'edit': - _editCollection(context); - break; - case 'delete': - _deleteCollection(context); - break; - } - } - } - - void _editCollection(BuildContext context) { - CollectionActions.editCollection(context, collection); - } - - void _deleteCollection(BuildContext context) { - CollectionActions.deleteCollection(context, collection); - } - - void _openCollection(BuildContext context) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => CollectionPage(collection: collection), - ), - ); - } -} - -class FileRowWidget extends StatelessWidget { - final EnteFile file; - final List collections; - final List? overflowActions; - final bool isLastItem; - - const FileRowWidget({ - super.key, - required this.file, - required this.collections, - this.overflowActions, - this.isLastItem = false, - }); - - @override - Widget build(BuildContext context) { - final updateTime = file.updationTime != null - ? DateTime.fromMicrosecondsSinceEpoch(file.updationTime!) - : (file.modificationTime != null - ? DateTime.fromMillisecondsSinceEpoch(file.modificationTime!) - : (file.creationTime != null - ? DateTime.fromMillisecondsSinceEpoch(file.creationTime!) - : DateTime.now())); - - return InkWell( - onTap: () => _openFile(context), - child: Container( - padding: EdgeInsets.fromLTRB(16.0, 2, 16.0, isLastItem ? 8 : 2), - decoration: BoxDecoration( - border: isLastItem - ? null - : Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor.withOpacity(0.3), - width: 0.5, - ), - ), - ), - child: Row( - children: [ - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.only(right: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - FileIconUtils.getFileIcon(file.displayName), - color: - FileIconUtils.getFileIconColor(file.displayName), - size: 20, - ), - const SizedBox(width: 12), - Flexible( - child: Text( - file.displayName, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: getEnteTextTheme(context).body, - ), - ), - ], - ), - ], - ), - ), - ), - Expanded( - flex: 1, - child: Text( - formatDate(context, updateTime), - style: getEnteTextTheme(context).small.copyWith( - color: Theme.of(context).textTheme.bodySmall?.color, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - PopupMenuButton( - onSelected: (value) => _handleMenuAction(context, value), - icon: const Icon( - Icons.more_vert, - size: 20, - ), - itemBuilder: (BuildContext context) { - if (overflowActions != null && overflowActions!.isNotEmpty) { - return overflowActions! - .map( - (action) => PopupMenuItem( - value: action.id, - child: Row( - children: [ - Icon(action.icon, size: 16), - const SizedBox(width: 8), - Text(action.label), - ], - ), - ), - ) - .toList(); - } else { - return [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit, size: 16), - const SizedBox(width: 8), - Text(context.l10n.edit), - ], - ), - ), - PopupMenuItem( - value: 'share_link', - child: Row( - children: [ - const Icon(Icons.share, size: 16), - const SizedBox(width: 8), - Text(context.l10n.share), - ], - ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete, size: 16), - const SizedBox(width: 8), - Text(context.l10n.delete), - ], - ), - ), - ]; - } - }, - ), - ], - ), - ), - ); - } - - void _handleMenuAction(BuildContext context, String action) { - if (overflowActions != null && overflowActions!.isNotEmpty) { - final customAction = overflowActions!.firstWhere( - (a) => a.id == action, - orElse: () => throw StateError('Action not found'), - ); - customAction.onTap(context, file, null); - } else { - switch (action) { - case 'edit': - _showEditDialog(context); - break; - case 'share_link': - _shareLink(context); - break; - case 'delete': - _showDeleteConfirmationDialog(context); - break; - } - } - } - - Future _shareLink(BuildContext context) async { - final dialog = createProgressDialog( - context, - context.l10n.creatingShareLink, - isDismissible: false, - ); - - try { - await dialog.show(); - - // Get or create the share link - final shareableLink = await LinksService.instance.getOrCreateLink(file); - - await dialog.hide(); - - // Show the link dialog with copy and delete options - if (context.mounted) { - await _showShareLinkDialog( - context, - shareableLink.fullURL!, - shareableLink.linkID, - ); - } - } catch (e) { - await dialog.hide(); - - if (context.mounted) { - SnackBarUtils.showWarningSnackBar( - context, - '${context.l10n.failedToCreateShareLink}: ${e.toString()}', - ); - } - } - } - - Future _showShareLinkDialog( - BuildContext context, - String url, - String linkID, - ) async { - final colorScheme = getEnteColorScheme(context); - final textTheme = getEnteTextTheme(context); - // Capture the root context (with Scaffold) before showing dialog - final rootContext = context; - - await showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - title: Text( - dialogContext.l10n.share, - style: textTheme.largeBold, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dialogContext.l10n.shareThisLink, - style: textTheme.body, - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.fillFaint, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.strokeFaint), - ), - child: Row( - children: [ - Expanded( - child: SelectableText( - url, - style: textTheme.small, - ), - ), - const SizedBox(width: 8), - _CopyButton( - url: url, - ), - ], - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await _deleteShareLink(rootContext, file.uploadedFileID!); - }, - child: Text( - dialogContext.l10n.deleteLink, - style: - textTheme.body.copyWith(color: colorScheme.warning500), - ), - ), - TextButton( - onPressed: () async { - Navigator.of(dialogContext).pop(); - // Use system share sheet to share the URL - await shareText( - url, - context: rootContext, - ); - }, - child: Text( - dialogContext.l10n.shareLink, - style: - textTheme.body.copyWith(color: colorScheme.primary500), - ), - ), - ], - ); - }, - ); - }, - ); - } - - Future _deleteShareLink(BuildContext context, int fileID) async { - final result = await showChoiceDialog( - context, - title: context.l10n.deleteShareLinkDialogTitle, - body: context.l10n.deleteShareLinkConfirmation, - firstButtonLabel: context.l10n.delete, - secondButtonLabel: context.l10n.cancel, - firstButtonType: ButtonType.critical, - isCritical: true, - ); - if (result?.action == ButtonAction.first && context.mounted) { - final dialog = createProgressDialog( - context, - context.l10n.deletingShareLink, - isDismissible: false, - ); - - try { - await dialog.show(); - await LinksService.instance.deleteLink(fileID); - await dialog.hide(); - - if (context.mounted) { - SnackBarUtils.showInfoSnackBar( - context, - context.l10n.shareLinkDeletedSuccessfully, - ); - } - } catch (e) { - await dialog.hide(); - - if (context.mounted) { - SnackBarUtils.showWarningSnackBar( - context, - '${context.l10n.failedToDeleteShareLink}: ${e.toString()}', - ); - } - } - } - } - - Future _showDeleteConfirmationDialog(BuildContext context) async { - final result = await showChoiceDialog( - context, - title: context.l10n.deleteFile, - body: context.l10n.deleteFileConfirmation(file.displayName), - firstButtonLabel: context.l10n.delete, - secondButtonLabel: context.l10n.cancel, - firstButtonType: ButtonType.critical, - isCritical: true, - ); - - if (result?.action == ButtonAction.first && context.mounted) { - await _deleteFile(context); - } - } - - Future _deleteFile(BuildContext context) async { - final dialog = createProgressDialog( - context, - context.l10n.deletingFile, - isDismissible: false, - ); - - try { - await dialog.show(); - - final collections = - await CollectionService.instance.getCollectionsForFile(file); - if (collections.isNotEmpty) { - await CollectionService.instance.trashFile(file, collections.first); - } - - await dialog.hide(); - - SnackBarUtils.showInfoSnackBar( - context, - context.l10n.fileDeletedSuccessfully, - ); - } catch (e) { - await dialog.hide(); - - SnackBarUtils.showWarningSnackBar( - context, - context.l10n.failedToDeleteFile(e.toString()), - ); - } - } - - Future _showEditDialog(BuildContext context) async { - final allCollections = await CollectionService.instance.getCollections(); - allCollections.removeWhere( - (c) => c.type == CollectionType.uncategorized, - ); - - final result = await showFileEditDialog( - context, - file: file, - collections: allCollections, - ); - - if (result != null && context.mounted) { - List currentCollections; - try { - currentCollections = - await CollectionService.instance.getCollectionsForFile(file); - } catch (e) { - currentCollections = []; - } - - final currentCollectionsSet = currentCollections.toSet(); - - final newCollectionsSet = result.selectedCollections.toSet(); - - final collectionsToAdd = - newCollectionsSet.difference(currentCollectionsSet).toList(); - - final collectionsToRemove = - currentCollectionsSet.difference(newCollectionsSet).toList(); - - final currentTitle = file.displayName; - final currentCaption = file.caption ?? ''; - final hasMetadataChanged = - result.title != currentTitle || result.caption != currentCaption; - - if (hasMetadataChanged || currentCollectionsSet != newCollectionsSet) { - final dialog = createProgressDialog( - context, - context.l10n.pleaseWait, - isDismissible: false, - ); - await dialog.show(); - - try { - final List> apiCalls = []; - for (final collection in collectionsToAdd) { - apiCalls.add( - CollectionService.instance.addToCollection(collection, file), - ); - } - await Future.wait(apiCalls); - apiCalls.clear(); - - for (final collection in collectionsToRemove) { - apiCalls.add( - CollectionService.instance - .move(file, collection, newCollectionsSet.first), - ); - } - if (hasMetadataChanged) { - apiCalls.add( - MetadataUpdaterService.instance - .editFileNameAndCaption(file, result.title, result.caption), - ); - } - await Future.wait(apiCalls); - - await dialog.hide(); - - SnackBarUtils.showInfoSnackBar( - context, - context.l10n.fileUpdatedSuccessfully, - ); - } catch (e) { - await dialog.hide(); - - SnackBarUtils.showWarningSnackBar( - context, - context.l10n.failedToUpdateFile(e.toString()), - ); - } - } else { - SnackBarUtils.showWarningSnackBar( - context, - context.l10n.noChangesWereMade, - ); - } - } - } - - Future _openFile(BuildContext context) async { - if (file.localPath != null) { - final localFile = File(file.localPath!); - if (await localFile.exists()) { - await _launchFile(context, localFile, file.displayName); - return; - } - } - - final String cachedFilePath = - "${Configuration.instance.getCacheDirectory()}${file.displayName}"; - final File cachedFile = File(cachedFilePath); - if (await cachedFile.exists()) { - await _launchFile(context, cachedFile, file.displayName); - return; - } - - final dialog = createProgressDialog( - context, - context.l10n.downloading, - isDismissible: false, - ); - - try { - await dialog.show(); - final fileKey = await CollectionService.instance.getFileKey(file); - final decryptedFile = await downloadAndDecrypt( - file, - fileKey, - progressCallback: (downloaded, total) { - if (total > 0 && downloaded >= 0) { - final percentage = - ((downloaded / total) * 100).clamp(0, 100).round(); - dialog.update( - message: context.l10n.downloadingProgress(percentage), - ); - } else { - dialog.update(message: context.l10n.downloading); - } - }, - shouldUseCache: true, - ); - - await dialog.hide(); - - if (decryptedFile != null) { - await _launchFile(context, decryptedFile, file.displayName); - } else { - await showErrorDialog( - context, - context.l10n.downloadFailed, - context.l10n.failedToDownloadOrDecrypt, - ); - } - } catch (e) { - await dialog.hide(); - await showErrorDialog( - context, - context.l10n.errorOpeningFile, - context.l10n.errorOpeningFileMessage(e.toString()), - ); - } - } - - Future _launchFile( - BuildContext context, - File file, - String fileName, - ) async { - try { - await OpenFile.open(file.path); - } catch (e) { - await showErrorDialog( - context, - context.l10n.errorOpeningFile, - context.l10n.couldNotOpenFile(e.toString()), - ); - } - } -} - -class _CopyButton extends StatefulWidget { - final String url; - - const _CopyButton({ - required this.url, - }); - - @override - State<_CopyButton> createState() => _CopyButtonState(); -} - -class _CopyButtonState extends State<_CopyButton> { - bool _isCopied = false; - - @override - Widget build(BuildContext context) { - final colorScheme = getEnteColorScheme(context); - - return IconButton( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: widget.url)); - setState(() { - _isCopied = true; - }); - // Reset the state after 2 seconds - Future.delayed(const Duration(seconds: 2), () { - if (mounted) { - setState(() { - _isCopied = false; - }); - } - }); - }, - icon: Icon( - _isCopied ? Icons.check : Icons.copy, - size: 16, - color: _isCopied ? colorScheme.primary500 : colorScheme.primary500, - ), - iconSize: 16, - constraints: const BoxConstraints(), - padding: const EdgeInsets.all(4), - tooltip: _isCopied - ? context.l10n.linkCopiedToClipboard - : context.l10n.copyLink, - ); - } -} - class FileListViewHelpers { static Widget createSearchEmptyState({ required String searchQuery, diff --git a/mobile/apps/locker/lib/ui/components/user_dialogs.dart b/mobile/apps/locker/lib/ui/components/user_dialogs.dart new file mode 100644 index 0000000000..206a30fa70 --- /dev/null +++ b/mobile/apps/locker/lib/ui/components/user_dialogs.dart @@ -0,0 +1,32 @@ +import "dart:async"; + +import "package:ente_ui/components/buttons/button_widget.dart"; +import "package:ente_ui/components/buttons/models/button_type.dart"; +import "package:ente_ui/components/dialog_widget.dart"; +import "package:ente_utils/share_utils.dart"; +import "package:flutter/material.dart"; +import "package:locker/l10n/l10n.dart"; +Future showInviteDialog(BuildContext context, String email) async { + await showDialogWidget( + context: context, + title: context.l10n.inviteToEnte, + icon: Icons.info_outline, + body: context.l10n.emailNoEnteAccount(email), + isDismissible: true, + buttons: [ + ButtonWidget( + buttonType: ButtonType.neutral, + icon: Icons.adaptive.share, + labelText: context.l10n.sendInvite, + isInAlert: true, + onTap: () async { + unawaited( + shareText( + context.l10n.shareTextRecommendUsingEnte, + ), + ); + }, + ), + ], + ); +} diff --git a/mobile/apps/locker/lib/ui/pages/all_collections_page.dart b/mobile/apps/locker/lib/ui/pages/all_collections_page.dart index f225f4f0e2..5219137a7d 100644 --- a/mobile/apps/locker/lib/ui/pages/all_collections_page.dart +++ b/mobile/apps/locker/lib/ui/pages/all_collections_page.dart @@ -18,8 +18,19 @@ import 'package:locker/ui/pages/trash_page.dart'; import 'package:locker/utils/collection_sort_util.dart'; import 'package:logging/logging.dart'; +enum UISectionType { + incomingCollections, + outgoingCollections, + homeCollections, +} + class AllCollectionsPage extends StatefulWidget { - const AllCollectionsPage({super.key}); + final UISectionType viewType; + + const AllCollectionsPage({ + super.key, + this.viewType = UISectionType.homeCollections, + }); @override State createState() => _AllCollectionsPageState(); @@ -34,6 +45,8 @@ class _AllCollectionsPageState extends State List _allFiles = []; bool _isLoading = true; String? _error; + bool showTrash = false; + bool showUncategorized = false; final _logger = Logger("AllCollectionsPage"); @override @@ -68,6 +81,10 @@ class _AllCollectionsPageState extends State Bus.instance.on().listen((event) async { await _loadCollections(); }); + if (widget.viewType == UISectionType.homeCollections) { + showTrash = true; + showUncategorized = true; + } } Future _loadCollections() async { @@ -77,7 +94,19 @@ class _AllCollectionsPageState extends State }); try { - final collections = await CollectionService.instance.getCollections(); + List collections = []; + + if (widget.viewType == UISectionType.homeCollections) { + collections = await CollectionService.instance.getCollections(); + } else { + final sharedCollections = + await CollectionService.instance.getSharedCollections(); + if (widget.viewType == UISectionType.outgoingCollections) { + collections = sharedCollections.outgoing; + } else if (widget.viewType == UISectionType.incomingCollections) { + collections = sharedCollections.incoming; + } + } final regularCollections = []; Collection? uncategorized; @@ -94,8 +123,12 @@ class _AllCollectionsPageState extends State _allCollections = List.from(collections); _sortedCollections = List.from(regularCollections); - _uncategorizedCollection = uncategorized; - _uncategorizedFileCount = uncategorized != null + _uncategorizedCollection = + widget.viewType == UISectionType.homeCollections + ? uncategorized + : null; + _uncategorizedFileCount = uncategorized != null && + widget.viewType == UISectionType.homeCollections ? (await CollectionService.instance .getFilesInCollection(uncategorized)) .length @@ -122,7 +155,7 @@ class _AllCollectionsPageState extends State child: Scaffold( appBar: AppBar( leading: buildSearchLeading(), - title: Text(context.l10n.collections), + title: Text(_getTitle(context)), centerTitle: false, backgroundColor: Theme.of(context).scaffoldBackgroundColor, foregroundColor: Theme.of(context).textTheme.bodyLarge?.color, @@ -237,9 +270,11 @@ class _AllCollectionsPageState extends State enableSorting: true, ), ), - if (!isSearchActive && _uncategorizedCollection != null) + if (!isSearchActive && + _uncategorizedCollection != null && + showUncategorized) _buildUncategorizedHook(), - _buildTrashHook(), + if (showTrash) _buildTrashHook(), ], ), ); @@ -254,9 +289,9 @@ class _AllCollectionsPageState extends State child: Container( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(0.3), + color: Theme.of(context).colorScheme.surface.withAlpha(30), border: Border.all( - color: Theme.of(context).dividerColor.withOpacity(0.5), + color: Theme.of(context).dividerColor.withAlpha(50), width: 0.5, ), borderRadius: BorderRadius.circular(12.0), @@ -265,11 +300,8 @@ class _AllCollectionsPageState extends State children: [ Icon( Icons.delete_outline, - color: Theme.of(context) - .textTheme - .bodyLarge - ?.color - ?.withOpacity(0.7), + color: + Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(70), size: 22, ), const SizedBox(width: 12), @@ -287,7 +319,7 @@ class _AllCollectionsPageState extends State .textTheme .bodyMedium ?.color - ?.withOpacity(0.6), + ?.withAlpha(60), size: 20, ), ], @@ -326,9 +358,9 @@ class _AllCollectionsPageState extends State child: Container( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(0.3), + color: Theme.of(context).colorScheme.surface.withAlpha(30), border: Border.all( - color: Theme.of(context).dividerColor.withOpacity(0.5), + color: Theme.of(context).dividerColor.withAlpha(50), width: 0.5, ), borderRadius: BorderRadius.circular(12.0), @@ -337,11 +369,8 @@ class _AllCollectionsPageState extends State children: [ Icon( Icons.folder_open_outlined, - color: Theme.of(context) - .textTheme - .bodyLarge - ?.color - ?.withOpacity(0.7), + color: + Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(70), size: 22, ), const SizedBox(width: 12), @@ -363,7 +392,7 @@ class _AllCollectionsPageState extends State .textTheme .bodySmall ?.color - ?.withOpacity(0.5), + ?.withAlpha(50), ), ), const SizedBox(width: 8), @@ -374,7 +403,7 @@ class _AllCollectionsPageState extends State .textTheme .bodySmall ?.color - ?.withOpacity(0.7), + ?.withAlpha(70), ), ), ], @@ -387,7 +416,7 @@ class _AllCollectionsPageState extends State .textTheme .bodyMedium ?.color - ?.withOpacity(0.6), + ?.withAlpha(60), size: 20, ), ], @@ -405,4 +434,15 @@ class _AllCollectionsPageState extends State ), ); } + + String _getTitle(BuildContext context) { + switch (widget.viewType) { + case UISectionType.homeCollections: + return context.l10n.collections; + case UISectionType.outgoingCollections: + return context.l10n.sharedByYou; + case UISectionType.incomingCollections: + return context.l10n.sharedWithYou; + } + } } diff --git a/mobile/apps/locker/lib/ui/pages/collection_page.dart b/mobile/apps/locker/lib/ui/pages/collection_page.dart index 4185890b45..65204e88cc 100644 --- a/mobile/apps/locker/lib/ui/pages/collection_page.dart +++ b/mobile/apps/locker/lib/ui/pages/collection_page.dart @@ -1,17 +1,27 @@ +import "dart:async"; + import 'package:ente_events/event_bus.dart'; import 'package:ente_ui/theme/ente_theme.dart'; +import "package:ente_ui/utils/dialog_util.dart"; +import "package:ente_utils/navigation_util.dart"; import 'package:flutter/material.dart'; import 'package:locker/events/collections_updated_event.dart'; import 'package:locker/l10n/l10n.dart'; import 'package:locker/services/collections/collections_service.dart'; import 'package:locker/services/collections/models/collection.dart'; +import "package:locker/services/collections/models/collection_view_type.dart"; +import "package:locker/services/configuration.dart"; import 'package:locker/services/files/sync/models/file.dart'; import 'package:locker/ui/components/item_list_view.dart'; import 'package:locker/ui/components/search_result_view.dart'; import 'package:locker/ui/mixins/search_mixin.dart'; import 'package:locker/ui/pages/home_page.dart'; import 'package:locker/ui/pages/uploader_page.dart'; +import "package:locker/ui/sharing/album_participants_page.dart"; +import "package:locker/ui/sharing/manage_links_widget.dart"; +import "package:locker/ui/sharing/share_collection_page.dart"; import 'package:locker/utils/collection_actions.dart'; +import "package:logging/logging.dart"; class CollectionPage extends UploaderPage { final Collection collection; @@ -27,9 +37,16 @@ class CollectionPage extends UploaderPage { class _CollectionPageState extends UploaderPageState with SearchMixin { + final _logger = Logger("CollectionPage"); + late StreamSubscription + _collectionUpdateSubscription; + late Collection _collection; List _files = []; List _filteredFiles = []; + late CollectionViewType collectionViewType; + bool isQuickLink = false; + bool showFAB = true; @override void onFileUploadComplete() { @@ -51,7 +68,9 @@ class _CollectionPageState extends UploaderPageState @override void onSearchResultsChanged( - List collections, List files,) { + List collections, + List files, + ) { setState(() { _filteredFiles = files; }); @@ -66,6 +85,12 @@ class _CollectionPageState extends UploaderPageState } } + @override + void dispose() { + _collectionUpdateSubscription.cancel(); + super.dispose(); + } + List get _displayedFiles => isSearchActive ? _filteredFiles : _files; @@ -73,14 +98,40 @@ class _CollectionPageState extends UploaderPageState void initState() { super.initState(); _initializeData(widget.collection); - Bus.instance.on().listen((event) async { - final collection = (await CollectionService.instance.getCollections()) - .where( - (c) => c.id == widget.collection.id, - ) - .first; - await _initializeData(collection); + _collectionUpdateSubscription = + Bus.instance.on().listen((event) async { + if (!mounted) return; + + try { + final collections = await CollectionService.instance.getCollections(); + + final matchingCollection = collections.where( + (c) => c.id == widget.collection.id, + ); + + if (matchingCollection.isNotEmpty) { + await _initializeData(matchingCollection.first); + } else { + _logger.warning( + 'Collection ${widget.collection.id} no longer exists, navigating back', + ); + if (mounted) { + Navigator.of(context).pop(); + } + } + } catch (e) { + _logger.severe('Error updating collection: $e'); + } }); + + collectionViewType = getCollectionViewType( + _collection, + Configuration.instance.getUserID()!, + ); + + showFAB = collectionViewType == CollectionViewType.ownedCollection || + collectionViewType == CollectionViewType.hiddenOwnedCollection || + collectionViewType == CollectionViewType.quickLink; } Future _initializeData(Collection collection) async { @@ -112,6 +163,48 @@ class _CollectionPageState extends UploaderPageState ); } + Future _shareCollection() async { + final collection = widget.collection; + try { + if ((collectionViewType != CollectionViewType.ownedCollection && + collectionViewType != CollectionViewType.sharedCollection && + collectionViewType != CollectionViewType.hiddenOwnedCollection && + collectionViewType != CollectionViewType.favorite && + !isQuickLink)) { + throw Exception( + "Cannot share collection of type $collectionViewType", + ); + } + if (Configuration.instance.getUserID() == collection.owner.id) { + unawaited( + routeToPage( + context, + (isQuickLink && (collection.hasLink)) + ? ManageSharedLinkWidget(collection: collection) + : ShareCollectionPage(collection: collection), + ), + ); + } else { + unawaited( + routeToPage( + context, + AlbumParticipantsPage(collection), + ), + ); + } + } catch (e, s) { + _logger.severe(e, s); + await showGenericErrorDialog(context: context, error: e); + } + } + + Future _leaveCollection() async { + await CollectionActions.leaveCollection( + context, + _collection, + ); + } + @override Widget build(BuildContext context) { return KeyboardListener( @@ -139,6 +232,14 @@ class _CollectionPageState extends UploaderPageState actions: [ buildSearchAction(), ...buildSearchActions(), + IconButton( + icon: Icon( + Icons.adaptive.share, + ), + onPressed: () async { + await _shareCollection(); + }, + ), _buildMenuButton(), ], ); @@ -155,33 +256,53 @@ class _CollectionPageState extends UploaderPageState case 'delete': _deleteCollection(); break; + case 'leave_collection': + _leaveCollection(); + break; } }, itemBuilder: (BuildContext context) { return [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: 12), - Text(context.l10n.edit), - ], + if (collectionViewType == CollectionViewType.ownedCollection || + collectionViewType == CollectionViewType.hiddenOwnedCollection || + collectionViewType == CollectionViewType.quickLink) + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 12), + Text(context.l10n.edit), + ], + ), ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete, color: Colors.red), - const SizedBox(width: 12), - Text( - context.l10n.delete, - style: const TextStyle(color: Colors.red), - ), - ], + if (collectionViewType == CollectionViewType.ownedCollection || + collectionViewType == CollectionViewType.hiddenOwnedCollection || + collectionViewType == CollectionViewType.quickLink) + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, color: Colors.red), + const SizedBox(width: 12), + Text( + context.l10n.delete, + style: const TextStyle(color: Colors.red), + ), + ], + ), + ), + if (collectionViewType == CollectionViewType.sharedCollection) + PopupMenuItem( + value: 'leave_collection', + child: Row( + children: [ + const Icon(Icons.logout), + const SizedBox(width: 12), + Text(context.l10n.leaveCollection), + ], + ), ), - ), ]; }, ); @@ -266,10 +387,12 @@ class _CollectionPageState extends UploaderPageState } Widget _buildFAB() { - return FloatingActionButton( - onPressed: addFile, - tooltip: context.l10n.addFiles, - child: const Icon(Icons.add), - ); + return showFAB + ? FloatingActionButton( + onPressed: addFile, + tooltip: context.l10n.addFiles, + child: const Icon(Icons.add), + ) + : const SizedBox.shrink(); } } diff --git a/mobile/apps/locker/lib/ui/pages/home_page.dart b/mobile/apps/locker/lib/ui/pages/home_page.dart index 90ad706866..adfb1452bf 100644 --- a/mobile/apps/locker/lib/ui/pages/home_page.dart +++ b/mobile/apps/locker/lib/ui/pages/home_page.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math'; import "package:ente_accounts/services/user_service.dart"; import 'package:ente_events/event_bus.dart'; import 'package:ente_ui/components/buttons/gradient_button.dart'; +import "package:ente_ui/components/buttons/icon_button_widget.dart"; import 'package:ente_ui/theme/ente_theme.dart'; import 'package:ente_ui/utils/dialog_util.dart'; import 'package:ente_utils/email_util.dart'; @@ -15,6 +15,8 @@ import 'package:locker/l10n/l10n.dart'; import 'package:locker/services/collections/collections_service.dart'; import 'package:locker/services/collections/models/collection.dart'; import 'package:locker/services/files/sync/models/file.dart'; +import "package:locker/ui/collections/collection_flex_grid_view.dart"; +import "package:locker/ui/collections/section_title.dart"; import 'package:locker/ui/components/recents_section_widget.dart'; import 'package:locker/ui/components/search_result_view.dart'; import 'package:locker/ui/mixins/search_mixin.dart'; @@ -24,7 +26,6 @@ import "package:locker/ui/pages/settings_page.dart"; import 'package:locker/ui/pages/uploader_page.dart'; import 'package:locker/utils/collection_actions.dart'; import 'package:locker/utils/collection_sort_util.dart'; -import "package:locker/utils/snack_bar_utils.dart"; import 'package:logging/logging.dart'; class HomePage extends UploaderPage { @@ -50,7 +51,13 @@ class _HomePageState extends UploaderPageState List _filteredCollections = []; List _recentFiles = []; List _filteredFiles = []; - Map _collectionFileCounts = {}; + List outgoingCollections = []; + List incomingCollections = []; + List quickLinks = []; + Map _outgoingCollectionFileCounts = {}; + Map _incomingCollectionFileCounts = {}; + Map _homeCollectionFileCounts = {}; + String? _error; final _logger = Logger('HomePage'); StreamSubscription? _mediaStreamSubscription; @@ -88,7 +95,17 @@ class _HomePageState extends UploaderPageState } List get _displayedCollections { - final collections = isSearchActive ? _filteredCollections : _collections; + final List collections; + if (isSearchActive) { + collections = _filteredCollections; + } else { + final excludeIds = { + ...incomingCollections.map((c) => c.id), + ...quickLinks.map((c) => c.id), + }; + collections = + _collections.where((c) => !excludeIds.contains(c.id)).toList(); + } return _filterOutUncategorized(collections); } @@ -268,10 +285,16 @@ class _HomePageState extends UploaderPageState final sortedCollections = CollectionSortUtil.getSortedCollections(collections); + final sharedCollections = + await CollectionService.instance.getSharedCollections(); + setState(() { _collections = sortedCollections; _filteredCollections = _filterOutUncategorized(sortedCollections); _filteredFiles = _recentFiles; + incomingCollections = sharedCollections.incoming; + outgoingCollections = sharedCollections.outgoing; + quickLinks = sharedCollections.quickLinks; _isLoading = false; }); @@ -491,10 +514,26 @@ class _HomePageState extends UploaderPageState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCollectionsHeader(), - const SizedBox(height: 24), - _buildCollectionsGrid(), - const SizedBox(height: 24), + ..._buildCollectionSection( + title: context.l10n.collections, + collections: _displayedCollections, + viewType: UISectionType.homeCollections, + fileCounts: _homeCollectionFileCounts, + ), + if (outgoingCollections.isNotEmpty) + ..._buildCollectionSection( + title: context.l10n.sharedByYou, + collections: outgoingCollections, + viewType: UISectionType.outgoingCollections, + fileCounts: _outgoingCollectionFileCounts, + ), + if (incomingCollections.isNotEmpty) + ..._buildCollectionSection( + title: context.l10n.sharedWithYou, + collections: incomingCollections, + viewType: UISectionType.incomingCollections, + fileCounts: _incomingCollectionFileCounts, + ), _buildRecentsSection(), ], ), @@ -557,105 +596,6 @@ class _HomePageState extends UploaderPageState } } - Widget _buildCollectionsHeader() { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - SnackBarUtils.showWarningSnackBar(context, "Hello"); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const AllCollectionsPage(), - ), - ); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.collections, - style: getEnteTextTheme(context).h3Bold, - ), - const Icon( - Icons.chevron_right, - color: Colors.grey, - ), - ], - ), - ); - } - - Widget _buildCollectionsGrid() { - return MediaQuery.removePadding( - context: context, - removeBottom: true, - removeTop: true, - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 2.2, - ), - itemCount: min(_displayedCollections.length, 4), - itemBuilder: (context, index) { - final collection = _displayedCollections[index]; - final collectionName = collection.name ?? 'Unnamed Collection'; - - return GestureDetector( - onTap: () => _navigateToCollection(collection), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: getEnteColorScheme(context).fillFaint, - ), - padding: const EdgeInsets.all(12), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - collectionName, - style: getEnteTextTheme(context).body.copyWith( - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.left, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - const SizedBox(height: 4), - Text( - context.l10n - .items(_collectionFileCounts[collection.id] ?? 0), - style: getEnteTextTheme(context).small.copyWith( - color: Colors.grey[600], - ), - textAlign: TextAlign.left, - ), - ], - ), - if (collection.type == CollectionType.favorites) - Positioned( - top: 0, - right: 0, - child: Icon( - Icons.star, - color: getEnteColorScheme(context).primary500, - size: 18, - ), - ), - ], - ), - ), - ); - }, - ), - ); - } - Widget _buildMultiOptionFab() { return ValueListenableBuilder( valueListenable: _isFabOpen, @@ -790,22 +730,79 @@ class _HomePageState extends UploaderPageState } Future _loadCollectionFileCounts() async { - final counts = {}; + final mainCounts = {}; + final outgoingCounts = {}; + final incomingCounts = {}; - for (final collection in _displayedCollections.take(4)) { - try { - final files = - await CollectionService.instance.getFilesInCollection(collection); - counts[collection.id] = files.length; - } catch (e) { - counts[collection.id] = 0; - } - } + await Future.wait([ + ..._displayedCollections.take(4).map((collection) async { + try { + final files = + await CollectionService.instance.getFilesInCollection(collection); + mainCounts[collection.id] = files.length; + } catch (e) { + mainCounts[collection.id] = 0; + } + }), + ...outgoingCollections.take(4).map((collection) async { + try { + final files = + await CollectionService.instance.getFilesInCollection(collection); + outgoingCounts[collection.id] = files.length; + } catch (e) { + outgoingCounts[collection.id] = 0; + } + }), + ...incomingCollections.take(4).map((collection) async { + try { + final files = + await CollectionService.instance.getFilesInCollection(collection); + incomingCounts[collection.id] = files.length; + } catch (e) { + incomingCounts[collection.id] = 0; + } + }), + ]); if (mounted) { setState(() { - _collectionFileCounts = counts; + _homeCollectionFileCounts = mainCounts; + _outgoingCollectionFileCounts = outgoingCounts; + _incomingCollectionFileCounts = incomingCounts; }); } } + + List _buildCollectionSection({ + required String title, + required List collections, + required UISectionType viewType, + required Map fileCounts, + }) { + return [ + SectionOptions( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AllCollectionsPage( + viewType: viewType, + ), + ), + ); + }, + SectionTitle(title: title), + trailingWidget: IconButtonWidget( + icon: Icons.chevron_right, + iconButtonType: IconButtonType.secondary, + iconColor: getEnteColorScheme(context).blurStrokePressed, + ), + ), + const SizedBox(height: 24), + CollectionFlexGridViewWidget( + collections: collections, + collectionFileCounts: fileCounts, + ), + const SizedBox(height: 24), + ]; + } } diff --git a/mobile/apps/locker/lib/ui/sharing/add_participant_page.dart b/mobile/apps/locker/lib/ui/sharing/add_participant_page.dart new file mode 100644 index 0000000000..2149bd4296 --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/add_participant_page.dart @@ -0,0 +1,468 @@ +import 'package:email_validator/email_validator.dart'; +import "package:ente_sharing/models/user.dart"; +import "package:ente_ui/components/buttons/button_widget.dart"; +import "package:ente_ui/components/buttons/models/button_type.dart"; +import "package:ente_ui/components/captioned_text_widget.dart"; +import "package:ente_ui/components/divider_widget.dart"; +import "package:ente_ui/components/menu_item_widget.dart"; +import "package:ente_ui/components/menu_section_description_widget.dart"; +import "package:ente_ui/components/menu_section_title.dart"; +import "package:ente_ui/components/separators.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_ui/utils/toast_util.dart"; +import 'package:flutter/material.dart'; +import "package:locker/extensions/user_extension.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/collections_service.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/ui/sharing/user_avator_widget.dart"; +import "package:locker/ui/sharing/verify_identity_dialog.dart"; +import "package:locker/utils/collection_actions.dart"; + +enum ActionTypesToShow { + addViewer, + addCollaborator, +} + +class AddParticipantPage extends StatefulWidget { + /// Cannot be empty + final List actionTypesToShow; + final List collections; + + AddParticipantPage( + this.collections, + this.actionTypesToShow, { + super.key, + }) : assert( + actionTypesToShow.isNotEmpty, + 'actionTypesToShow cannot be empty', + ); + + @override + State createState() => _AddParticipantPage(); +} + +class _AddParticipantPage extends State { + final _selectedEmails = {}; + String _newEmail = ''; + bool _emailIsValid = false; + bool isKeypadOpen = false; + late List _suggestedUsers; + + // Focus nodes are necessary + final textFieldFocusNode = FocusNode(); + final _textController = TextEditingController(); + + late CollectionActions collectionActions; + + @override + void initState() { + super.initState(); + _suggestedUsers = _getSuggestedUser(); + collectionActions = CollectionActions(); + } + + @override + void dispose() { + _textController.dispose(); + textFieldFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final filterSuggestedUsers = _suggestedUsers + .where( + (element) => + (element.displayName ?? element.email).toLowerCase().contains( + _textController.text.trim().toLowerCase(), + ), + ) + .toList(); + isKeypadOpen = MediaQuery.viewInsetsOf(context).bottom > 100; + final enteTextTheme = getEnteTextTheme(context); + final enteColorScheme = getEnteColorScheme(context); + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + title: Text( + _getTitle(), + ), + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + context.l10n.addANewEmail, + style: enteTextTheme.small + .copyWith(color: enteColorScheme.textMuted), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _enterEmailField(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + filterSuggestedUsers.isNotEmpty + ? MenuSectionTitle( + title: context.l10n.orPickAnExistingOne, + ) + : const SizedBox.shrink(), + Expanded( + child: ListView.builder( + physics: const BouncingScrollPhysics(), + itemBuilder: (context, index) { + if (index >= filterSuggestedUsers.length) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + filterSuggestedUsers.isNotEmpty + ? MenuSectionDescriptionWidget( + content: context.l10n + .longPressAnEmailToVerifyEndToEndEncryption, + ) + : const SizedBox.shrink(), + widget.actionTypesToShow.contains( + ActionTypesToShow.addCollaborator, + ) + ? MenuSectionDescriptionWidget( + content: context.l10n + .collaboratorsCanAddFilesToTheSharedCollection, + ) + : const SizedBox.shrink(), + ], + ), + ); + } + final currentUser = filterSuggestedUsers[index]; + return Column( + children: [ + MenuItemWidget( + key: ValueKey( + currentUser.displayName ?? currentUser.email, + ), + captionedTextWidget: CaptionedTextWidget( + title: currentUser.displayName ?? + currentUser.email, + ), + leadingIconSize: 24.0, + leadingIconWidget: UserAvatarWidget( + currentUser, + type: AvatarType.mini, + ), + menuItemColor: + getEnteColorScheme(context).fillFaint, + pressedColor: + getEnteColorScheme(context).fillFaint, + trailingIcon: + (_selectedEmails.contains(currentUser.email)) + ? Icons.check + : null, + onTap: () async { + textFieldFocusNode.unfocus(); + if (_selectedEmails + .contains(currentUser.email)) { + _selectedEmails.remove(currentUser.email); + } else { + _selectedEmails.add(currentUser.email); + } + + setState(() => {}); + // showShortToast(context, "yet to implement"); + }, + onLongPress: () { + showDialog( + useRootNavigator: false, + context: context, + builder: (BuildContext context) { + return VerifyIdentifyDialog( + self: false, + email: currentUser.email, + ); + }, + ); + }, + isTopBorderRadiusRemoved: index > 0, + isBottomBorderRadiusRemoved: + index < (filterSuggestedUsers.length - 1), + ), + (index == (filterSuggestedUsers.length - 1)) + ? const SizedBox.shrink() + : DividerWidget( + dividerType: DividerType.menu, + bgColor: + getEnteColorScheme(context).fillFaint, + ), + ], + ); + }, + itemCount: filterSuggestedUsers.length + 1, + ), + ), + ], + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.only( + top: 8, + bottom: 8, + left: 16, + right: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 8), + ..._actionButtons(), + const SizedBox(height: 12), + ], + ), + ), + ), + ], + ), + ); + } + + List _actionButtons() { + final widgets = []; + if (widget.actionTypesToShow.contains(ActionTypesToShow.addViewer)) { + widgets.add( + ButtonWidget( + buttonType: ButtonType.primary, + buttonSize: ButtonSize.large, + labelText: context.l10n.addViewers(_selectedEmails.length), + isDisabled: _selectedEmails.isEmpty, + onTap: () async { + final results = []; + final collections = widget.collections; + + for (String email in _selectedEmails) { + bool result = false; + for (Collection collection in collections) { + result = await collectionActions.addEmailToCollection( + context, + collection, + email, + CollectionParticipantRole.viewer, + ); + } + results.add(result); + } + + final noOfSuccessfullAdds = results.where((e) => e).length; + showToast( + context, + context.l10n.viewersSuccessfullyAdded(noOfSuccessfullAdds), + ); + + if (!results.any((e) => e == false) && mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ); + } + if (widget.actionTypesToShow.contains( + ActionTypesToShow.addCollaborator, + )) { + widgets.add( + ButtonWidget( + buttonType: + widget.actionTypesToShow.contains(ActionTypesToShow.addViewer) + ? ButtonType.neutral + : ButtonType.primary, + buttonSize: ButtonSize.large, + labelText: context.l10n.addCollaborators(_selectedEmails.length), + isDisabled: _selectedEmails.isEmpty, + onTap: () async { + // TODO: This is not currently designed for best UX for action on + // multiple collections and emails, especially if some operations + // fail. Can be improved by using a different 'addEmailToCollection' + // that accepts list of emails and list of collections. + final results = []; + final collections = widget.collections; + + for (String email in _selectedEmails) { + bool result = false; + for (Collection collection in collections) { + result = await collectionActions.addEmailToCollection( + context, + collection, + email, + CollectionParticipantRole.collaborator, + ); + } + results.add(result); + } + + final noOfSuccessfullAdds = results.where((e) => e).length; + showToast( + context, + context.l10n.collaboratorsSuccessfullyAdded(noOfSuccessfullAdds), + ); + + if (!results.any((e) => e == false) && mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ); + } + final widgetsWithSpaceBetween = addSeparators( + widgets, + const SizedBox( + height: 8, + ), + ); + return widgetsWithSpaceBetween; + } + + void clearFocus() { + _textController.clear(); + _newEmail = _textController.text; + _emailIsValid = false; + textFieldFocusNode.unfocus(); + setState(() => {}); + } + + Widget _enterEmailField() { + return Row( + children: [ + Expanded( + child: TextFormField( + controller: _textController, + focusNode: textFieldFocusNode, + style: getEnteTextTheme(context).body, + autofillHints: const [AutofillHints.email], + decoration: InputDecoration( + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + borderSide: + BorderSide(color: getEnteColorScheme(context).strokeMuted), + ), + fillColor: getEnteColorScheme(context).fillFaint, + filled: true, + hintText: context.l10n.enterEmail, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(4), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: getEnteColorScheme(context).strokeMuted, + ), + suffixIcon: _newEmail == '' + ? null + : IconButton( + onPressed: clearFocus, + icon: Icon( + Icons.cancel, + color: getEnteColorScheme(context).strokeMuted, + ), + ), + ), + onChanged: (value) { + _newEmail = value.trim(); + _emailIsValid = EmailValidator.validate(_newEmail); + setState(() {}); + }, + autocorrect: false, + keyboardType: TextInputType.emailAddress, + //initialValue: _email, + textInputAction: TextInputAction.next, + ), + ), + const SizedBox(width: 8), + ButtonWidget( + buttonType: ButtonType.secondary, + buttonSize: ButtonSize.small, + labelText: context.l10n.add, + isDisabled: !_emailIsValid, + onTap: () async { + if (_emailIsValid) { + final result = await collectionActions.doesEmailHaveAccount( + context, + _newEmail, + ); + if (result && mounted) { + setState(() { + for (var suggestedUser in _suggestedUsers) { + if (suggestedUser.email == _newEmail) { + _selectedEmails.add(suggestedUser.email); + clearFocus(); + + return; + } + } + _suggestedUsers.insert(0, User(email: _newEmail)); + _selectedEmails.add(_newEmail); + clearFocus(); + }); + } + } + }, + ), + ], + ); + } + + List _getSuggestedUser() { + final Set existingEmails = {}; + final collections = widget.collections; + if (collections.isEmpty) { + return []; + } + + for (final Collection collection in collections) { + for (final User u in collection.sharees) { + if (u.id != null && u.email.isNotEmpty) { + existingEmails.add(u.email); + } + } + } + + final List suggestedUsers = + CollectionService.instance.getRelevantContacts(); + + if (_textController.text.trim().isNotEmpty) { + suggestedUsers.removeWhere( + (element) => !(element.displayName ?? element.email) + .toLowerCase() + .contains(_textController.text.trim().toLowerCase()), + ); + } + suggestedUsers.sort((a, b) => a.email.compareTo(b.email)); + + return suggestedUsers; + } + + String _getTitle() { + if (widget.actionTypesToShow.length > 1) { + return context.l10n.addParticipants; + } else if (widget.actionTypesToShow.first == ActionTypesToShow.addViewer) { + return context.l10n.addViewer; + } else { + return context.l10n.addCollaborator; + } + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/album_participants_page.dart b/mobile/apps/locker/lib/ui/sharing/album_participants_page.dart new file mode 100644 index 0000000000..bfe88cf813 --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/album_participants_page.dart @@ -0,0 +1,307 @@ +import "package:ente_sharing/models/user.dart"; +import "package:ente_ui/components/captioned_text_widget.dart"; +import "package:ente_ui/components/divider_widget.dart"; +import "package:ente_ui/components/menu_item_widget.dart"; +import "package:ente_ui/components/menu_section_title.dart"; +import "package:ente_ui/components/title_bar_title_widget.dart"; +import "package:ente_ui/components/title_bar_widget.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_utils/ente_utils.dart"; +import 'package:flutter/material.dart'; +import "package:locker/extensions/user_extension.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/services/configuration.dart"; +import "package:locker/ui/sharing/add_participant_page.dart"; +import "package:locker/ui/sharing/manage_album_participant.dart"; +import "package:locker/ui/sharing/user_avator_widget.dart"; + +class AlbumParticipantsPage extends StatefulWidget { + final Collection collection; + + const AlbumParticipantsPage( + this.collection, { + super.key, + }); + + @override + State createState() => _AlbumParticipantsPageState(); +} + +class _AlbumParticipantsPageState extends State { + late int currentUserID; + + @override + void initState() { + currentUserID = Configuration.instance.getUserID()!; + super.initState(); + } + + Future _navigateToManageUser(User user) async { + if (user.id == currentUserID) { + return; + } + await routeToPage( + context, + ManageIndividualParticipant(collection: widget.collection, user: user), + ); + if (mounted) { + setState(() => {}); + } + } + + Future _navigateToAddUser(bool addingViewer) async { + await routeToPage( + context, + AddParticipantPage( + [widget.collection], + addingViewer + ? [ActionTypesToShow.addViewer] + : [ActionTypesToShow.addCollaborator], + ), + ); + if (mounted) { + setState(() => {}); + } + } + + @override + Widget build(BuildContext context) { + final isOwner = + widget.collection.owner.id == Configuration.instance.getUserID(); + final colorScheme = getEnteColorScheme(context); + final currentUserID = Configuration.instance.getUserID()!; + final int participants = 1 + widget.collection.getSharees().length; + final User owner = widget.collection.owner; + if (owner.id == currentUserID && owner.email == "") { + owner.email = Configuration.instance.getEmail()!; + } + final splitResult = + widget.collection.getSharees().splitMatch((x) => x.isViewer); + final List viewers = splitResult.matched; + viewers.sort((a, b) => a.email.compareTo(b.email)); + final List collaborators = splitResult.unmatched; + collaborators.sort((a, b) => a.email.compareTo(b.email)); + + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: widget.collection.name, + ), + flexibleSpaceCaption: + context.l10n.albumParticipantsCount(participants), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.only(top: 20, left: 16, right: 16), + child: Column( + children: [ + Column( + children: [ + MenuSectionTitle( + title: context.l10n.albumOwner, + iconData: Icons.admin_panel_settings_outlined, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: isOwner + ? context.l10n.you + : _nameIfAvailableElseEmail( + widget.collection.owner, + ), + makeTextBold: isOwner, + ), + leadingIconWidget: UserAvatarWidget( + owner, + currentUserID: currentUserID, + ), + leadingIconSize: 24, + menuItemColor: colorScheme.fillFaint, + singleBorderRadius: 8, + isGestureDetectorDisabled: true, + ), + ], + ), + ], + ), + ); + }, + childCount: 1, + ), + ), + SliverPadding( + padding: const EdgeInsets.only(top: 20, left: 16, right: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0 && (isOwner || collaborators.isNotEmpty)) { + return MenuSectionTitle( + title: context.l10n.collaborator, + iconData: Icons.edit_outlined, + ); + } else if (index > 0 && index <= collaborators.length) { + final listIndex = index - 1; + final currentUser = collaborators[listIndex]; + final isSameAsLoggedInUser = + currentUserID == currentUser.id; + final isLastItem = + !isOwner && index == collaborators.length; + return Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: isSameAsLoggedInUser + ? context.l10n.you + : _nameIfAvailableElseEmail(currentUser), + makeTextBold: isSameAsLoggedInUser, + ), + leadingIconSize: 24.0, + leadingIconWidget: UserAvatarWidget( + currentUser, + type: AvatarType.mini, + currentUserID: currentUserID, + ), + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingIcon: isOwner ? Icons.chevron_right : null, + trailingIconIsMuted: true, + onTap: isOwner + ? () async { + if (isOwner) { + // ignore: unawaited_futures + _navigateToManageUser(currentUser); + } + } + : null, + isTopBorderRadiusRemoved: listIndex > 0, + isBottomBorderRadiusRemoved: !isLastItem, + singleBorderRadius: 8, + ), + isLastItem + ? const SizedBox.shrink() + : DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ], + ); + } else if (index == (1 + collaborators.length) && isOwner) { + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: collaborators.isNotEmpty + ? context.l10n.addMore + : context.l10n.addCollaborator, + makeTextBold: true, + ), + leadingIcon: Icons.add_outlined, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + // ignore: unawaited_futures + _navigateToAddUser(false); + }, + isTopBorderRadiusRemoved: collaborators.isNotEmpty, + singleBorderRadius: 8, + ); + } + return const SizedBox.shrink(); + }, + childCount: 1 + collaborators.length + 1, + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.only(top: 24, left: 16, right: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0 && (isOwner || viewers.isNotEmpty)) { + return MenuSectionTitle( + title: context.l10n.viewer, + iconData: Icons.photo_outlined, + ); + } else if (index > 0 && index <= viewers.length) { + final listIndex = index - 1; + final currentUser = viewers[listIndex]; + final isSameAsLoggedInUser = + currentUserID == currentUser.id; + final isLastItem = !isOwner && index == viewers.length; + return Column( + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: isSameAsLoggedInUser + ? context.l10n.you + : _nameIfAvailableElseEmail(currentUser), + makeTextBold: isSameAsLoggedInUser, + ), + leadingIconSize: 24.0, + leadingIconWidget: UserAvatarWidget( + currentUser, + type: AvatarType.mini, + currentUserID: currentUserID, + ), + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingIcon: isOwner ? Icons.chevron_right : null, + trailingIconIsMuted: true, + onTap: isOwner + ? () async { + if (isOwner) { + // ignore: unawaited_futures + _navigateToManageUser(currentUser); + } + } + : null, + isTopBorderRadiusRemoved: listIndex > 0, + isBottomBorderRadiusRemoved: !isLastItem, + singleBorderRadius: 8, + ), + isLastItem + ? const SizedBox.shrink() + : DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ], + ); + } else if (index == (1 + viewers.length) && isOwner) { + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: viewers.isNotEmpty + ? context.l10n.addMore + : context.l10n.addViewer, + makeTextBold: true, + ), + leadingIcon: Icons.add_outlined, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + // ignore: unawaited_futures + _navigateToAddUser(true); + }, + isTopBorderRadiusRemoved: viewers.isNotEmpty, + singleBorderRadius: 8, + ); + } + return const SizedBox.shrink(); + }, + childCount: 1 + viewers.length + 1, + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 72)), + ], + ), + ); + } + + String _nameIfAvailableElseEmail(User user) { + final name = user.displayName; + if (name != null && name.isNotEmpty) { + return name; + } + return user.email; + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/album_share_info_widget.dart b/mobile/apps/locker/lib/ui/sharing/album_share_info_widget.dart new file mode 100644 index 0000000000..a0fbf911c0 --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/album_share_info_widget.dart @@ -0,0 +1,102 @@ +import "dart:math"; + +import "package:ente_sharing/models/user.dart"; +import "package:flutter/material.dart"; +import "package:locker/ui/sharing/more_count_badge.dart"; +import "package:locker/ui/sharing/user_avator_widget.dart"; + +class AlbumSharesIcons extends StatelessWidget { + final List sharees; + final int limitCountTo; + final AvatarType type; + final bool removeBorder; + final EdgeInsets padding; + final Widget? trailingWidget; + final Alignment stackAlignment; + + const AlbumSharesIcons({ + super.key, + required this.sharees, + this.type = AvatarType.tiny, + this.limitCountTo = 2, + this.removeBorder = true, + this.trailingWidget, + this.padding = const EdgeInsets.only(left: 10.0, top: 10, bottom: 10), + this.stackAlignment = Alignment.topLeft, + }); + + @override + Widget build(BuildContext context) { + final displayCount = min(sharees.length, limitCountTo); + final hasMore = sharees.length > limitCountTo; + final double overlapPadding = getOverlapPadding(type); + final widgets = List.generate( + displayCount, + (index) => Positioned( + left: overlapPadding * index, + child: UserAvatarWidget( + sharees[index], + thumbnailView: removeBorder, + type: type, + ), + ), + ); + + if (hasMore) { + widgets.add( + Positioned( + left: (overlapPadding * displayCount), + child: MoreCountWidget( + sharees.length - displayCount, + type: moreCountTypeFromAvatarType(type), + thumbnailView: removeBorder, + ), + ), + ); + } + if (trailingWidget != null) { + widgets.add( + Positioned( + left: (overlapPadding * (displayCount + (hasMore ? 1 : 0))) + + (displayCount > 0 ? 12 : 0), + child: trailingWidget!, + ), + ); + } + + return Padding( + padding: padding, + child: Stack( + alignment: stackAlignment, + clipBehavior: Clip.none, + children: widgets, + ), + ); + } +} + +double getOverlapPadding(AvatarType type) { + switch (type) { + case AvatarType.extra: + return 14.0; + case AvatarType.tiny: + return 14.0; + case AvatarType.mini: + return 20.0; + case AvatarType.small: + return 28.0; + } +} + +MoreCountType moreCountTypeFromAvatarType(AvatarType type) { + switch (type) { + case AvatarType.extra: + return MoreCountType.extra; + case AvatarType.tiny: + return MoreCountType.tiny; + case AvatarType.mini: + return MoreCountType.mini; + case AvatarType.small: + return MoreCountType.small; + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/manage_album_participant.dart b/mobile/apps/locker/lib/ui/sharing/manage_album_participant.dart new file mode 100644 index 0000000000..4501f7d174 --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/manage_album_participant.dart @@ -0,0 +1,188 @@ +import "package:ente_sharing/models/user.dart"; +import "package:ente_ui/components/buttons/button_widget.dart"; +import "package:ente_ui/components/captioned_text_widget.dart"; +import "package:ente_ui/components/divider_widget.dart"; +import "package:ente_ui/components/menu_item_widget.dart"; +import "package:ente_ui/components/menu_section_description_widget.dart"; +import "package:ente_ui/components/menu_section_title.dart"; +import "package:ente_ui/components/title_bar_title_widget.dart"; +import "package:ente_ui/theme/colors.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_ui/utils/dialog_util.dart"; +import 'package:flutter/material.dart'; +import "package:locker/extensions/user_extension.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/utils/collection_actions.dart"; + +class ManageIndividualParticipant extends StatefulWidget { + final Collection collection; + final User user; + + const ManageIndividualParticipant({ + super.key, + required this.collection, + required this.user, + }); + + @override + State createState() => _ManageIndividualParticipantState(); +} + +class _ManageIndividualParticipantState + extends State { + late CollectionActions collectionActions; + + @override + void initState() { + super.initState(); + collectionActions = CollectionActions(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final textTheme = getEnteTextTheme(context); + bool isConvertToViewSuccess = false; + return Scaffold( + appBar: AppBar(), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 12, + ), + TitleBarTitleWidget( + title: context.l10n.manage, + ), + Text( + widget.user.email, + textAlign: TextAlign.left, + style: + textTheme.small.copyWith(color: colorScheme.textMuted), + ), + ], + ), + ), + const SizedBox(height: 12), + MenuSectionTitle(title: context.l10n.addedAs), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.collaborator, + ), + leadingIcon: Icons.edit_outlined, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingIcon: widget.user.isCollaborator ? Icons.check : null, + onTap: widget.user.isCollaborator + ? null + : () async { + final result = + await collectionActions.addEmailToCollection( + context, + widget.collection, + widget.user.email, + CollectionParticipantRole.collaborator, + ); + if (result && mounted) { + widget.user.role = CollectionParticipantRole + .collaborator + .toStringVal(); + setState(() => {}); + } + }, + isBottomBorderRadiusRemoved: true, + ), + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.viewer, + ), + leadingIcon: Icons.photo_outlined, + leadingIconColor: getEnteColorScheme(context).strokeBase, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingIcon: widget.user.isViewer ? Icons.check : null, + showOnlyLoadingState: true, + onTap: widget.user.isViewer + ? null + : () async { + final actionResult = await showChoiceActionSheet( + context, + title: context.l10n.changePermissions, + firstButtonLabel: context.l10n.yesConvertToViewer, + body: + context.l10n.cannotAddMoreFilesAfterBecomingViewer( + widget.user.displayName ?? widget.user.email, + ), + isCritical: true, + ); + if (actionResult?.action != null) { + if (actionResult!.action == ButtonAction.first) { + try { + isConvertToViewSuccess = + await collectionActions.addEmailToCollection( + context, + widget.collection, + widget.user.email, + CollectionParticipantRole.viewer, + ); + } catch (e) { + await showGenericErrorDialog( + context: context, + error: e, + ); + } + if (isConvertToViewSuccess && mounted) { + // reset value + isConvertToViewSuccess = false; + widget.user.role = + CollectionParticipantRole.viewer.toStringVal(); + setState(() => {}); + } + } + } + }, + isTopBorderRadiusRemoved: true, + ), + MenuSectionDescriptionWidget( + content: context.l10n.collaboratorsCanAddFilesToTheSharedAlbum, + ), + const SizedBox(height: 24), + MenuSectionTitle(title: context.l10n.removeParticipant), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.remove, + textColor: warning500, + makeTextBold: true, + ), + leadingIcon: Icons.not_interested_outlined, + leadingIconColor: warning500, + menuItemColor: getEnteColorScheme(context).fillFaint, + surfaceExecutionStates: false, + onTap: () async { + final result = await collectionActions.removeParticipant( + context, + widget.collection, + widget.user, + ); + + if ((result) && mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/manage_links_widget.dart b/mobile/apps/locker/lib/ui/sharing/manage_links_widget.dart new file mode 100644 index 0000000000..2d54d338ff --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/manage_links_widget.dart @@ -0,0 +1,353 @@ +import "dart:convert"; + +import "package:ente_crypto_dart/ente_crypto_dart.dart"; +import "package:ente_ui/components/captioned_text_widget.dart"; +import "package:ente_ui/components/divider_widget.dart"; +import "package:ente_ui/components/menu_item_widget.dart"; +import "package:ente_ui/components/menu_section_description_widget.dart"; +import "package:ente_ui/components/toggle_switch_widget.dart"; +import "package:ente_ui/theme/colors.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_ui/utils/dialog_util.dart"; +import "package:ente_ui/utils/toast_util.dart"; +import "package:ente_utils/navigation_util.dart"; +import "package:ente_utils/share_utils.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/collections_api_client.dart"; +import "package:locker/services/collections/collections_service.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/services/collections/models/public_url.dart"; +import "package:locker/ui/sharing/pickers/device_limit_picker_page.dart"; +import "package:locker/ui/sharing/pickers/link_expiry_picker_page.dart"; +import "package:locker/utils/collection_actions.dart"; +import "package:locker/utils/date_time_util.dart"; + +class ManageSharedLinkWidget extends StatefulWidget { + final Collection? collection; + + const ManageSharedLinkWidget({super.key, this.collection}); + + @override + State createState() => _ManageSharedLinkWidgetState(); +} + +class _ManageSharedLinkWidgetState extends State { + final GlobalKey sendLinkButtonKey = GlobalKey(); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final isCollectEnabled = + widget.collection!.publicURLs.firstOrNull?.enableCollect ?? false; + final isDownloadEnabled = + widget.collection!.publicURLs.firstOrNull?.enableDownload ?? true; + final isPasswordEnabled = + widget.collection!.publicURLs.firstOrNull?.passwordEnabled ?? false; + final enteColorScheme = getEnteColorScheme(context); + final PublicURL url = widget.collection!.publicURLs.firstOrNull!; + final String urlValue = + CollectionService.instance.getPublicUrl(widget.collection!); + return Scaffold( + appBar: AppBar( + elevation: 0, + title: Text(context.l10n.manageLink), + ), + body: SingleChildScrollView( + child: ListBody( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MenuItemWidget( + key: ValueKey("Allow collect $isCollectEnabled"), + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.allowAddingFiles, + ), + alignCaptionedTextToLeft: true, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => isCollectEnabled, + onChanged: () async { + await _updateUrlSettings( + context, + {'enableCollect': !isCollectEnabled}, + ); + }, + ), + ), + MenuSectionDescriptionWidget( + content: context.l10n.allowAddFilesDescription, + ), + const SizedBox(height: 24), + MenuItemWidget( + alignCaptionedTextToLeft: true, + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.linkExpiry, + subTitle: (url.hasExpiry + ? (url.isExpired + ? context.l10n.linkExpired + : context.l10n.linkEnabled) + : context.l10n.linkNeverExpires), + subTitleColor: url.isExpired ? warning500 : null, + ), + trailingIcon: Icons.chevron_right, + menuItemColor: enteColorScheme.fillFaint, + surfaceExecutionStates: false, + onTap: () async { + // ignore: unawaited_futures + routeToPage( + context, + LinkExpiryPickerPage(widget.collection!), + ).then((value) { + setState(() {}); + }); + }, + ), + url.hasExpiry + ? MenuSectionDescriptionWidget( + content: url.isExpired + ? context.l10n.expiredLinkInfo + : context.l10n.linkExpiresOn( + getFormattedTime( + DateTime.fromMicrosecondsSinceEpoch( + url.validTill, + ), + ), + ), + ) + : const SizedBox.shrink(), + const Padding(padding: EdgeInsets.only(top: 24)), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.linkDeviceLimit, + subTitle: url.deviceLimit == 0 + ? context.l10n.noDeviceLimit + : "${url.deviceLimit}", + ), + trailingIcon: Icons.chevron_right, + menuItemColor: enteColorScheme.fillFaint, + alignCaptionedTextToLeft: true, + isBottomBorderRadiusRemoved: true, + onTap: () async { + // ignore: unawaited_futures + routeToPage( + context, + DeviceLimitPickerPage(widget.collection!), + ).then((value) { + setState(() {}); + }); + }, + surfaceExecutionStates: false, + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: getEnteColorScheme(context).fillFaint, + ), + MenuItemWidget( + key: ValueKey("Allow downloads $isDownloadEnabled"), + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.allowDownloads, + ), + alignCaptionedTextToLeft: true, + isBottomBorderRadiusRemoved: true, + isTopBorderRadiusRemoved: true, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => isDownloadEnabled, + onChanged: () async { + await _updateUrlSettings( + context, + {'enableDownload': !isDownloadEnabled}, + ); + if (isDownloadEnabled) { + // ignore: unawaited_futures + showErrorDialog( + context, + context.l10n.disableDownloadWarningTitle, + context.l10n.disableDownloadWarningBody, + ); + } + }, + ), + ), + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: getEnteColorScheme(context).fillFaint, + ), + MenuItemWidget( + key: ValueKey("Password lock $isPasswordEnabled"), + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.passwordLock, + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => isPasswordEnabled, + onChanged: () async { + if (!isPasswordEnabled) { + // ignore: unawaited_futures + showTextInputDialog( + context, + title: context.l10n.setPasswordTitle, + submitButtonLabel: context.l10n.lockButtonLabel, + hintText: context.l10n.enterPassword, + isPasswordInput: true, + alwaysShowSuccessState: true, + onSubmit: (String password) async { + if (password.trim().isNotEmpty) { + final propToUpdate = + await _getEncryptedPassword( + password, + ); + await _updateUrlSettings( + context, + propToUpdate, + showProgressDialog: false, + ); + } + }, + ); + } else { + await _updateUrlSettings( + context, + {'disablePassword': true}, + ); + } + }, + ), + ), + const SizedBox( + height: 24, + ), + if (url.isExpired) + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.linkExpired, + textColor: getEnteColorScheme(context).warning500, + ), + leadingIcon: Icons.error_outline, + leadingIconColor: getEnteColorScheme(context).warning500, + menuItemColor: getEnteColorScheme(context).fillFaint, + isBottomBorderRadiusRemoved: true, + ), + if (!url.isExpired) + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.copyLink, + makeTextBold: true, + ), + leadingIcon: Icons.copy, + menuItemColor: getEnteColorScheme(context).fillFaint, + showOnlyLoadingState: true, + onTap: () async { + await Clipboard.setData(ClipboardData(text: urlValue)); + showShortToast( + context, + context.l10n.linkCopiedToClipboard, + ); + }, + isBottomBorderRadiusRemoved: true, + ), + if (!url.isExpired) + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + if (!url.isExpired) + MenuItemWidget( + key: sendLinkButtonKey, + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.sendLink, + makeTextBold: true, + ), + leadingIcon: Icons.adaptive.share, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + // ignore: unawaited_futures + await shareText( + urlValue, + context: context, + ); + }, + isTopBorderRadiusRemoved: true, + ), + const SizedBox(height: 24), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.removeLink, + textColor: warning500, + makeTextBold: true, + ), + leadingIcon: Icons.remove_circle_outline, + leadingIconColor: warning500, + menuItemColor: getEnteColorScheme(context).fillFaint, + surfaceExecutionStates: false, + onTap: () async { + final bool result = await CollectionActions.disableUrl( + context, + widget.collection!, + ); + if (result && mounted) { + Navigator.of(context).pop(); + if (widget.collection!.isQuickLinkCollection()) { + Navigator.of(context).pop(); + } + } + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future> _getEncryptedPassword(String pass) async { + final kekSalt = CryptoUtil.getSaltToDeriveKey(); + final result = await CryptoUtil.deriveInteractiveKey( + utf8.encode(pass), + kekSalt, + ); + return { + 'passHash': CryptoUtil.bin2base64(result.key), + 'nonce': CryptoUtil.bin2base64(kekSalt), + 'memLimit': result.memLimit, + 'opsLimit': result.opsLimit, + }; + } + + Future _updateUrlSettings( + BuildContext context, + Map prop, { + bool showProgressDialog = true, + }) async { + final dialog = showProgressDialog + ? createProgressDialog(context, context.l10n.pleaseWait) + : null; + await dialog?.show(); + try { + await CollectionApiClient.instance + .updateShareUrl(widget.collection!, prop); + await dialog?.hide(); + showShortToast(context, "Collection updated"); + if (mounted) { + setState(() {}); + } + } catch (e) { + await dialog?.hide(); + await showGenericErrorDialog(context: context, error: e); + rethrow; + } + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/more_count_badge.dart b/mobile/apps/locker/lib/ui/sharing/more_count_badge.dart new file mode 100644 index 0000000000..6ff660e9ff --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/more_count_badge.dart @@ -0,0 +1,79 @@ +import "package:ente_ui/theme/colors.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; + +enum MoreCountType { small, mini, tiny, extra } + +class MoreCountWidget extends StatelessWidget { + final MoreCountType type; + final bool thumbnailView; + final int count; + + const MoreCountWidget( + this.count, { + super.key, + this.type = MoreCountType.mini, + this.thumbnailView = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final displayChar = "+$count"; + final Color decorationColor = thumbnailView + ? backgroundElevated2Light + : colorScheme.backgroundElevated2; + + final avatarStyle = getAvatarStyle(context, type); + final double size = avatarStyle.item1; + final TextStyle textStyle = thumbnailView + ? avatarStyle.item2.copyWith(color: textFaintLight) + : avatarStyle.item2.copyWith(color: Colors.white); + return Container( + padding: const EdgeInsets.all(0.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: thumbnailView + ? strokeMutedDark + : getEnteColorScheme(context).strokeMuted, + width: 1.0, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: SizedBox( + height: size, + width: size, + child: CircleAvatar( + backgroundColor: decorationColor, + child: Transform.scale( + scale: 0.85, + child: Text( + displayChar.toUpperCase(), + // fixed color + style: textStyle, + ), + ), + ), + ), + ); + } + + Tuple2 getAvatarStyle( + BuildContext context, + MoreCountType type, + ) { + final enteTextTheme = getEnteTextTheme(context); + switch (type) { + case MoreCountType.small: + return Tuple2(32.0, enteTextTheme.small); + case MoreCountType.mini: + return Tuple2(24.0, enteTextTheme.mini); + case MoreCountType.tiny: + return Tuple2(18.0, enteTextTheme.tiny); + case MoreCountType.extra: + return Tuple2(18.0, enteTextTheme.tiny); + } + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/pickers/device_limit_picker_page.dart b/mobile/apps/locker/lib/ui/sharing/pickers/device_limit_picker_page.dart new file mode 100644 index 0000000000..dff471cedb --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/pickers/device_limit_picker_page.dart @@ -0,0 +1,145 @@ +import "package:ente_ui/components/captioned_text_widget.dart"; +import "package:ente_ui/components/divider_widget.dart"; +import "package:ente_ui/components/menu_item_widget.dart"; +import "package:ente_ui/components/separators.dart"; +import "package:ente_ui/components/title_bar_title_widget.dart"; +import "package:ente_ui/components/title_bar_widget.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_ui/utils/dialog_util.dart"; +import 'package:flutter/material.dart'; +import "package:locker/core/constants.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/collections_api_client.dart"; +import "package:locker/services/collections/models/collection.dart"; + +class DeviceLimitPickerPage extends StatelessWidget { + final Collection collection; + const DeviceLimitPickerPage(this.collection, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.linkDeviceLimit, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(8)), + child: ItemsWidget(collection), + ), + ], + ), + ); + }, + childCount: 1, + ), + ), + const SliverPadding(padding: EdgeInsets.symmetric(vertical: 12)), + ], + ), + ); + } +} + +class ItemsWidget extends StatefulWidget { + final Collection collection; + const ItemsWidget(this.collection, {super.key}); + + @override + State createState() => _ItemsWidgetState(); +} + +class _ItemsWidgetState extends State { + late int currentDeviceLimit; + late int initialDeviceLimit; + List items = []; + bool isCustomLimit = false; + @override + void initState() { + currentDeviceLimit = widget.collection.publicURLs.first.deviceLimit; + initialDeviceLimit = currentDeviceLimit; + if (!publicLinkDeviceLimits.contains(currentDeviceLimit)) { + isCustomLimit = true; + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + items.clear(); + if (isCustomLimit) { + items.add( + _menuItemForPicker(initialDeviceLimit), + ); + } + for (int deviceLimit in publicLinkDeviceLimits) { + items.add( + _menuItemForPicker(deviceLimit), + ); + } + items = addSeparators( + items, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: items, + ); + } + + Widget _menuItemForPicker(int deviceLimit) { + return MenuItemWidget( + key: ValueKey(deviceLimit), + menuItemColor: getEnteColorScheme(context).fillFaint, + captionedTextWidget: CaptionedTextWidget( + title: deviceLimit == 0 ? context.l10n.noDeviceLimit : "$deviceLimit", + ), + trailingIcon: currentDeviceLimit == deviceLimit ? Icons.check : null, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + showOnlyLoadingState: true, + onTap: () async { + await _updateUrlSettings(context, { + 'deviceLimit': deviceLimit, + }).then( + (value) => setState(() { + currentDeviceLimit = deviceLimit; + }), + ); + }, + ); + } + + Future _updateUrlSettings( + BuildContext context, + Map prop, + ) async { + try { + await CollectionApiClient.instance + .updateShareUrl(widget.collection, prop); + } catch (e) { + await showGenericErrorDialog(context: context, error: e); + rethrow; + } + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/pickers/link_expiry_picker_page.dart b/mobile/apps/locker/lib/ui/sharing/pickers/link_expiry_picker_page.dart new file mode 100644 index 0000000000..4a85cbbd61 --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/pickers/link_expiry_picker_page.dart @@ -0,0 +1,168 @@ +import "package:ente_ui/components/captioned_text_widget.dart"; +import "package:ente_ui/components/divider_widget.dart"; +import "package:ente_ui/components/menu_item_widget.dart"; +import "package:ente_ui/components/separators.dart"; +import "package:ente_ui/components/title_bar_title_widget.dart"; +import "package:ente_ui/components/title_bar_widget.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_ui/utils/dialog_util.dart"; +import 'package:flutter/material.dart'; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/collections_api_client.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/ui/viewer/date/date_time_picker.dart"; +import "package:tuple/tuple.dart"; + +class LinkExpiryPickerPage extends StatelessWidget { + final Collection collection; + const LinkExpiryPickerPage(this.collection, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.linkExpiry, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(8)), + child: ItemsWidget(collection), + ), + ], + ), + ); + }, + childCount: 1, + ), + ), + const SliverPadding(padding: EdgeInsets.symmetric(vertical: 12)), + ], + ), + ); + } +} + +class ItemsWidget extends StatefulWidget { + final Collection collection; + const ItemsWidget(this.collection, {super.key}); + + @override + State createState() => _ItemsWidgetState(); +} + +class _ItemsWidgetState extends State { + // index, title, milliseconds in future post which link should expire (when >0) + late final List> _expiryOptions = [ + Tuple2(context.l10n.never, 0), + Tuple2(context.l10n.after1Hour, const Duration(hours: 1).inMicroseconds), + Tuple2(context.l10n.after1Day, const Duration(days: 1).inMicroseconds), + Tuple2(context.l10n.after1Week, const Duration(days: 7).inMicroseconds), + // todo: make this time calculation perfect + Tuple2(context.l10n.after1Month, const Duration(days: 30).inMicroseconds), + Tuple2(context.l10n.after1Year, const Duration(days: 365).inMicroseconds), + Tuple2(context.l10n.custom, -1), + ]; + + @override + Widget build(BuildContext context) { + List items = []; + for (Tuple2 expiryOpiton in _expiryOptions) { + items.add( + _menuItemForPicker(context, expiryOpiton), + ); + } + items = addSeparators( + items, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: items, + ); + } + + Widget _menuItemForPicker( + BuildContext context, + Tuple2 expiryOpiton, + ) { + return MenuItemWidget( + menuItemColor: getEnteColorScheme(context).fillFaint, + captionedTextWidget: CaptionedTextWidget( + title: expiryOpiton.item1, + ), + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + alwaysShowSuccessState: true, + surfaceExecutionStates: expiryOpiton.item2 == -1 ? false : true, + onTap: () async { + int newValidTill = -1; + final int expireAfterInMicroseconds = expiryOpiton.item2; + // need to manually select time + if (expireAfterInMicroseconds < 0) { + final now = DateTime.now(); + final DateTime? picked = await showDatePickerSheet( + context, + initialDate: now, + minDate: now, + ); + final timeInMicrosecondsFromEpoch = picked?.microsecondsSinceEpoch; + if (timeInMicrosecondsFromEpoch != null) { + newValidTill = timeInMicrosecondsFromEpoch; + } + } else if (expireAfterInMicroseconds == 0) { + // no expiry + newValidTill = 0; + } else { + newValidTill = + DateTime.now().microsecondsSinceEpoch + expireAfterInMicroseconds; + } + if (newValidTill >= 0) { + debugPrint( + "Setting expire date to ${DateTime.fromMicrosecondsSinceEpoch(newValidTill)}", + ); + await updateTime(newValidTill, context); + } + }, + ); + } + + Future updateTime(int newValidTill, BuildContext context) async { + await _updateUrlSettings( + context, + {'validTill': newValidTill}, + ); + } + + Future _updateUrlSettings( + BuildContext context, + Map prop, + ) async { + try { + await CollectionApiClient.instance + .updateShareUrl(widget.collection, prop); + } catch (e) { + await showGenericErrorDialog(context: context, error: e); + rethrow; + } + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/share_collection_page.dart b/mobile/apps/locker/lib/ui/sharing/share_collection_page.dart new file mode 100644 index 0000000000..f40553ef6c --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/share_collection_page.dart @@ -0,0 +1,402 @@ +import "package:ente_sharing/models/user.dart"; +import "package:ente_ui/components/captioned_text_widget.dart"; +import "package:ente_ui/components/divider_widget.dart"; +import "package:ente_ui/components/menu_item_widget.dart"; +import "package:ente_ui/components/menu_section_description_widget.dart"; +import "package:ente_ui/components/menu_section_title.dart"; +import "package:ente_ui/theme/colors.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_ui/utils/toast_util.dart"; +import "package:ente_utils/ente_utils.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:locker/extensions/user_extension.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/collections/collections_service.dart"; +import "package:locker/services/collections/models/collection.dart"; +import "package:locker/ui/sharing/add_participant_page.dart"; +import "package:locker/ui/sharing/album_participants_page.dart"; +import "package:locker/ui/sharing/album_share_info_widget.dart"; +import "package:locker/ui/sharing/manage_album_participant.dart"; +import "package:locker/ui/sharing/manage_links_widget.dart"; +import "package:locker/ui/sharing/user_avator_widget.dart"; +import "package:locker/utils/collection_actions.dart"; + +class ShareCollectionPage extends StatefulWidget { + final Collection collection; + const ShareCollectionPage({super.key, required this.collection}); + + @override + State createState() => _ShareCollectionPageState(); +} + +class _ShareCollectionPageState extends State { + late List _sharees; + + Future _navigateToManageUser() async { + if (_sharees.length == 1) { + await routeToPage( + context, + ManageIndividualParticipant( + collection: widget.collection, + user: _sharees.first!, + ), + ); + } else { + await routeToPage( + context, + AlbumParticipantsPage(widget.collection), + ); + } + if (mounted) { + setState(() => {}); + } + } + + @override + Widget build(BuildContext context) { + final bool hasUrl = widget.collection.hasLink; + final bool hasExpired = + widget.collection.publicURLs.firstOrNull?.isExpired ?? false; + _sharees = widget.collection.sharees; + + final children = []; + + children.add( + MenuSectionTitle( + title: context.l10n.shareWithPeopleSectionTitle(_sharees.length), + iconData: Icons.workspaces, + ), + ); + + children.add( + EmailItemWidget( + widget.collection, + onTap: _navigateToManageUser, + ), + ); + + children.add( + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.addViewer, + makeTextBold: true, + ), + leadingIcon: Icons.add, + menuItemColor: getEnteColorScheme(context).fillFaint, + isTopBorderRadiusRemoved: _sharees.isNotEmpty, + isBottomBorderRadiusRemoved: true, + onTap: () async { + // ignore: unawaited_futures + routeToPage( + context, + AddParticipantPage( + [widget.collection], + const [ActionTypesToShow.addViewer], + ), + ).then( + (value) => { + if (mounted) {setState(() => {})}, + }, + ); + }, + ), + ); + children.add( + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ); + + children.add( + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.addCollaborator, + makeTextBold: true, + ), + leadingIcon: Icons.add, + menuItemColor: getEnteColorScheme(context).fillFaint, + isTopBorderRadiusRemoved: true, + onTap: () async { + // ignore: unawaited_futures + routeToPage( + context, + AddParticipantPage( + [widget.collection], + const [ActionTypesToShow.addCollaborator], + ), + ).then( + (value) => { + if (mounted) {setState(() => {})}, + }, + ); + }, + ), + ); + + if (_sharees.isEmpty && !hasUrl) { + children.add( + MenuSectionDescriptionWidget( + content: context.l10n.sharedCollectionSectionDescription, + ), + ); + } + + children.addAll([ + const SizedBox( + height: 24, + ), + MenuSectionTitle( + title: + hasUrl ? context.l10n.publicLinkEnabled : context.l10n.shareALink, + iconData: Icons.public, + ), + ]); + if (hasUrl) { + if (hasExpired) { + children.add( + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.linkHasExpired, + textColor: getEnteColorScheme(context).warning500, + ), + leadingIcon: Icons.error_outline, + leadingIconColor: getEnteColorScheme(context).warning500, + menuItemColor: getEnteColorScheme(context).fillFaint, + isBottomBorderRadiusRemoved: true, + ), + ); + } else { + final String url = + CollectionService.instance.getPublicUrl(widget.collection); + children.addAll( + [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.copyLink, + makeTextBold: true, + ), + leadingIcon: Icons.copy, + menuItemColor: getEnteColorScheme(context).fillFaint, + showOnlyLoadingState: true, + onTap: () async { + await Clipboard.setData(ClipboardData(text: url)); + showShortToast(context, "Link copied to clipboard"); + }, + isBottomBorderRadiusRemoved: true, + ), + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.sendLink, + makeTextBold: true, + ), + leadingIcon: Icons.adaptive.share, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + // ignore: unawaited_futures + await shareText( + url, + context: context, + ); + }, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + ), + ], + ); + } + + children.addAll( + [ + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.manageLink, + makeTextBold: true, + ), + leadingIcon: Icons.link, + trailingIcon: Icons.navigate_next, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingIconIsMuted: true, + onTap: () async { + // ignore: unawaited_futures + routeToPage( + context, + ManageSharedLinkWidget(collection: widget.collection), + ).then( + (value) => { + if (mounted) {setState(() => {})}, + }, + ); + }, + isTopBorderRadiusRemoved: true, + ), + const SizedBox(height: 24), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.removeLink, + textColor: warning500, + makeTextBold: true, + ), + leadingIcon: Icons.remove_circle_outline, + leadingIconColor: warning500, + menuItemColor: getEnteColorScheme(context).fillFaint, + surfaceExecutionStates: false, + onTap: () async { + final bool result = await CollectionActions.disableUrl( + context, + widget.collection, + ); + if (result && mounted) { + Navigator.of(context).pop(); + if (widget.collection.isQuickLinkCollection()) { + Navigator.of(context).pop(); + } + } + }, + ), + ], + ); + } else { + children.addAll( + [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.createPublicLink, + makeTextBold: true, + ), + leadingIcon: Icons.link, + menuItemColor: getEnteColorScheme(context).fillFaint, + showOnlyLoadingState: true, + onTap: () async { + final bool result = + await CollectionActions.enableUrl(context, widget.collection); + if (result && mounted) { + setState(() => {}); + } + }, + ), + ], + ); + } + return Scaffold( + appBar: AppBar( + title: Text( + widget.collection.name ?? "Collection", + style: + Theme.of(context).textTheme.headlineSmall?.copyWith(fontSize: 16), + ), + elevation: 0, + centerTitle: false, + ), + body: SingleChildScrollView( + child: ListBody( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ], + ), + ), + ); + } +} + +class EmailItemWidget extends StatelessWidget { + final Collection collection; + final Function? onTap; + + const EmailItemWidget( + this.collection, { + this.onTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (collection.getSharees().isEmpty) { + return const SizedBox.shrink(); + } else if (collection.getSharees().length == 1) { + final User? user = collection.getSharees().firstOrNull; + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: user?.displayName ?? user?.email ?? '', + ), + leadingIconWidget: UserAvatarWidget( + collection.getSharees().first, + thumbnailView: false, + ), + leadingIconSize: 24, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingIconIsMuted: true, + trailingIcon: Icons.chevron_right, + onTap: () async { + if (onTap != null) { + onTap!(); + } + }, + isBottomBorderRadiusRemoved: true, + ), + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ], + ); + } else { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + MenuItemWidget( + captionedTextWidget: Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + child: SizedBox( + height: 24, + child: AlbumSharesIcons( + sharees: collection.getSharees(), + padding: const EdgeInsets.all(0), + limitCountTo: 10, + type: AvatarType.mini, + removeBorder: false, + ), + ), + ), + ), + alignCaptionedTextToLeft: true, + // leadingIcon: Icons.people_outline, + menuItemColor: getEnteColorScheme(context).fillFaint, + trailingIconIsMuted: true, + trailingIcon: Icons.chevron_right, + onTap: () async { + if (onTap != null) { + onTap!(); + } + }, + isBottomBorderRadiusRemoved: true, + ), + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ], + ); + } + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/user_avator_widget.dart b/mobile/apps/locker/lib/ui/sharing/user_avator_widget.dart new file mode 100644 index 0000000000..5ce26e230b --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/user_avator_widget.dart @@ -0,0 +1,212 @@ + +import "package:ente_sharing/models/user.dart"; +import "package:ente_ui/theme/colors.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import 'package:flutter/material.dart'; +import "package:locker/services/configuration.dart"; +import 'package:tuple/tuple.dart'; + +enum AvatarType { small, mini, tiny, extra } + +class UserAvatarWidget extends StatefulWidget { + final User user; + final AvatarType type; + final int currentUserID; + final bool thumbnailView; + + const UserAvatarWidget( + this.user, { + super.key, + this.currentUserID = -1, + this.type = AvatarType.mini, + this.thumbnailView = false, + }); + + @override + State createState() => _UserAvatarWidgetState(); + static const strokeWidth = 1.0; +} + +class _UserAvatarWidgetState extends State { + @override + Widget build(BuildContext context) { + final double size = getAvatarSize(widget.type); + return Container( + padding: const EdgeInsets.all(0.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: widget.thumbnailView + ? strokeMutedDark + : getEnteColorScheme(context).strokeMuted, + width: UserAvatarWidget.strokeWidth, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: SizedBox( + height: size, + width: size, + child: _FirstLetterCircularAvatar( + user: widget.user, + currentUserID: widget.currentUserID, + thumbnailView: widget.thumbnailView, + type: widget.type, + ), + ), + ); + } +} + +class _FirstLetterCircularAvatar extends StatefulWidget { + final User user; + final int currentUserID; + final bool thumbnailView; + final AvatarType type; + const _FirstLetterCircularAvatar({ + required this.user, + required this.currentUserID, + required this.thumbnailView, + required this.type, + }); + + @override + State<_FirstLetterCircularAvatar> createState() => + _FirstLetterCircularAvatarState(); +} + +class _FirstLetterCircularAvatarState + extends State<_FirstLetterCircularAvatar> { + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final displayChar = + (widget.user.name == null || widget.user.name!.isEmpty) + ? ((widget.user.email.isEmpty) + ? " " + : widget.user.email.substring(0, 1)) + : widget.user.name!.substring(0, 1); + Color decorationColor; + if ((widget.user.id != null && widget.user.id! < 0) || + widget.user.email == Configuration.instance.getEmail()) { + decorationColor = Colors.black; + } else { + decorationColor = colorScheme.avatarColors[(widget.user.email.length) + .remainder(colorScheme.avatarColors.length)]; + } + + final avatarStyle = getAvatarStyle(context, widget.type); + final double size = avatarStyle.item1; + final TextStyle textStyle = avatarStyle.item2; + return Container( + padding: const EdgeInsets.all(0.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: widget.thumbnailView + ? strokeMutedDark + : getEnteColorScheme(context).strokeMuted, + width: UserAvatarWidget.strokeWidth, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: SizedBox( + height: size, + width: size, + child: CircleAvatar( + backgroundColor: decorationColor, + child: Text( + displayChar.toUpperCase(), + // fixed color + style: textStyle.copyWith(color: Colors.white), + ), + ), + ), + ); + } + + Tuple2 getAvatarStyle( + BuildContext context, + AvatarType type, + ) { + final enteTextTheme = getEnteTextTheme(context); + switch (type) { + case AvatarType.small: + return Tuple2(32.0, enteTextTheme.small); + case AvatarType.mini: + return Tuple2(24.0, enteTextTheme.mini); + case AvatarType.tiny: + return Tuple2(18.0, enteTextTheme.tiny); + case AvatarType.extra: + return Tuple2(18.0, enteTextTheme.tiny); + } + } +} + +double getAvatarSize( + AvatarType type, +) { + switch (type) { + case AvatarType.small: + return 32.0; + case AvatarType.mini: + return 24.0; + case AvatarType.tiny: + return 18.0; + case AvatarType.extra: + return 18.0; + } +} + +class FirstLetterUserAvatar extends StatefulWidget { + final User user; + const FirstLetterUserAvatar(this.user, {super.key}); + + @override + State createState() => _FirstLetterUserAvatarState(); +} + +class _FirstLetterUserAvatarState extends State { + final currentUserEmail = Configuration.instance.getEmail(); + late User user; + + @override + void initState() { + super.initState(); + user = widget.user; + } + + @override + void didUpdateWidget(covariant FirstLetterUserAvatar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.user != widget.user) { + setState(() { + user = widget.user; + }); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + final displayChar = (user.name == null || user.name!.isEmpty) + ? ((user.email.isEmpty) ? " " : user.email.substring(0, 1)) + : user.name!.substring(0, 1); + Color decorationColor; + if ((widget.user.id != null && widget.user.id! < 0) || + user.email == currentUserEmail) { + decorationColor = Colors.black; + } else { + decorationColor = colorScheme.avatarColors[ + (user.email.length).remainder(colorScheme.avatarColors.length)]; + } + return Container( + color: decorationColor, + child: Center( + child: Text( + displayChar.toUpperCase(), + style: getEnteTextTheme(context).small.copyWith(color: Colors.white), + ), + ), + ); + } +} diff --git a/mobile/apps/locker/lib/ui/sharing/verify_identity_dialog.dart b/mobile/apps/locker/lib/ui/sharing/verify_identity_dialog.dart new file mode 100644 index 0000000000..81b3c11674 --- /dev/null +++ b/mobile/apps/locker/lib/ui/sharing/verify_identity_dialog.dart @@ -0,0 +1,208 @@ +import "dart:convert"; + +import 'package:bip39/bip39.dart' as bip39; +import "package:crypto/crypto.dart"; +import "package:dotted_border/dotted_border.dart"; +import "package:ente_accounts/services/user_service.dart"; +import "package:ente_ui/components/buttons/button_widget.dart"; +import "package:ente_ui/components/buttons/models/button_type.dart"; +import "package:ente_ui/components/loading_widget.dart"; +import "package:ente_ui/theme/ente_theme.dart"; +import "package:ente_utils/share_utils.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:locker/l10n/l10n.dart"; +import "package:locker/services/configuration.dart"; +import "package:logging/logging.dart"; + +class VerifyIdentifyDialog extends StatefulWidget { + // email id of the user who's verification ID is being displayed for + // verification + final String email; + + // self is true when the user is viewing their own verification ID + final bool self; + + VerifyIdentifyDialog({ + super.key, + required this.self, + this.email = '', + }) { + if (!self && email.isEmpty) { + throw ArgumentError("email cannot be empty when self is false"); + } + } + + @override + State createState() => _VerifyIdentifyDialogState(); +} + +class _VerifyIdentifyDialogState extends State { + final bool doesUserExist = true; + + @override + Widget build(BuildContext context) { + final textStyle = getEnteTextTheme(context); + final String subTitle = widget.self + ? context.l10n.thisIsYourVerificationId + : context.l10n.thisIsPersonVerificationId(widget.email); + final String bottomText = widget.self + ? context.l10n.someoneSharingAlbumsWithYouShouldSeeTheSameId + : context.l10n.howToViewShareeVerificationID; + + final AlertDialog alert = AlertDialog( + title: Text( + widget.self + ? context.l10n.verificationId + : context.l10n.verifyEmailID(widget.email), + ), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FutureBuilder( + future: _getPublicKey(), + builder: (context, snapshot) { + if (snapshot.hasData) { + final publicKey = snapshot.data!; + if (publicKey.isEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.emailNoEnteAccount(widget.email), + ), + const SizedBox(height: 24), + ButtonWidget( + buttonType: ButtonType.neutral, + icon: Icons.adaptive.share, + labelText: context.l10n.sendInvite, + isInAlert: true, + onTap: () async { + // ignore: unawaited_futures + shareText( + context.l10n.shareTextRecommendUsingEnte, + ); + }, + ), + ], + ); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subTitle, + style: textStyle.bodyMuted, + ), + const SizedBox(height: 20), + _verificationIDWidget(context, publicKey), + const SizedBox(height: 16), + Text( + bottomText, + style: textStyle.bodyMuted, + ), + const SizedBox(height: 24), + ButtonWidget( + buttonType: ButtonType.neutral, + isInAlert: true, + labelText: + widget.self ? context.l10n.ok : context.l10n.done, + ), + ], + ); + } + } else if (snapshot.hasError) { + Logger("VerificationID") + .severe("failed to end userID", snapshot.error); + return Text( + context.l10n.somethingWentWrong, + style: textStyle.bodyMuted, + ); + } else { + return const SizedBox( + height: 200, + child: EnteLoadingWidget(), + ); + } + }, + ), + ], + ), + ); + return alert; + } + + Future _getPublicKey() async { + if (widget.self) { + return Configuration.instance.getKeyAttributes()!.publicKey; + } + final String? userPublicKey = + await UserService.instance.getPublicKey(widget.email); + if (userPublicKey == null) { + // user not found + return ""; + } + return userPublicKey; + } + + Widget _verificationIDWidget(BuildContext context, String publicKey) { + final colorScheme = getEnteColorScheme(context); + final textStyle = getEnteTextTheme(context); + final String verificationID = _generateVerificationID(publicKey); + return DottedBorder( + options: RoundedRectDottedBorderOptions( + color: colorScheme.strokeMuted, + strokeWidth: 1, + dashPattern: const [12, 6], + radius: const Radius.circular(8), + ), + child: Column( + children: [ + GestureDetector( + onTap: () async { + if (verificationID.isEmpty) { + return; + } + await Clipboard.setData( + ClipboardData(text: verificationID), + ); + // ignore: unawaited_futures + shareText( + widget.self + ? context.l10n.shareMyVerificationID(verificationID) + : context.l10n + .shareTextConfirmOthersVerificationID(verificationID), + ); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(2), + ), + color: colorScheme.backgroundElevated2, + ), + padding: const EdgeInsets.all(20), + width: double.infinity, + child: Text( + verificationID, + style: textStyle.bodyBold, + ), + ), + ), + ], + ), + ); + } + + String _generateVerificationID(String publicKey) { + final inputBytes = base64.decode(publicKey); + final shaValue = sha256.convert(inputBytes); + return bip39.generateMnemonic( + strength: 256, + randomBytes: (int size) { + return Uint8List.fromList(shaValue.bytes); + }, + ); + } +} diff --git a/mobile/apps/locker/lib/ui/viewer/date/date_time_picker.dart b/mobile/apps/locker/lib/ui/viewer/date/date_time_picker.dart new file mode 100644 index 0000000000..c44b2a3ea1 --- /dev/null +++ b/mobile/apps/locker/lib/ui/viewer/date/date_time_picker.dart @@ -0,0 +1,227 @@ +import "package:ente_ui/theme/ente_theme.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:locker/l10n/l10n.dart"; + +Future showDatePickerSheet( + BuildContext context, { + required DateTime initialDate, + DateTime? maxDate, + DateTime? minDate, + bool startWithTime = false, +}) async { + final colorScheme = getEnteColorScheme(context); + final sheet = Container( + decoration: BoxDecoration( + color: colorScheme.backgroundElevated, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: DateTimePickerWidget( + (DateTime dateTime) { + Navigator.of(context).pop(dateTime); + }, + () { + Navigator.of(context).pop(null); + }, + initialDate, + minDateTime: minDate, + maxDateTime: maxDate, + ), + ), + ); + final newDate = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => sheet, + ); + return newDate; +} + +class DateTimePickerWidget extends StatefulWidget { + final Function(DateTime) onDateTimeSelected; + final Function() onCancel; + final DateTime initialDateTime; + final DateTime? maxDateTime; + final DateTime? minDateTime; + final bool startWithTime; + + const DateTimePickerWidget( + this.onDateTimeSelected, + this.onCancel, + this.initialDateTime, { + this.maxDateTime, + this.minDateTime, + this.startWithTime = false, + super.key, + }); + + @override + State createState() => _DateTimePickerWidgetState(); +} + +class _DateTimePickerWidgetState extends State { + late DateTime _selectedDateTime; + bool _showTimePicker = false; + + @override + void initState() { + super.initState(); + _showTimePicker = widget.startWithTime; + _selectedDateTime = widget.initialDateTime; + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Container( + color: colorScheme.backgroundElevated, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _showTimePicker + ? context.l10n.selectTime + : context.l10n.selectDate, + style: TextStyle( + color: colorScheme.textBase, + fontSize: 16, + ), + ), + ), + ), + + // Date/Time Picker + Container( + height: 220, + decoration: BoxDecoration( + color: colorScheme.backgroundElevated2, + borderRadius: BorderRadius.circular(12), + ), + child: CupertinoTheme( + data: CupertinoThemeData( + brightness: Brightness.dark, + textTheme: CupertinoTextThemeData( + dateTimePickerTextStyle: TextStyle( + color: colorScheme.textBase, + fontSize: 22, + ), + ), + ), + child: CupertinoDatePicker( + key: ValueKey(_showTimePicker), + mode: _showTimePicker + ? CupertinoDatePickerMode.time + : CupertinoDatePickerMode.date, + initialDateTime: _selectedDateTime, + minimumDate: widget.minDateTime ?? DateTime(1800), + maximumDate: widget.maxDateTime ?? DateTime(2200), + use24hFormat: MediaQuery.of(context).alwaysUse24HourFormat, + showDayOfWeek: !_showTimePicker, + onDateTimeChanged: (DateTime newDateTime) { + setState(() { + if (_showTimePicker) { + // Keep the date but update the time + _selectedDateTime = DateTime( + _selectedDateTime.year, + _selectedDateTime.month, + _selectedDateTime.day, + newDateTime.hour, + newDateTime.minute, + ); + } else { + // Keep the time but update the date + _selectedDateTime = DateTime( + newDateTime.year, + newDateTime.month, + newDateTime.day, + _selectedDateTime.hour, + _selectedDateTime.minute, + ); + } + + // Ensure the selected date doesn't exceed maxDateTime or minDateTime + if (widget.minDateTime != null && + _selectedDateTime.isBefore(widget.minDateTime!)) { + _selectedDateTime = widget.minDateTime!; + } + if (widget.maxDateTime != null && + _selectedDateTime.isAfter(widget.maxDateTime!)) { + _selectedDateTime = widget.maxDateTime!; + } + }); + }, + ), + ), + ), + + // Buttons + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Cancel Button + CupertinoButton( + padding: EdgeInsets.zero, + child: Text( + _showTimePicker + ? context.l10n.previous + : context.l10n.cancel, + style: TextStyle( + color: colorScheme.textBase, + fontSize: 14, + ), + ), + onPressed: () { + if (_showTimePicker) { + // Go back to date picker + setState(() { + _showTimePicker = false; + }); + } else { + widget.onCancel(); + } + }, + ), + + // Next/Done Button + CupertinoButton( + padding: EdgeInsets.zero, + child: Text( + _showTimePicker ? context.l10n.done : context.l10n.next, + style: TextStyle( + color: colorScheme.primary700, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () { + if (_showTimePicker) { + // We're done, call the callback + widget.onDateTimeSelected(_selectedDateTime); + } else { + // Move to time picker + setState(() { + _showTimePicker = true; + }); + } + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/apps/locker/lib/utils/collection_actions.dart b/mobile/apps/locker/lib/utils/collection_actions.dart index 90bdc76473..5bde888fa7 100644 --- a/mobile/apps/locker/lib/utils/collection_actions.dart +++ b/mobile/apps/locker/lib/utils/collection_actions.dart @@ -1,10 +1,24 @@ +import "dart:async"; + +import "package:ente_accounts/services/user_service.dart"; +import "package:ente_sharing/models/user.dart"; +import "package:ente_ui/components/action_sheet_widget.dart"; import 'package:ente_ui/components/buttons/button_widget.dart'; import 'package:ente_ui/components/buttons/models/button_type.dart'; +import "package:ente_ui/components/dialog_widget.dart"; +import "package:ente_ui/components/progress_dialog.dart"; import 'package:ente_ui/utils/dialog_util.dart'; +import "package:ente_utils/email_util.dart"; +import "package:ente_utils/share_utils.dart"; import 'package:flutter/material.dart'; +import "package:locker/core/errors.dart"; +import "package:locker/extensions/user_extension.dart"; import 'package:locker/l10n/l10n.dart'; +import "package:locker/services/collections/collections_api_client.dart"; import 'package:locker/services/collections/collections_service.dart'; -import 'package:locker/services/collections/models/collection.dart'; +import 'package:locker/services/collections/models/collection.dart'; +import "package:locker/services/configuration.dart"; +import "package:locker/ui/components/user_dialogs.dart"; import 'package:locker/utils/snack_bar_utils.dart'; import 'package:logging/logging.dart'; @@ -157,4 +171,336 @@ class CollectionActions { ); } } + + static Future leaveCollection( + BuildContext context, + Collection collection, { + VoidCallback? onSuccess, + }) async { + final actionResult = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + buttonType: ButtonType.critical, + isInAlert: true, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: context.l10n.leaveCollection, + onTap: () async { + await CollectionApiClient.instance.leaveCollection(collection); + }, + ), + ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: true, + labelText: context.l10n.cancel, + ), + ], + title: context.l10n.leaveCollection, + body: context.l10n.filesAddedByYouWillBeRemovedFromTheCollection, + ); + if (actionResult?.action != null && context.mounted) { + if (actionResult!.action == ButtonAction.error) { + await showGenericErrorDialog( + context: context, + error: actionResult.exception, + ); + } else if (actionResult.action == ButtonAction.first) { + onSuccess?.call(); + Navigator.of(context).pop(); + SnackBarUtils.showInfoSnackBar( + context, + "Leave collection successfully", + ); + } + } + } + + static Future enableUrl( + BuildContext context, + Collection collection, { + bool enableCollect = false, + }) async { + try { + await CollectionApiClient.instance.createShareUrl( + collection, + enableCollect: enableCollect, + ); + return true; + } catch (e) { + if (e is SharingNotPermittedForFreeAccountsError) { + await _showUnSupportedAlert(context); + } else { + _logger.severe("Failed to update shareUrl collection", e); + await showGenericErrorDialog(context: context, error: e); + } + return false; + } + } + + static Future disableUrl( + BuildContext context, + Collection collection, + ) async { + final actionResult = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + buttonType: ButtonType.critical, + isInAlert: true, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: "Yes, remove", + onTap: () async { + await CollectionApiClient.instance.disableShareUrl(collection); + }, + ), + ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: true, + labelText: context.l10n.cancel, + ), + ], + title: "Remove public link", + body: + "This will remove the public link for accessing \"${collection.name}\".", + ); + if (actionResult?.action != null) { + if (actionResult!.action == ButtonAction.error) { + await showGenericErrorDialog( + context: context, + error: actionResult.exception, + ); + } + return actionResult.action == ButtonAction.first; + } else { + return false; + } + } + + static Future _showUnSupportedAlert(BuildContext context) async { + final AlertDialog alert = AlertDialog( + title: const Text("Sorry"), + content: const Text( + "You need an active paid subscription to enable sharing.", + ), + actions: [ + ButtonWidget( + buttonType: ButtonType.primary, + isInAlert: true, + shouldStickToDarkTheme: false, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: "Subscribe", + onTap: () async { + // TODO: If we are having subscriptions for locker + // Navigator.of(context).push( + // MaterialPageRoute( + // builder: (BuildContext context) { + // return getSubscriptionPage(); + // }, + // ), + // ).ignore(); + Navigator.of(context).pop(); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: false, + labelText: context.l10n.ok, + ), + ), + ], + ); + + return showDialog( + useRootNavigator: false, + context: context, + builder: (BuildContext context) { + return alert; + }, + barrierDismissible: true, + ); + } + + Future doesEmailHaveAccount( + BuildContext context, + String email, { + bool showProgress = false, + }) async { + ProgressDialog? dialog; + String? publicKey; + if (showProgress) { + dialog = createProgressDialog( + context, + context.l10n.sharing, + isDismissible: true, + ); + await dialog.show(); + } + try { + publicKey = await UserService.instance.getPublicKey(email); + } catch (e) { + await dialog?.hide(); + _logger.severe("Failed to get public key", e); + await showGenericErrorDialog(context: context, error: e); + return false; + } + // getPublicKey can return null when no user is associated with given + // email id + if (publicKey == null || publicKey == '') { + // todo: neeraj replace this as per the design where a new screen + // is used for error. Do this change along with handling of network errors + await showInviteDialog(context, email); + return false; + } else { + return true; + } + } + + // addEmailToCollection returns true if add operation was successful + Future addEmailToCollection( + BuildContext context, + Collection collection, + String email, + CollectionParticipantRole role, { + bool showProgress = false, + }) async { + if (!isValidEmail(email)) { + await showErrorDialog( + context, + context.l10n.invalidEmailAddress, + context.l10n.enterValidEmail, + ); + return false; + } else if (email.trim() == Configuration.instance.getEmail()) { + await showErrorDialog( + context, + context.l10n.oops, + context.l10n.youCannotShareWithYourself, + ); + return false; + } + + ProgressDialog? dialog; + String? publicKey; + if (showProgress) { + dialog = createProgressDialog( + context, + context.l10n.sharing, + isDismissible: true, + ); + await dialog.show(); + } + + try { + publicKey = await UserService.instance.getPublicKey(email); + } catch (e) { + await dialog?.hide(); + _logger.severe("Failed to get public key", e); + await showGenericErrorDialog(context: context, error: e); + return false; + } + // getPublicKey can return null when no user is associated with given + // email id + if (publicKey == null || publicKey == '') { + // todo: neeraj replace this as per the design where a new screen + // is used for error. Do this change along with handling of network errors + await showDialogWidget( + context: context, + title: context.l10n.inviteToEnte, + icon: Icons.info_outline, + body: context.l10n.emailNoEnteAccount(email), + isDismissible: true, + buttons: [ + ButtonWidget( + buttonType: ButtonType.neutral, + icon: Icons.adaptive.share, + labelText: context.l10n.sendInvite, + isInAlert: true, + onTap: () async { + unawaited( + shareText( + context.l10n.shareTextRecommendUsingEnte, + ), + ); + }, + ), + ], + ); + return false; + } else { + try { + final newSharees = await CollectionApiClient.instance + .share(collection.id, email, publicKey, role); + await dialog?.hide(); + collection.updateSharees(newSharees); + return true; + } catch (e) { + await dialog?.hide(); + if (e is SharingNotPermittedForFreeAccountsError) { + await _showUnSupportedAlert(context); + } else { + _logger.severe("failed to share collection", e); + await showGenericErrorDialog(context: context, error: e); + } + return false; + } + } + } + + // removeParticipant remove the user from a share album + Future removeParticipant( + BuildContext context, + Collection collection, + User user, + ) async { + final actionResult = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + buttonType: ButtonType.critical, + isInAlert: true, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: context.l10n.yesRemove, + onTap: () async { + final newSharees = await CollectionApiClient.instance + .unshare(collection.id, user.email); + collection.updateSharees(newSharees); + }, + ), + ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: true, + labelText: context.l10n.cancel, + ), + ], + title: context.l10n.removeWithQuestionMark, + body: context.l10n.removeParticipantBody(user.displayName ?? user.email), + ); + if (actionResult?.action != null) { + if (actionResult!.action == ButtonAction.error) { + await showGenericErrorDialog( + context: context, + error: actionResult.exception, + ); + } + return actionResult.action == ButtonAction.first; + } + return false; + } } diff --git a/mobile/apps/locker/pubspec.lock b/mobile/apps/locker/pubspec.lock index ac9b39f1cd..69bf81af11 100644 --- a/mobile/apps/locker/pubspec.lock +++ b/mobile/apps/locker/pubspec.lock @@ -66,7 +66,7 @@ packages: source: hosted version: "2.13.0" bip39: - dependency: transitive + dependency: "direct main" description: name: bip39 sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc @@ -146,7 +146,7 @@ packages: source: hosted version: "0.3.4+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" @@ -202,7 +202,7 @@ packages: source: hosted version: "2.1.1" dotted_border: - dependency: transitive + dependency: "direct main" description: name: dotted_border sha256: "99b091ec6891ba0c5331fdc2b502993c7c108f898995739a73c6845d71dad70c" @@ -275,6 +275,13 @@ packages: relative: true source: path version: "1.0.0" + ente_sharing: + dependency: "direct main" + description: + path: "../../packages/sharing" + relative: true + source: path + version: "1.0.0" ente_strings: dependency: "direct main" description: @@ -1331,7 +1338,7 @@ packages: source: hosted version: "0.5.0" tuple: - dependency: transitive + dependency: "direct main" description: name: tuple sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 diff --git a/mobile/apps/locker/pubspec.yaml b/mobile/apps/locker/pubspec.yaml index 783d2f9b88..1cb310a295 100644 --- a/mobile/apps/locker/pubspec.yaml +++ b/mobile/apps/locker/pubspec.yaml @@ -8,8 +8,11 @@ environment: dependencies: adaptive_theme: ^3.6.0 + bip39: ^1.0.6 collection: ^1.18.0 + crypto: ^3.0.6 dio: ^5.8.0+1 + dotted_border: ^3.1.0 email_validator: ^3.0.0 ente_accounts: path: ../../packages/accounts @@ -28,6 +31,8 @@ dependencies: path: ../../packages/logging ente_network: path: ../../packages/network + ente_sharing: + path: ../../packages/sharing ente_strings: path: ../../packages/strings ente_ui: @@ -59,6 +64,7 @@ dependencies: sqflite: ^2.4.1 styled_text: ^8.1.0 tray_manager: ^0.5.0 + tuple: ^2.0.2 url_launcher: ^6.3.2 uuid: ^4.5.1 window_manager: ^0.5.0 diff --git a/mobile/apps/locker/pubspec_overrides.yaml b/mobile/apps/locker/pubspec_overrides.yaml index 53839bf8d8..5c1cd4757e 100644 --- a/mobile/apps/locker/pubspec_overrides.yaml +++ b/mobile/apps/locker/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils +# melos_managed_dependency_overrides: ente_accounts,ente_base,ente_configuration,ente_events,ente_lock_screen,ente_logging,ente_network,ente_strings,ente_ui,ente_utils,ente_sharing dependency_overrides: ente_accounts: path: ../../packages/accounts @@ -14,6 +14,8 @@ dependency_overrides: path: ../../packages/logging ente_network: path: ../../packages/network + ente_sharing: + path: ../../packages/sharing ente_strings: path: ../../packages/strings ente_ui: diff --git a/mobile/packages/accounts/lib/services/user_service.dart b/mobile/packages/accounts/lib/services/user_service.dart index ce636beae8..e6246fd1a9 100644 --- a/mobile/packages/accounts/lib/services/user_service.dart +++ b/mobile/packages/accounts/lib/services/user_service.dart @@ -160,6 +160,24 @@ class UserService { ); } + // getPublicKey returns null value if email id is not + // associated with another ente account + Future getPublicKey(String email) async { + try { + final response = await _enteDio.get( + "/users/public-key", + queryParameters: {"email": email}, + ); + final publicKey = response.data["publicKey"]; + return publicKey; + } on DioException catch (e) { + if (e.response != null && e.response?.statusCode == 404) { + return null; + } + rethrow; + } + } + Future getUserDetailsV2({ bool memoryCount = false, bool shouldCache = true, diff --git a/mobile/packages/sharing/analysis_options.yaml b/mobile/packages/sharing/analysis_options.yaml new file mode 100644 index 0000000000..1bd78bc1b0 --- /dev/null +++ b/mobile/packages/sharing/analysis_options.yaml @@ -0,0 +1,72 @@ +# For more linters, we can check https://dart-lang.github.io/linter/lints/index.html +# or https://pub.dev/packages/lint (Effective dart) +# use "flutter analyze ." or "dart analyze ." for running lint checks + +include: package:flutter_lints/flutter.yaml +linter: + rules: + # Ref https://github.com/flutter/packages/blob/master/packages/flutter_lints/lib/flutter.yaml + # Ref https://dart-lang.github.io/linter/lints/ + - avoid_print + - avoid_unnecessary_containers + - avoid_web_libraries_in_flutter + - no_logic_in_create_state + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - prefer_final_locals + - require_trailing_commas + - sized_box_for_whitespace + - use_full_hex_values_for_flutter_colors + - use_key_in_widget_constructors + - cancel_subscriptions + + + - avoid_empty_else + - exhaustive_cases + + # just style suggestions + - sort_pub_dependencies + - use_rethrow_when_possible + - prefer_double_quotes + - directives_ordering + - always_use_package_imports + - sort_child_properties_last + - unawaited_futures + +analyzer: + errors: + avoid_empty_else: error + exhaustive_cases: error + curly_braces_in_flow_control_structures: error + directives_ordering: error + require_trailing_commas: error + always_use_package_imports: warning + prefer_final_fields: error + unused_import: error + camel_case_types: error + prefer_is_empty: warning + use_rethrow_when_possible: info + unused_field: warning + use_key_in_widget_constructors: warning + sort_child_properties_last: warning + sort_pub_dependencies: warning + library_private_types_in_public_api: warning + constant_identifier_names: ignore + prefer_const_constructors: warning + prefer_const_declarations: warning + prefer_const_constructors_in_immutables: warning + prefer_final_locals: warning + unnecessary_const: error + cancel_subscriptions: error + unrelated_type_equality_checks: error + unnecessary_cast: info + + + unawaited_futures: warning # convert to warning after fixing existing issues + invalid_dependency: info + use_build_context_synchronously: ignore # experimental lint, requires many changes + prefer_interpolation_to_compose_strings: ignore # later too many warnings + prefer_double_quotes: ignore # too many warnings + avoid_renaming_method_parameters: ignore # incorrect warnings for `equals` overrides diff --git a/mobile/packages/sharing/lib/collection_sharing_service.dart b/mobile/packages/sharing/lib/collection_sharing_service.dart new file mode 100644 index 0000000000..0c1217d9cd --- /dev/null +++ b/mobile/packages/sharing/lib/collection_sharing_service.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:ente_crypto_dart/ente_crypto_dart.dart'; +import 'package:ente_network/network.dart'; +import 'package:ente_sharing/errors.dart'; +import 'package:ente_sharing/models/user.dart'; +import 'package:logging/logging.dart'; + +class CollectionSharingService { + final _logger = Logger('CollectionSharingService'); + final _enteDio = Network.instance.enteDio; + + static final CollectionSharingService instance = + CollectionSharingService._privateConstructor(); + + CollectionSharingService._privateConstructor(); + + /// Share a collection with a user + Future> share( + int collectionID, + String email, + String publicKey, + String role, + Uint8List collectionKey, + Uint8List encryptedKey, + ) async { + final params = { + 'collectionID': collectionID, + 'email': email, + 'encryptedKey': CryptoUtil.bin2base64(encryptedKey), + 'role': role, + }; + + try { + final response = await _enteDio.post('/collection/share', data: params); + final sharees = []; + for (final user in response.data["sharees"]) { + sharees.add(User.fromMap(user)); + } + return sharees; + } on DioException catch (e) { + if (e.response?.statusCode == 402) { + throw SharingNotPermittedForFreeAccountsError(); + } + rethrow; + } + } + + /// Unshare a collection with a user + Future> unshare(int collectionID, String email) async { + try { + final response = await _enteDio.post( + "/collections/unshare", + data: { + "collectionID": collectionID, + "email": email, + }, + ); + final sharees = []; + for (final user in response.data["sharees"]) { + sharees.add(User.fromMap(user)); + } + return sharees; + } catch (e) { + _logger.severe('Failed to unshare collection', e); + rethrow; + } + } + + /// Create a public sharing URL for a collection + Future createShareUrl( + int collectionID, + bool enableCollect, + ) async { + try { + final response = await _enteDio.post( + '/collection/share-url', + data: { + 'collectionID': collectionID, + 'enableCollect': enableCollect, + "enableJoin": true, + }, + ); + return response; + } on DioException catch (e) { + if (e.response?.statusCode == 402) { + throw SharingNotPermittedForFreeAccountsError(); + } + rethrow; + } catch (e, s) { + _logger.severe("failed to create share URL", e, s); + rethrow; + } + } + + /// Disable public sharing URL for a collection + Future disableShareUrl(int collectionID) async { + try { + await _enteDio.delete( + "/collections/share-url/" + collectionID.toString(), + ); + } on DioException catch (e) { + _logger.info(e); + rethrow; + } + } + + Future updateShareUrl( + int collectionID, + Map prop, + ) async { + prop.putIfAbsent('collectionID', () => collectionID); + try { + final response = await _enteDio.put( + "/collections/share-url", + data: json.encode(prop), + ); + return response; + } on DioException catch (e) { + if (e.response?.statusCode == 402) { + throw SharingNotPermittedForFreeAccountsError(); + } + rethrow; + } catch (e, s) { + _logger.severe("failed to update ShareUrl", e, s); + rethrow; + } + } + + /// Leave a shared collection + Future leaveCollection(int collectionID) async { + try { + await _enteDio.post( + "/collections/leave/$collectionID", + ); + } catch (e) { + _logger.severe('Failed to leave collection', e); + rethrow; + } + } +} diff --git a/mobile/packages/sharing/lib/ente_sharing.dart b/mobile/packages/sharing/lib/ente_sharing.dart new file mode 100644 index 0000000000..b108fb971d --- /dev/null +++ b/mobile/packages/sharing/lib/ente_sharing.dart @@ -0,0 +1,2 @@ +export 'collection_sharing_service.dart'; +export 'models/user.dart'; diff --git a/mobile/packages/sharing/lib/errors.dart b/mobile/packages/sharing/lib/errors.dart new file mode 100644 index 0000000000..371af78fc2 --- /dev/null +++ b/mobile/packages/sharing/lib/errors.dart @@ -0,0 +1 @@ +class SharingNotPermittedForFreeAccountsError extends Error {} diff --git a/mobile/apps/locker/lib/services/collections/models/user.dart b/mobile/packages/sharing/lib/models/user.dart similarity index 100% rename from mobile/apps/locker/lib/services/collections/models/user.dart rename to mobile/packages/sharing/lib/models/user.dart diff --git a/mobile/packages/sharing/pubspec.lock b/mobile/packages/sharing/pubspec.lock new file mode 100644 index 0000000000..0004f637a2 --- /dev/null +++ b/mobile/packages/sharing/pubspec.lock @@ -0,0 +1,822 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + bip39: + dependency: transitive + description: + name: bip39 + sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc + url: "https://pub.dev" + source: hosted + version: "1.0.6" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cronet_http: + dependency: transitive + description: + name: cronet_http + sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_http: + dependency: transitive + description: + name: cupertino_http + sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + ente_base: + dependency: "direct overridden" + description: + path: "../base" + relative: true + source: path + version: "1.0.0" + ente_configuration: + dependency: "direct overridden" + description: + path: "../configuration" + relative: true + source: path + version: "1.0.0" + ente_crypto_dart: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: f91e1545f8263df127762240c4da54a0c42835b2 + url: "https://github.com/ente-io/ente_crypto_dart.git" + source: git + version: "1.0.0" + ente_events: + dependency: "direct overridden" + description: + path: "../events" + relative: true + source: path + version: "1.0.0" + ente_logging: + dependency: "direct overridden" + description: + path: "../logging" + relative: true + source: path + version: "1.0.0" + ente_network: + dependency: "direct main" + description: + path: "../network" + relative: true + source: path + version: "1.0.0" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fast_base58: + dependency: "direct main" + description: + name: fast_base58 + sha256: "611f65633b734f27a850b51371b3eba993a5165650e12e8e7b02959f3768ba06" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage: + dependency: transitive + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + http_profile: + dependency: transitive + description: + name: http_profile + sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 + url: "https://pub.dev" + source: hosted + version: "0.14.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_dio_adapter: + dependency: transitive + description: + name: native_dio_adapter + sha256: "1c51bd42027861d27ccad462ba0903f5e3197461cc6d59a0bb8658cb5ad7bd01" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "9f034ba1eeca53ddb339bc8f4813cb07336a849cd735559b60cdc068ecce2dc7" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + sentry: + dependency: transitive + description: + name: sentry + sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb" + url: "https://pub.dev" + source: hosted + version: "8.14.2" + sentry_flutter: + dependency: transitive + description: + name: sentry_flutter + sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8" + url: "https://pub.dev" + source: hosted + version: "8.14.2" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 + url: "https://pub.dev" + source: hosted + version: "2.4.12" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + sodium: + dependency: transitive + description: + name: sodium + sha256: d9830a388e37c82891888e64cfd4c6764fa3ac716bed80ac6eab89ee42c3cd76 + url: "https://pub.dev" + source: hosted + version: "2.3.1+1" + sodium_libs: + dependency: transitive + description: + name: sodium_libs + sha256: aa764acd6ccc6113e119c2d99471aeeb4637a9a501639549b297d3a143ff49b3 + url: "https://pub.dev" + source: hosted + version: "2.2.1+6" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + ua_client_hints: + dependency: transitive + description: + name: ua_client_hints + sha256: "1b8759a46bfeab355252881df27f2604c01bded86aa2b578869fb1b638b23118" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.29.0" diff --git a/mobile/packages/sharing/pubspec.yaml b/mobile/packages/sharing/pubspec.yaml new file mode 100644 index 0000000000..7f78e46272 --- /dev/null +++ b/mobile/packages/sharing/pubspec.yaml @@ -0,0 +1,26 @@ +name: ente_sharing +description: Common sharing functionality for ente apps +version: 1.0.0 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.0" + +dependencies: + collection: ^1.17.0 + dio: ^5.0.0 + ente_crypto_dart: + git: + url: https://github.com/ente-io/ente_crypto_dart.git + ente_network: + path: ../network + fast_base58: ^0.2.1 + flutter: + sdk: flutter + logging: ^1.0.0 + +dev_dependencies: + flutter_lints: ^2.0.0 + flutter_test: + sdk: flutter diff --git a/mobile/packages/sharing/pubspec_overrides.yaml b/mobile/packages/sharing/pubspec_overrides.yaml new file mode 100644 index 0000000000..95002e716b --- /dev/null +++ b/mobile/packages/sharing/pubspec_overrides.yaml @@ -0,0 +1,12 @@ +# melos_managed_dependency_overrides: ente_base,ente_configuration,ente_events,ente_logging,ente_network +dependency_overrides: + ente_base: + path: ../base + ente_configuration: + path: ../configuration + ente_events: + path: ../events + ente_logging: + path: ../logging + ente_network: + path: ../network diff --git a/mobile/packages/ui/lib/components/captioned_text_widget.dart b/mobile/packages/ui/lib/components/captioned_text_widget.dart index bc9a9708d3..c7aecf080a 100644 --- a/mobile/packages/ui/lib/components/captioned_text_widget.dart +++ b/mobile/packages/ui/lib/components/captioned_text_widget.dart @@ -7,12 +7,14 @@ class CaptionedTextWidget extends StatelessWidget { final TextStyle? textStyle; final bool makeTextBold; final Color? textColor; + final Color? subTitleColor; const CaptionedTextWidget({ required this.title, this.subTitle, this.textStyle, this.makeTextBold = false, this.textColor, + this.subTitleColor, super.key, }); @@ -41,7 +43,7 @@ class CaptionedTextWidget extends StatelessWidget { ? TextSpan( text: ' \u2022 $subTitle', style: enteTextTheme.small.copyWith( - color: enteColorScheme.textMuted, + color: subTitleColor ?? enteColorScheme.textMuted, ), ) : const TextSpan(text: ''), diff --git a/mobile/packages/ui/lib/components/menu_section_description_widget.dart b/mobile/packages/ui/lib/components/menu_section_description_widget.dart new file mode 100644 index 0000000000..a932f572fa --- /dev/null +++ b/mobile/packages/ui/lib/components/menu_section_description_widget.dart @@ -0,0 +1,21 @@ +import "package:ente_ui/theme/ente_theme.dart"; +import 'package:flutter/material.dart'; + +class MenuSectionDescriptionWidget extends StatelessWidget { + final String content; + const MenuSectionDescriptionWidget({required this.content, super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + child: Text( + content, + textAlign: TextAlign.left, + style: getEnteTextTheme(context) + .mini + .copyWith(color: getEnteColorScheme(context).textMuted), + ), + ); + } +} diff --git a/mobile/packages/ui/lib/components/menu_section_title.dart b/mobile/packages/ui/lib/components/menu_section_title.dart new file mode 100644 index 0000000000..6f6ff1019e --- /dev/null +++ b/mobile/packages/ui/lib/components/menu_section_title.dart @@ -0,0 +1,35 @@ +import "package:ente_ui/theme/ente_theme.dart"; +import 'package:flutter/widgets.dart'; + +class MenuSectionTitle extends StatelessWidget { + final String title; + final IconData? iconData; + + const MenuSectionTitle({super.key, required this.title, this.iconData}); + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Padding( + padding: const EdgeInsets.only(left: 8, top: 6, bottom: 6), + child: Row( + children: [ + iconData != null + ? Icon( + iconData, + color: colorScheme.strokeMuted, + size: 17, + ) + : const SizedBox.shrink(), + iconData != null ? const SizedBox(width: 8) : const SizedBox.shrink(), + Text( + title, + style: getEnteTextTheme(context).small.copyWith( + color: colorScheme.textMuted, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/lib/theme/colors.dart b/mobile/packages/ui/lib/theme/colors.dart index ac4bc24138..09123e109d 100644 --- a/mobile/packages/ui/lib/theme/colors.dart +++ b/mobile/packages/ui/lib/theme/colors.dart @@ -158,6 +158,9 @@ class EnteColorScheme extends ThemeExtension { final Color primaryColor; final Color surface; + //other colors + final List avatarColors; + bool get isLightTheme => backgroundBase == backgroundBaseLight; const EnteColorScheme( @@ -188,7 +191,8 @@ class EnteColorScheme extends ThemeExtension { this.primary700, this.primary500, this.primary400, - this.primary300, { + this.primary300, + this.avatarColors, { this.warning700 = _warning700, this.warning800 = _warning800, this.warning500 = _warning500, @@ -273,6 +277,7 @@ class EnteColorScheme extends ThemeExtension { primary500 ?? _defaultPrimary500, primary400 ?? _defaultPrimary400, primary300 ?? _defaultPrimary300, + avatarLight, alternativeColor: primary400 ?? _defaultAlternativeColor, warning700: warning700 ?? _warning700, warning800: warning800 ?? _warning800, @@ -326,6 +331,7 @@ class EnteColorScheme extends ThemeExtension { primary500 ?? _defaultPrimary500, primary400 ?? _defaultPrimary400, primary300 ?? _defaultPrimary300, + avatarDark, alternativeColor: primary400 ?? _defaultAlternativeColor, warning700: warning700 ?? _warning700, warning800: warning800 ?? _warning800, @@ -404,6 +410,7 @@ class EnteColorScheme extends ThemeExtension { Color? searchResultsBackgroundColor, Color? codeCardBackgroundColor, Color? primaryColor, + List? avatarColors, }) { return EnteColorScheme( backgroundBase ?? this.backgroundBase, @@ -434,6 +441,7 @@ class EnteColorScheme extends ThemeExtension { primary500 ?? this.primary500, primary400 ?? this.primary400, primary300 ?? this.primary300, + avatarColors ?? this.avatarColors, warning700: warning700 ?? this.warning700, warning800: warning800 ?? this.warning800, warning500: warning500 ?? this.warning500, @@ -520,6 +528,7 @@ class EnteColorScheme extends ThemeExtension { Color.lerp(primary500, other.primary500, t)!, Color.lerp(primary400, other.primary400, t)!, Color.lerp(primary300, other.primary300, t)!, + _lerpColorList(avatarColors, other.avatarColors, t), warning700: Color.lerp(warning700, other.warning700, t)!, warning800: Color.lerp(warning800, other.warning800, t)!, warning500: Color.lerp(warning500, other.warning500, t)!, @@ -569,6 +578,7 @@ const EnteColorScheme lightScheme = EnteColorScheme( _defaultPrimary500, _defaultPrimary400, _defaultPrimary300, + avatarLight, ); const EnteColorScheme darkScheme = EnteColorScheme( @@ -600,6 +610,7 @@ const EnteColorScheme darkScheme = EnteColorScheme( _defaultPrimary500, _defaultPrimary400, _defaultPrimary300, + avatarDark, ); // Background Colors @@ -877,3 +888,55 @@ class ColorSchemeBuilder { return (light: lightScheme, dark: darkScheme); } } + +const List avatarLight = [ + Color.fromRGBO(118, 84, 154, 1), + Color.fromRGBO(223, 120, 97, 1), + Color.fromRGBO(148, 180, 159, 1), + Color.fromRGBO(135, 162, 251, 1), + Color.fromRGBO(198, 137, 198, 1), + Color.fromRGBO(147, 125, 194, 1), // Fixed duplicate + Color.fromRGBO(50, 82, 136, 1), + Color.fromRGBO(133, 180, 224, 1), + Color.fromRGBO(193, 163, 163, 1), + Color.fromRGBO(225, 160, 89, 1), // Fixed duplicate + Color.fromRGBO(66, 97, 101, 1), + Color.fromRGBO(107, 119, 178, 1), // Fixed duplicate + Color.fromRGBO(149, 127, 239, 1), // Fixed duplicate + Color.fromRGBO(221, 157, 226, 1), + Color.fromRGBO(130, 171, 139, 1), + Color.fromRGBO(155, 187, 232, 1), + Color.fromRGBO(143, 190, 190, 1), + Color.fromRGBO(138, 195, 161, 1), + Color.fromRGBO(168, 176, 242, 1), + Color.fromRGBO(176, 198, 149, 1), + Color.fromRGBO(233, 154, 173, 1), + Color.fromRGBO(209, 132, 132, 1), + Color.fromRGBO(120, 181, 167, 1), +]; + +const List avatarDark = [ + Color.fromRGBO(118, 84, 154, 1), + Color.fromRGBO(223, 120, 97, 1), + Color.fromRGBO(148, 180, 159, 1), + Color.fromRGBO(135, 162, 251, 1), + Color.fromRGBO(198, 137, 198, 1), + Color.fromRGBO(147, 125, 194, 1), + Color.fromRGBO(50, 82, 136, 1), + Color.fromRGBO(133, 180, 224, 1), + Color.fromRGBO(193, 163, 163, 1), + Color.fromRGBO(225, 160, 89, 1), + Color.fromRGBO(66, 97, 101, 1), + Color.fromRGBO(107, 119, 178, 1), + Color.fromRGBO(149, 127, 239, 1), + Color.fromRGBO(221, 157, 226, 1), + Color.fromRGBO(130, 171, 139, 1), + Color.fromRGBO(155, 187, 232, 1), + Color.fromRGBO(143, 190, 190, 1), + Color.fromRGBO(138, 195, 161, 1), + Color.fromRGBO(168, 176, 242, 1), + Color.fromRGBO(176, 198, 149, 1), + Color.fromRGBO(233, 154, 173, 1), + Color.fromRGBO(209, 132, 132, 1), + Color.fromRGBO(120, 181, 167, 1), +]; diff --git a/mobile/packages/utils/lib/ente_utils.dart b/mobile/packages/utils/lib/ente_utils.dart index d9e96ad0c7..d5d103d71f 100644 --- a/mobile/packages/utils/lib/ente_utils.dart +++ b/mobile/packages/utils/lib/ente_utils.dart @@ -1,7 +1,8 @@ export 'debouncer.dart'; export 'directory_utils.dart'; export 'email_util.dart'; +export 'extensions/list.dart'; export 'fake_progress.dart'; export 'navigation_util.dart'; export 'platform_util.dart'; -export 'share_utils.dart'; +export 'share_utils.dart'; \ No newline at end of file diff --git a/mobile/packages/utils/lib/extensions/list.dart b/mobile/packages/utils/lib/extensions/list.dart new file mode 100644 index 0000000000..a64d7d6140 --- /dev/null +++ b/mobile/packages/utils/lib/extensions/list.dart @@ -0,0 +1,38 @@ +extension ListExtension on List { + List> chunks(int chunkSize) { + final List> result = >[]; + for (var i = 0; i < length; i += chunkSize) { + result.add( + sublist(i, i + chunkSize > length ? length : i + chunkSize), + ); + } + return result; + } + + // splitMatch, based on the matchFunction, split the input list in two + // lists. result.matched contains items which matched and result.unmatched + // contains remaining items. + ListMatch splitMatch(bool Function(E e) matchFunction) { + final listMatch = ListMatch(); + for (final element in this) { + if (matchFunction(element)) { + listMatch.matched.add(element); + } else { + listMatch.unmatched.add(element); + } + } + return listMatch; + } + + Iterable interleave(E separator) sync* { + for (int i = 0; i < length; i++) { + yield this[i]; + if (i < length) yield separator; + } + } +} + +class ListMatch { + List matched = []; + List unmatched = []; +}