diff --git a/fastlane/metadata/android/en-US/changelogs/420.txt b/fastlane/metadata/android/en-US/changelogs/420.txt new file mode 100644 index 0000000000000000000000000000000000000000..b34916cd402df30cae84cad15e714dd5924060cd --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/420.txt @@ -0,0 +1,11 @@ +* Collaborative albums ✨ + +You can now create albums where multiple ente users can add photos! + +Albums can have both collaborators and viewers, and as many as you like. Storage is only counted once, for the person who uploaded the photo + +This will enable many aspects of partner sharing + +* Support for uncategorized photos - keep photos that do not belong to albums. This also allows you to delete albums whilst keeping their contents + +* Redesigned album selector \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 4719ffb0770db990b2a1a723bb81fe3bfe8a91e5..53cab6c96a946729adc74920a22c7d9e54a0f5f4 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,12 +1,10 @@ ente is a simple app to backup and share your photos and videos. -If you've been looking for a privacy-friendly alternative to Google Photos, you've come to the right place. With Ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them. +If you've been looking for a privacy-friendly alternative to Google Photos, you've come to the right place. With ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them. -We have apps across Android, iOS, web and desktop, and your photos will seamlessly sync between all of them in an end-to-end encrypted (e2ee) manner. +We have open-source apps across Android, iOS, web and desktop, and your photos will seamlessly sync between all of them in an end-to-end encrypted (e2ee) manner. -ente also makes it simple to share your albums with your loved ones, even if they aren't on ente. - -You can share publicly viewable links, where they can view your album and collaborate by adding photos to it, even without an account or app. +ente also makes it simple to share your albums with your loved ones, even if they aren't on ente. You can share publicly viewable links, where they can view your album and collaborate by adding photos to it, even without an account or app. Your encrypted data is replicated to 3 different locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you. @@ -29,7 +27,7 @@ FEATURES - and a LOT more! PERMISSIONS -Ente requests for certain permissions to serve the purpose of a photo storage provider, which can be reviewed here: https://github.com/ente-io/photos-app/blob/f-droid/android/permissions.md +ente requests for certain permissions to serve the purpose of a photo storage provider, which can be reviewed here: https://github.com/ente-io/photos-app/blob/f-droid/android/permissions.md PRICING We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io. diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 6678521fc8b8a1958aa58ee717b8c5a694788f37..236419b4d28f522d28864bfb991f0ec660f10022 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -55,3 +55,5 @@ const int intMaxValue = 9223372036854775807; const double restrictedMaxWidth = 430; const double mobileSmallThreshold = 336; + +const publicLinkDeviceLimits = [50, 25, 10, 5, 2, 1]; diff --git a/lib/core/network/ente_interceptor.dart b/lib/core/network/ente_interceptor.dart new file mode 100644 index 0000000000000000000000000000000000000000..5eb8d16d51505de45787d41cb2f3359ddfbe65f2 --- /dev/null +++ b/lib/core/network/ente_interceptor.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +class EnteRequestInterceptor extends Interceptor { + final SharedPreferences _preferences; + final String enteEndpoint; + + EnteRequestInterceptor(this._preferences, this.enteEndpoint); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (kDebugMode) { + assert( + options.baseUrl == enteEndpoint, + "interceptor should only be used for API endpoint", + ); + } + // ignore: prefer_const_constructors + options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString()); + final String? tokenValue = _preferences.getString(Configuration.tokenKey); + if (tokenValue != null) { + options.headers.putIfAbsent("X-Auth-Token", () => tokenValue); + } + return super.onRequest(options, handler); + } +} diff --git a/lib/core/network.dart b/lib/core/network/network.dart similarity index 59% rename from lib/core/network.dart rename to lib/core/network/network.dart index 0d396e5d7fe68709eb7a32f13d80c18afb49f000..b3930ddafeeb143eacd2343b5db93cfc10150911 100644 --- a/lib/core/network.dart +++ b/lib/core/network/network.dart @@ -2,16 +2,14 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:fk_user_agent/fk_user_agent.dart'; -import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; +import 'package:photos/core/network/ente_interceptor.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:uuid/uuid.dart'; int kConnectTimeout = 15000; -class Network { +class NetworkClient { // apiEndpoint points to the Ente server's API endpoint static const apiEndpoint = String.fromEnvironment( "endpoint", @@ -46,37 +44,14 @@ class Network { }, ), ); - _enteDio.interceptors.add(EnteRequestInterceptor(preferences)); + _enteDio.interceptors.add(EnteRequestInterceptor(preferences, apiEndpoint)); } - Network._privateConstructor(); + NetworkClient._privateConstructor(); - static Network instance = Network._privateConstructor(); + static NetworkClient instance = NetworkClient._privateConstructor(); Dio getDio() => _dio; Dio get enteDio => _enteDio; } - -class EnteRequestInterceptor extends Interceptor { - final SharedPreferences _preferences; - - EnteRequestInterceptor(this._preferences); - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - if (kDebugMode) { - assert( - options.baseUrl == Network.apiEndpoint, - "interceptor should only be used for API endpoint", - ); - } - // ignore: prefer_const_constructors - options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString()); - final String? tokenValue = _preferences.getString(Configuration.tokenKey); - if (tokenValue != null) { - options.headers.putIfAbsent("X-Auth-Token", () => tokenValue); - } - return super.onRequest(options, handler); - } -} diff --git a/lib/db/device_files_db.dart b/lib/db/device_files_db.dart index 79aaa6f46104d9261d0a48fbd5647fb8bea64f1d..d967e916dc4de60c898593b6f455b5abf7a50651 100644 --- a/lib/db/device_files_db.dart +++ b/lib/db/device_files_db.dart @@ -307,6 +307,7 @@ extension DeviceFiles on FilesDB { Future getFilesInDeviceCollection( DeviceCollection deviceCollection, + int? ownerID, int startTime, int endTime, { int? limit, @@ -319,7 +320,9 @@ extension DeviceFiles on FilesDB { FROM ${FilesDB.filesTable} WHERE ${FilesDB.columnLocalID} IS NOT NULL AND ${FilesDB.columnCreationTime} >= $startTime AND - ${FilesDB.columnCreationTime} <= $endTime AND + ${FilesDB.columnCreationTime} <= $endTime AND + (${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} = + $ownerID ) AND ${FilesDB.columnLocalID} IN (SELECT id FROM device_files where path_id = '${deviceCollection.id}' ) ORDER BY ${FilesDB.columnCreationTime} $order , ${FilesDB.columnModificationTime} $order diff --git a/lib/db/file_updation_db.dart b/lib/db/file_updation_db.dart index 26e97fb66cdabf54d9f1b33cf2cf543fee5a23ee..554d761504c4eb24ac4e2813d540ebc6a9e4951f 100644 --- a/lib/db/file_updation_db.dart +++ b/lib/db/file_updation_db.dart @@ -105,7 +105,7 @@ class FileUpdationDB { endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch, ); _logger.info( - "Batch insert of ${fileLocalIDs.length} " + "Batch insert of ${fileLocalIDs.length} updated files due to $reason " "took ${duration.inMilliseconds} ms.", ); } diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index 2f436da9cf4e9446688baf5a9db948e0f51d1473..1b5d1117a794cdafbf31f2bd1aacd8f2085f230e 100644 --- a/lib/db/files_db.dart +++ b/lib/db/files_db.dart @@ -763,13 +763,15 @@ class FilesDB { return convertToFiles(results)[0]; } - Future> getExistingLocalFileIDs() async { + Future> getExistingLocalFileIDs(int ownerID) async { final db = await instance.database; final rows = await db.query( filesTable, columns: [columnLocalID], distinct: true, - where: '$columnLocalID IS NOT NULL', + where: '$columnLocalID IS NOT NULL AND ($columnOwnerID IS NULL OR ' + '$columnOwnerID = ?)', + whereArgs: [ownerID], ); final result = {}; for (final row in rows) { diff --git a/lib/main.dart b/lib/main.dart index 9346e2e42fecbe5bd77ee3010d8c822938f1c507..4548871ab281d634bd57553ae06a78219a4a53bc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,7 +12,7 @@ import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/error-reporting/super_logging.dart'; import 'package:photos/core/errors.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/upload_locks_db.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/services/app_lifecycle_service.dart'; @@ -135,7 +135,7 @@ Future _init(bool isBackground, {String via = ''}) async { } CryptoUtil.init(); await NotificationService.instance.init(); - await Network.instance.init(); + await NetworkClient.instance.init(); await Configuration.instance.init(); await UserService.instance.init(); await UserRemoteFlagService.instance.init(); diff --git a/lib/models/collection.dart b/lib/models/collection.dart index e1b6cf1f12347567f4ab79b671417f6fa6bec847..9d1af6e4e527e3dee30b2a96595d31c4197aa31f 100644 --- a/lib/models/collection.dart +++ b/lib/models/collection.dart @@ -49,6 +49,13 @@ class Collection { return mMdVersion > 0 && magicMetadata.visibility == visibilityArchive; } + // hasLink returns true if there's any link attached to the collection + // including expired links + bool get hasLink => publicURLs != null && publicURLs!.isNotEmpty; + + // hasSharees returns true if the collection is shared with other ente users + bool get hasSharees => sharees != null && sharees!.isNotEmpty; + bool isHidden() { if (isDefaultHidden()) { return true; diff --git a/lib/models/gallery_type.dart b/lib/models/gallery_type.dart index 59adc9c23dd8c2562e2b3e7c20011823d50cb726..d93e42faf14c61a650e8efe9c65d543fd56448bd 100644 --- a/lib/models/gallery_type.dart +++ b/lib/models/gallery_type.dart @@ -184,4 +184,12 @@ extension GalleyTypeExtension on GalleryType { bool showUnFavoriteOption() { return this == GalleryType.favorite; } + + bool showRestoreOption() { + return this == GalleryType.trash; + } + + bool showPermanentlyDeleteOption() { + return this == GalleryType.trash; + } } diff --git a/lib/services/billing_service.dart b/lib/services/billing_service.dart index 7f685f62e1daf342a4f708aba1d90ad9f6d7866d..8f603c894fe8c71e7dd4bc53b44dbad2fccc1ccf 100644 --- a/lib/services/billing_service.dart +++ b/lib/services/billing_service.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/errors.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/models/billing_plan.dart'; import 'package:photos/models/subscription.dart'; import 'package:photos/models/user_details.dart'; @@ -32,7 +32,7 @@ class BillingService { static final BillingService instance = BillingService._privateConstructor(); final _logger = Logger("BillingService"); - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; bool _isOnSubscriptionPage = false; diff --git a/lib/services/collections_service.dart b/lib/services/collections_service.dart index 7df7af78518ff82b30630be6f75234da14d879a8..f35c29d63001f13158d89825d4482019b73ffae5 100644 --- a/lib/services/collections_service.dart +++ b/lib/services/collections_service.dart @@ -12,7 +12,7 @@ import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/errors.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/collections_db.dart'; import 'package:photos/db/device_files_db.dart'; import 'package:photos/db/files_db.dart'; @@ -50,7 +50,7 @@ class CollectionsService { late Configuration _config; late SharedPreferences _prefs; - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; final _localPathToCollectionID = {}; final _collectionIDToCollections = {}; final _cachedKeys = {}; @@ -138,7 +138,7 @@ class CollectionsService { } await _updateDB(updatedCollections); _prefs.setInt(_collectionsSyncTimeKey, maxUpdationTime); - watch.logAndReset("till DB insertion"); + watch.logAndReset("till DB insertion ${updatedCollections.length}"); final collections = await _db.getAllCollections(); for (final collection in collections) { _cacheCollectionAttributes(collection); @@ -159,6 +159,8 @@ class CollectionsService { void clearCache() { _localPathToCollectionID.clear(); _collectionIDToCollections.clear(); + cachedDefaultHiddenCollection = null; + cachedUncategorizedCollection = null; _cachedKeys.clear(); } @@ -238,7 +240,7 @@ class CollectionsService { (u) => u.id == userID, ); if (matchingUser != null) { - _cachedUserIdToUser[userID] = collection.owner!; + _cachedUserIdToUser[userID] = matchingUser; } } } @@ -592,12 +594,16 @@ class CollectionsService { } } - Future createShareUrl(Collection collection) async { + Future createShareUrl( + Collection collection, { + bool enableCollect = false, + }) async { try { final response = await _enteDio.post( "/collections/share-url", data: { "collectionID": collection.id, + "enableCollect": enableCollect, }, ); collection.publicURLs?.add(PublicURL.fromMap(response.data["result"])); @@ -894,8 +900,9 @@ class CollectionsService { final params = {}; params["collectionID"] = toCollectionID; final toCollectionKey = getCollectionKey(toCollectionID); + final int ownerID = Configuration.instance.getUserID()!; final Set existingLocalIDS = - await FilesDB.instance.getExistingLocalFileIDs(); + await FilesDB.instance.getExistingLocalFileIDs(ownerID); final batchedFiles = files.chunks(batchSize); for (final batch in batchedFiles) { params["files"] = []; @@ -1048,7 +1055,7 @@ class CollectionsService { params["fileIDs"].add(file.uploadedFileID); } await _enteDio.post( - "/collections/v2/remove-files", + "/collections/v3/remove-files", data: params, ); diff --git a/lib/services/deduplication_service.dart b/lib/services/deduplication_service.dart index 3ebac05b75d3ec2ed0f5c32be4b0be0345edb459..01c534a613ab8506fda2a0128571491935cfcd57 100644 --- a/lib/services/deduplication_service.dart +++ b/lib/services/deduplication_service.dart @@ -1,13 +1,13 @@ import 'package:logging/logging.dart'; import 'package:photos/core/errors.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/models/duplicate_files.dart'; import 'package:photos/models/file.dart'; class DeduplicationService { final _logger = Logger("DeduplicationService"); - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; DeduplicationService._privateConstructor(); diff --git a/lib/services/feature_flag_service.dart b/lib/services/feature_flag_service.dart index 47517d183252372a9dfa784c5810da6ff55521b2..2d24dd08be3fe40e0e2d2a8006d42b75f5042028 100644 --- a/lib/services/feature_flag_service.dart +++ b/lib/services/feature_flag_service.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:shared_preferences/shared_preferences.dart'; class FeatureFlagService { @@ -69,7 +69,7 @@ class FeatureFlagService { Future fetchFeatureFlags() async { try { - final response = await Network.instance + final response = await NetworkClient.instance .getDio() .get("https://static.ente.io/feature_flags.json"); final flagsResponse = FeatureFlags.fromMap(response.data); diff --git a/lib/services/file_magic_service.dart b/lib/services/file_magic_service.dart index 4fdc8e05629ad18774194ce5d92199792cc580a8..f3ce08ee67c88818e353d54716d5a43691eefc35 100644 --- a/lib/services/file_magic_service.dart +++ b/lib/services/file_magic_service.dart @@ -7,7 +7,7 @@ import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/force_reload_home_gallery_event.dart'; @@ -26,7 +26,7 @@ class FileMagicService { FileMagicService._privateConstructor() { _filesDB = FilesDB.instance; - _enteDio = Network.instance.enteDio; + _enteDio = NetworkClient.instance.enteDio; } static final FileMagicService instance = diff --git a/lib/services/files_service.dart b/lib/services/files_service.dart index 2d13f971f0a90ff52ddf51f55bb9285e0006b222..136e3e7c21b0d497797c4fea6d02d9237675bcb0 100644 --- a/lib/services/files_service.dart +++ b/lib/services/files_service.dart @@ -2,7 +2,7 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart'; import 'package:photos/core/configuration.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/extensions/list.dart'; import 'package:photos/models/file.dart'; @@ -17,7 +17,7 @@ class FilesService { late Configuration _config; FilesService._privateConstructor() { - _enteDio = Network.instance.enteDio; + _enteDio = NetworkClient.instance.enteDio; _logger = Logger("FilesService"); _filesDB = FilesDB.instance; _config = Configuration.instance; diff --git a/lib/services/local_sync_service.dart b/lib/services/local_sync_service.dart index 0f3d2c7fd3db2db6328996499a358a9572ea0062..eb79aeb182074765c4d13c8b3f94085355a594e0 100644 --- a/lib/services/local_sync_service.dart +++ b/lib/services/local_sync_service.dart @@ -33,7 +33,6 @@ class LocalSyncService { static const hasImportedDeviceCollections = "has_imported_device_collections"; static const kHasGrantedPermissionsKey = "has_granted_permissions"; static const kPermissionStateKey = "permission_state"; - static const kEditedFileIDsKey = "edited_file_ids"; // Adding `_2` as a suffic to pull files that were earlier ignored due to permission errors // See https://github.com/CaiJingLong/flutter_photo_manager/issues/589 @@ -75,20 +74,18 @@ class LocalSyncService { return _existingSync!.future; } _existingSync = Completer(); - final existingLocalFileIDs = await _db.getExistingLocalFileIDs(); - _logger.info( - existingLocalFileIDs.length.toString() + " localIDs were discovered", - ); - final editedFileIDs = _getEditedFileIDs().toSet(); + final int ownerID = Configuration.instance.getUserID()!; + final existingLocalFileIDs = await _db.getExistingLocalFileIDs(ownerID); + _logger.info("${existingLocalFileIDs.length} localIDs were discovered"); + final syncStartTime = DateTime.now().microsecondsSinceEpoch; final lastDBUpdationTime = _prefs.getInt(kDbUpdationTimeKey) ?? 0; final startTime = DateTime.now().microsecondsSinceEpoch; if (lastDBUpdationTime != 0) { - await _loadAndStorePhotos( - lastDBUpdationTime, - syncStartTime, + await _loadAndStoreDiff( existingLocalFileIDs, - editedFileIDs, + fromTime: lastDBUpdationTime, + toTime: syncStartTime, ); } else { // Load from 0 - 01.01.2010 @@ -97,25 +94,22 @@ class LocalSyncService { var toYear = 2010; var toTime = DateTime(toYear).microsecondsSinceEpoch; while (toTime < syncStartTime) { - await _loadAndStorePhotos( - startTime, - toTime, + await _loadAndStoreDiff( existingLocalFileIDs, - editedFileIDs, + fromTime: startTime, + toTime: toTime, ); startTime = toTime; toYear++; toTime = DateTime(toYear).microsecondsSinceEpoch; } - await _loadAndStorePhotos( - startTime, - syncStartTime, + await _loadAndStoreDiff( existingLocalFileIDs, - editedFileIDs, + fromTime: startTime, + toTime: syncStartTime, ); } - if (!_prefs.containsKey(kHasCompletedFirstImportKey) || - !(_prefs.getBool(kHasCompletedFirstImportKey)!)) { + if (!hasCompletedFirstImport()) { await _prefs.setBool(kHasCompletedFirstImportKey, true); // mark device collection has imported on first import await _refreshDeviceFolderCountAndCover(isFirstSync: true); @@ -177,7 +171,12 @@ class LocalSyncService { } Future syncAll() async { + if (!Configuration.instance.isLoggedIn()) { + _logger.warning("syncCall called when user is not logged in"); + return false; + } final stopwatch = EnteWatch("localSyncAll")..start(); + final localAssets = await getAllLocalAssets(); _logger.info( "Loading allLocalAssets ${localAssets.length} took ${stopwatch.elapsedMilliseconds}ms ", @@ -186,7 +185,8 @@ class LocalSyncService { _logger.info( "refreshDeviceFolderCountAndCover + allLocalAssets took ${stopwatch.elapsedMilliseconds}ms ", ); - final existingLocalFileIDs = await _db.getExistingLocalFileIDs(); + final int ownerID = Configuration.instance.getUserID()!; + final existingLocalFileIDs = await _db.getExistingLocalFileIDs(ownerID); final Map> pathToLocalIDs = await _db.getDevicePathIDToLocalIDMap(); final invalidIDs = _getInvalidFileIDs().toSet(); @@ -236,15 +236,6 @@ class LocalSyncService { return hasUnsyncedFiles; } - List _getEditedFileIDs() { - if (_prefs.containsKey(kEditedFileIDsKey)) { - return _prefs.getStringList(kEditedFileIDsKey)!; - } else { - final List editedIDs = []; - return editedIDs; - } - } - Future trackInvalidFile(File file) async { if (file.localID == null) { debugPrint("Warning: Invalid file has no localID"); @@ -296,7 +287,6 @@ class LocalSyncService { kHasCompletedFirstImportKey, hasImportedDeviceCollections, kDbUpdationTimeKey, - kEditedFileIDsKey, "has_synced_edit_time", "has_selected_all_folders_for_backup", ]) { @@ -304,39 +294,40 @@ class LocalSyncService { } } - Future _loadAndStorePhotos( - int fromTime, - int toTime, - Set existingLocalFileIDs, - Set editedFileIDs, - ) async { + Future _loadAndStoreDiff( + Set existingLocalDs, { + required int fromTime, + required int toTime, + }) async { final Tuple2, List> result = await getLocalPathAssetsAndFiles(fromTime, toTime, _computer); + + // Update the mapping for device path_id to local file id. Also, keep track + // of newly discovered device paths await FilesDB.instance.insertLocalAssets( result.item1, shouldAutoBackup: Configuration.instance.hasSelectedAllFoldersForBackup(), ); + final List files = result.item2; - _logger.info( - "Loaded ${files.length} photos from " + - DateTime.fromMicrosecondsSinceEpoch(fromTime).toString() + - " to " + - DateTime.fromMicrosecondsSinceEpoch(toTime).toString(), - ); if (files.isNotEmpty) { - await _trackUpdatedFiles( - files, - existingLocalFileIDs, - editedFileIDs, + _logger.info( + "Loaded ${files.length} photos from " + + DateTime.fromMicrosecondsSinceEpoch(fromTime).toString() + + " to " + + DateTime.fromMicrosecondsSinceEpoch(toTime).toString(), ); + await _trackUpdatedFiles(files, existingLocalDs); + // keep reference of all Files for firing LocalPhotosUpdatedEvent final List allFiles = []; allFiles.addAll(files); - files.removeWhere((file) => existingLocalFileIDs.contains(file.localID)); + // remove existing files and insert newly imported files in the table + files.removeWhere((file) => existingLocalDs.contains(file.localID)); await _db.insertMultiple( files, conflictAlgorithm: ConflictAlgorithm.ignore, ); - _logger.info("Inserted " + files.length.toString() + " files."); + _logger.info('Inserted ${files.length} files'); Bus.instance.fire( LocalPhotosUpdatedEvent(allFiles, source: "loadedPhoto"), ); @@ -347,22 +338,16 @@ class LocalSyncService { Future _trackUpdatedFiles( List files, Set existingLocalFileIDs, - Set editedFileIDs, ) async { - final updatedFiles = files - .where((file) => existingLocalFileIDs.contains(file.localID)) + final List updatedLocalIDs = files + .where( + (file) => + file.localID != null && + existingLocalFileIDs.contains(file.localID), + ) + .map((e) => e.localID!) .toList(); - updatedFiles.removeWhere((file) => editedFileIDs.contains(file.localID)); - if (updatedFiles.isNotEmpty) { - _logger.info( - updatedFiles.length.toString() + " local files were updated.", - ); - final List updatedLocalIDs = []; - for (final file in updatedFiles) { - if (file.localID != null) { - updatedLocalIDs.add(file.localID!); - } - } + if (updatedLocalIDs.isNotEmpty) { await FileUpdationDB.instance.insertMultiple( updatedLocalIDs, FileUpdationDB.modificationTimeUpdated, diff --git a/lib/services/push_service.dart b/lib/services/push_service.dart index e74f35af90d4d6fdfe8a666da6a32a142f9f0341..1bee67a45a2b37e9dc706a431a8741fb214ec6fa 100644 --- a/lib/services/push_service.dart +++ b/lib/services/push_service.dart @@ -4,7 +4,7 @@ import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/events/signed_in_event.dart'; import 'package:photos/services/sync_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -74,7 +74,7 @@ class PushService { String fcmToken, String? apnsToken, ) async { - await Network.instance.enteDio.post( + await NetworkClient.instance.enteDio.post( "/push/token", data: { "fcmToken": fcmToken, diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 3e0c0318055a1859ae0e26ad7ec1f155bbcb380f..6f41bba7aeb43c547ecc525aa11348f74ada5045 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -1,6 +1,6 @@ import 'package:logging/logging.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/data/holidays.dart'; import 'package:photos/data/months.dart'; import 'package:photos/data/years.dart'; @@ -21,7 +21,7 @@ import 'package:tuple/tuple.dart'; class SearchService { Future>? _cachedFilesFuture; - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; final _logger = Logger((SearchService).toString()); final _collectionService = CollectionsService.instance; static const _maximumResultsLimit = 20; @@ -115,8 +115,8 @@ class SearchService { break; } - if (!c.collection.isHidden() && c.collection.type != CollectionType - .uncategorized && + if (!c.collection.isHidden() && + c.collection.type != CollectionType.uncategorized && c.collection.name!.toLowerCase().contains( query.toLowerCase(), )) { diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 99bd4785656e744aba7f56ab49546393ad63f5d2..cdd5e7643bc38a8ee46c5e03fe75aaedd7af1829 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -9,7 +9,7 @@ import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/errors.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/device_files_db.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/events/permission_granted_event.dart'; @@ -28,7 +28,7 @@ class SyncService { final _logger = Logger("SyncService"); final _localSyncService = LocalSyncService.instance; final _remoteSyncService = RemoteSyncService.instance; - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; final _uploader = FileUploader.instance; bool _syncStopRequested = false; Completer? _existingSync; diff --git a/lib/services/trash_sync_service.dart b/lib/services/trash_sync_service.dart index 961436ef2687e2b8361755e6ed3539ae082459b8..4178331bdfcdbdf6216f15ffdf801c27eb914be7 100644 --- a/lib/services/trash_sync_service.dart +++ b/lib/services/trash_sync_service.dart @@ -4,7 +4,7 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/trash_db.dart'; import 'package:photos/events/collection_updated_event.dart'; import 'package:photos/events/force_reload_trash_page_event.dart'; @@ -29,7 +29,7 @@ class TrashSyncService { static final TrashSyncService instance = TrashSyncService._privateConstructor(); - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; void init(SharedPreferences preferences) { _prefs = preferences; diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 570449214b95ed0b56e89b3448b581bbf596c460..ee051ae683a99493292b9af946fdbb035814c5a3 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:photos/core/constants.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/services/notification_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:tuple/tuple.dart'; @@ -98,7 +98,7 @@ class UpdateService { } Future _getLatestVersionInfo() async { - final response = await Network.instance + final response = await NetworkClient.instance .getDio() .get("https://ente.io/release-info/independent.json"); return LatestVersionInfo.fromMap(response.data["latestVersion"]); diff --git a/lib/services/user_remote_flag_service.dart b/lib/services/user_remote_flag_service.dart index b850deae895fa70e7298e7eb72721f4723ada194..3a1d122e08c2f072ccaab3a73164b89521f72138 100644 --- a/lib/services/user_remote_flag_service.dart +++ b/lib/services/user_remote_flag_service.dart @@ -4,13 +4,13 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/events/notification_event.dart'; import 'package:photos/services/user_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class UserRemoteFlagService { - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; final _logger = Logger((UserRemoteFlagService).toString()); late SharedPreferences _prefs; diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index e4bf4c13bc177e541789dbe664deb2bb77ee87d4..a9dc5c01368d21044ed7c051a2e2e6e2b595a6db 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -9,7 +9,7 @@ import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/public_keys_db.dart'; import 'package:photos/events/two_factor_status_change_event.dart'; import 'package:photos/events/user_details_changed_event.dart'; @@ -37,8 +37,8 @@ import 'package:shared_preferences/shared_preferences.dart'; class UserService { static const keyHasEnabledTwoFactor = "has_enabled_two_factor"; static const keyUserDetails = "user_details"; - final _dio = Network.instance.getDio(); - final _enteDio = Network.instance.enteDio; + final _dio = NetworkClient.instance.getDio(); + final _enteDio = NetworkClient.instance.enteDio; final _logger = Logger((UserService).toString()); final _config = Configuration.instance; late SharedPreferences _preferences; @@ -117,6 +117,8 @@ 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( @@ -127,8 +129,10 @@ class UserService { await PublicKeysDB.instance.setKey(PublicKey(email, publicKey)); return publicKey; } on DioError catch (e) { - _logger.info(e); - return null; + if (e.response != null && e.response?.statusCode == 404) { + return null; + } + rethrow; } } @@ -197,21 +201,23 @@ class UserService { } Future logout(BuildContext context) async { - final dialog = createProgressDialog(context, "Logging out..."); - await dialog.show(); try { final response = await _enteDio.post("/users/logout"); if (response.statusCode == 200) { await Configuration.instance.logout(); - await dialog.hide(); Navigator.of(context).popUntil((route) => route.isFirst); } else { throw Exception("Log out action failed"); } } catch (e) { _logger.severe(e); - await dialog.hide(); - showGenericErrorDialog(context: context); + //This future is for waiting for the dialog from which logout() is called + //to close and only then to show the error dialog. + Future.delayed( + const Duration(milliseconds: 150), + () => showGenericErrorDialog(context: context), + ); + rethrow; } } diff --git a/lib/ui/account/delete_account_page.dart b/lib/ui/account/delete_account_page.dart index c6cc97ba052058ba3321bef3e425d15331726719..a0e4bccc139b2837d10dac5fd804f0bea7e5bb6d 100644 --- a/lib/ui/account/delete_account_page.dart +++ b/lib/ui/account/delete_account_page.dart @@ -131,7 +131,7 @@ class DeleteAccountPage extends StatelessWidget { ); if (hasAuthenticated) { - final choice = await showNewChoiceDialog( + final choice = await showChoiceDialog( context, title: 'Are you sure you want to delete your account?', body: diff --git a/lib/ui/account/password_reentry_page.dart b/lib/ui/account/password_reentry_page.dart index 30650f07d5d3f03aa888a0a41b93f3651318c70c..c8b0601f514301f7e0a3c3c49ceb4d09c789807a 100644 --- a/lib/ui/account/password_reentry_page.dart +++ b/lib/ui/account/password_reentry_page.dart @@ -80,7 +80,7 @@ class _PasswordReentryPageState extends State { } on KeyDerivationError catch (e, s) { _logger.severe("Password verification failed", e, s); await dialog.hide(); - final dialogChoice = await showNewChoiceDialog( + final dialogChoice = await showChoiceDialog( context, title: "Recreate password", body: "The current device is not powerful enough to verify your " @@ -102,7 +102,7 @@ class _PasswordReentryPageState extends State { } catch (e, s) { _logger.severe("Password verification failed", e, s); await dialog.hide(); - final dialogChoice = await showNewChoiceDialog( + final dialogChoice = await showChoiceDialog( context, title: "Incorrect password", body: "Please try again", diff --git a/lib/ui/account/sessions_page.dart b/lib/ui/account/sessions_page.dart index 80fe7cc0afd2a6be03fd4d9ed331d3af4bcf9cdc..be219e5824e64ee39e3e6ccc6ae00404fe72a4a1 100644 --- a/lib/ui/account/sessions_page.dart +++ b/lib/ui/account/sessions_page.dart @@ -22,7 +22,9 @@ class _SessionsPageState extends State { @override void initState() { - _fetchActiveSessions(); + _fetchActiveSessions().onError((error, stackTrace) { + showToast(context, "Failed to fetch active sessions"); + }); super.initState(); } @@ -115,9 +117,9 @@ class _SessionsPageState extends State { await UserService.instance.terminateSession(session.token); await _fetchActiveSessions(); await dialog.hide(); - } catch (e, s) { + } catch (e) { await dialog.hide(); - _logger.severe('failed to terminate', e, s); + _logger.severe('failed to terminate'); showErrorDialog( context, 'Oops', @@ -127,17 +129,17 @@ class _SessionsPageState extends State { } Future _fetchActiveSessions() async { - _sessions = await UserService.instance - .getActiveSessions() - .onError((error, stackTrace) { - showToast(context, "Failed to fetch active sessions"); - throw error!; + _sessions = await UserService.instance.getActiveSessions().onError((e, s) { + _logger.severe("failed to fetch active sessions", e, s); + throw e!; }); if (_sessions != null) { _sessions!.sessions.sort((first, second) { return second.lastUsedTime.compareTo(first.lastUsedTime); }); - setState(() {}); + if (mounted) { + setState(() {}); + } } } diff --git a/lib/ui/account/verify_recovery_page.dart b/lib/ui/account/verify_recovery_page.dart index 3b8680a0ad29c5b8a6550743110ee678fadc2632..4c7b42ef15385c467f504d2c33fa46a406de2803 100644 --- a/lib/ui/account/verify_recovery_page.dart +++ b/lib/ui/account/verify_recovery_page.dart @@ -74,7 +74,7 @@ class _VerifyRecoveryPageState extends State { "The recovery key you entered is not valid. Please make sure it " "contains 24 words, and check the spelling of each.\n\nIf you " "entered an older recovery code, make sure it is 64 characters long, and check each of them."; - final result = await showNewChoiceDialog( + final result = await showChoiceDialog( context, title: "Invalid key", body: errMessage, diff --git a/lib/ui/actions/collection/collection_file_actions.dart b/lib/ui/actions/collection/collection_file_actions.dart index 57572e22c673a1b90d8d2797653f44d55ffb9124..846f56744459d5cc2de7c61d68b9b263692bafaf 100644 --- a/lib/ui/actions/collection/collection_file_actions.dart +++ b/lib/ui/actions/collection/collection_file_actions.dart @@ -1,10 +1,8 @@ import 'package:flutter/cupertino.dart'; -import 'package:photos/db/files_db.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/services/favorites_service.dart'; -import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; import 'package:photos/ui/common/progress_dialog.dart'; import 'package:photos/ui/components/action_sheet_widget.dart'; @@ -18,13 +16,15 @@ extension CollectionFileActions on CollectionActions { BuildContext bContext, Collection collection, SelectedFiles selectedFiles, + bool removingOthersFile, ) async { final actionResult = await showActionSheet( context: bContext, buttons: [ ButtonWidget( - labelText: "Yes, remove", - buttonType: ButtonType.neutral, + labelText: "Remove", + buttonType: + removingOthersFile ? ButtonType.critical : ButtonType.neutral, buttonSize: ButtonSize.large, shouldStickToDarkTheme: true, isInAlert: true, @@ -50,9 +50,11 @@ extension CollectionFileActions on CollectionActions { isInAlert: true, ), ], - title: "Remove from album?", - body: "Selected items will be removed from this album. Items which are " - "only in this album will be moved to Uncategorized.", + title: removingOthersFile ? "Remove from album?" : null, + body: removingOthersFile + ? "Some of the items you are removing were " + "added by other people, and you will lose access to them" + : "Selected items will be removed from this album", actionSheetType: ActionSheetType.defaultActionSheet, ); if (actionResult != null && actionResult == ButtonAction.error) { @@ -62,103 +64,6 @@ extension CollectionFileActions on CollectionActions { } } - Future showRemoveFromCollectionSheet( - BuildContext context, - Collection collection, - SelectedFiles selectedFiles, - ) async { - final count = selectedFiles.files.length; - final textTheme = getEnteTextTheme(context); - final showDeletePrompt = await _anyItemPresentOnlyInCurrentAlbum( - selectedFiles.files, - collection.id, - ); - final String title = - showDeletePrompt ? "Delete items?" : "Remove from album?"; - final String message1 = showDeletePrompt - ? "Some of the selected items are present only in this album and will be deleted." - : "Selected items will be removed from this album."; - - final String message2 = showDeletePrompt - ? "\n\nItems which are also " - "present in other albums will be removed from this album but will remain elsewhere." - : ""; - - final action = CupertinoActionSheet( - title: Text( - title, - style: textTheme.h3Bold, - textAlign: TextAlign.left, - ), - message: RichText( - text: TextSpan( - children: [ - TextSpan(text: message1, style: textTheme.body), - TextSpan(text: message2, style: textTheme.body) - ], - ), - ), - actions: [ - CupertinoActionSheetAction( - isDestructiveAction: true, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - final dialog = createProgressDialog( - context, - showDeletePrompt ? "Deleting files..." : "Removing files...", - ); - await dialog.show(); - try { - await collectionsService.removeFromCollection( - collection.id, - selectedFiles.files.toList(), - ); - await dialog.hide(); - selectedFiles.clearAll(); - } catch (e, s) { - logger.severe(e, s); - await dialog.hide(); - showGenericErrorDialog(context: context); - } - }, - child: Text(showDeletePrompt ? "Yes, delete" : "Yes, remove"), - ), - ], - cancelButton: CupertinoActionSheetAction( - child: const Text("Cancel"), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop(); - }, - ), - ); - await showCupertinoModalPopup(context: context, builder: (_) => action); - } - - // check if any of the file only belongs in the given collection id. - // if true, then we need to warn the user that some of the items will be - // deleted - Future _anyItemPresentOnlyInCurrentAlbum( - Set files, - int collectionID, - ) async { - final List uploadedIDs = files - .where((e) => e.uploadedFileID != null) - .map((e) => e.uploadedFileID!) - .toList(); - - final Map> collectionToFilesMap = - await FilesDB.instance.getAllFilesGroupByCollectionID(uploadedIDs); - final Set ids = uploadedIDs.toSet(); - for (MapEntry> entry in collectionToFilesMap.entries) { - if (entry.key == collectionID) { - logger.finest('ignore the collection from which remove is happening'); - continue; - } - ids.removeAll(entry.value.map((f) => f.uploadedFileID!).toSet()); - } - return ids.isNotEmpty; - } - Future updateFavorites( BuildContext context, List files, diff --git a/lib/ui/actions/collection/collection_sharing_actions.dart b/lib/ui/actions/collection/collection_sharing_actions.dart index 352ee0fd9a7bdbc896b6c7268e0311d674e44f89..b2a92437d15f27d91e58a2fd180acb3a43a5c85f 100644 --- a/lib/ui/actions/collection/collection_sharing_actions.dart +++ b/lib/ui/actions/collection/collection_sharing_actions.dart @@ -13,8 +13,10 @@ import 'package:photos/services/hidden_service.dart'; import 'package:photos/services/user_service.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/common/progress_dialog.dart'; import 'package:photos/ui/components/action_sheet_widget.dart'; import 'package:photos/ui/components/button_widget.dart'; +import 'package:photos/ui/components/dialog_widget.dart'; import 'package:photos/ui/components/models/button_type.dart'; import 'package:photos/ui/payment/subscription.dart'; import 'package:photos/utils/date_time_util.dart'; @@ -29,59 +31,18 @@ class CollectionActions { CollectionActions(this.collectionsService); - Future publicLinkToggle( + Future enableUrl( BuildContext context, - Collection collection, - bool enable, - ) async { - // confirm if user wants to disable the url - if (!enable) { - final ButtonAction? result = await showActionSheet( - context: context, - buttons: [ - ButtonWidget( - buttonType: ButtonType.critical, - isInAlert: true, - shouldStickToDarkTheme: true, - buttonAction: ButtonAction.first, - shouldSurfaceExecutionStates: true, - labelText: "Yes, remove", - onTap: () async { - await CollectionsService.instance.disableShareUrl(collection); - }, - ), - const ButtonWidget( - buttonType: ButtonType.secondary, - buttonAction: ButtonAction.cancel, - isInAlert: true, - shouldStickToDarkTheme: true, - labelText: "Cancel", - ) - ], - title: "Remove public link", - body: - 'This will remove the public link for accessing "${collection.name}".', - ); - if (result != null) { - if (result == ButtonAction.error) { - showGenericErrorDialog(context: context); - } - return result == ButtonAction.first; - } else { - return false; - } - } - final dialog = createProgressDialog( - context, - "Creating link...", - ); + Collection collection, { + bool enableCollect = false, + }) async { try { - await dialog.show(); - await CollectionsService.instance.createShareUrl(collection); - dialog.hide(); + await CollectionsService.instance.createShareUrl( + collection, + enableCollect: enableCollect, + ); return true; } catch (e) { - dialog.hide(); if (e is SharingNotPermittedForFreeAccountsError) { _showUnSupportedAlert(context); } else { @@ -92,6 +53,43 @@ class CollectionActions { } } + Future disableUrl(BuildContext context, Collection collection) async { + final ButtonAction? result = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + buttonType: ButtonType.critical, + isInAlert: true, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: "Yes, remove", + onTap: () async { + await CollectionsService.instance.disableShareUrl(collection); + }, + ), + const ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: true, + labelText: "Cancel", + ) + ], + title: "Remove public link", + body: + 'This will remove the public link for accessing "${collection.name}".', + ); + if (result != null) { + if (result == ButtonAction.error) { + showGenericErrorDialog(context: context); + } + return result == ButtonAction.first; + } else { + return false; + } + } + Future createSharedCollectionLink( BuildContext context, List files, @@ -137,44 +135,55 @@ class CollectionActions { } // removeParticipant remove the user from a share album - Future removeParticipant( + Future removeParticipant( BuildContext context, Collection collection, User user, ) async { - final result = await showNewChoiceDialog( - context, - title: "Remove", - body: "${user.email} will be removed", - firstButtonLabel: "Yes, remove", - firstButtonOnTap: () async { - try { - final newSharees = await CollectionsService.instance - .unshare(collection.id, user.email); - collection.updateSharees(newSharees); - } catch (e, s) { - Logger("EmailItemWidget").severe(e, s); - rethrow; - } - }, + final ButtonAction? result = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + buttonType: ButtonType.critical, + isInAlert: true, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: "Yes, remove", + onTap: () async { + final newSharees = await CollectionsService.instance + .unshare(collection.id, user.email); + collection.updateSharees(newSharees); + }, + ), + const ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: true, + labelText: "Cancel", + ) + ], + title: "Remove?", + body: '${user.email} will be removed from this shared album\n\nAny ' + 'photos added by them will also be removed from the album', ); - if (result == ButtonAction.error) { - await showGenericErrorDialog(context: context); - return false; - } - if (result == ButtonAction.first) { - return true; - } else { - return false; + if (result != null) { + if (result == ButtonAction.error) { + showGenericErrorDialog(context: context); + } + return result == ButtonAction.first; } + return false; } - Future addEmailToCollection( + // addEmailToCollection returns true if add operation was successful + Future addEmailToCollection( BuildContext context, Collection collection, - String email, { - CollectionParticipantRole role = CollectionParticipantRole.viewer, - String? publicKey, + String email, + CollectionParticipantRole role, { + bool showProgress = false, }) async { if (!isValidEmail(email)) { await showErrorDialog( @@ -182,80 +191,64 @@ class CollectionActions { "Invalid email address", "Please enter a valid email address.", ); - return null; - } else if (email == Configuration.instance.getEmail()) { + return false; + } else if (email.trim() == Configuration.instance.getEmail()) { await showErrorDialog(context, "Oops", "You cannot share with yourself"); - return null; - } else { - // if (collection.getSharees().any((user) => user.email == email)) { - // showErrorDialog( - // context, - // "Oops", - // "You're already sharing this with " + email, - // ); - // return null; - // } + return false; } - if (publicKey == null) { - final dialog = createProgressDialog(context, "Searching for user..."); + + ProgressDialog? dialog; + String? publicKey; + if (showProgress) { + dialog = createProgressDialog(context, "Sharing...", isDismissible: true); await dialog.show(); - try { - publicKey = await UserService.instance.getPublicKey(email); - await dialog.hide(); - } catch (e) { - logger.severe("Failed to get public key", e); - showGenericErrorDialog(context: context); - await dialog.hide(); - } } - // getPublicKey can return null - // ignore: unnecessary_null_comparison + + try { + publicKey = await UserService.instance.getPublicKey(email); + } catch (e) { + await dialog?.hide(); + logger.severe("Failed to get public key", e); + showGenericErrorDialog(context: context); + return false; + } + // getPublicKey can return null when no user is associated with given + // email id if (publicKey == null || publicKey == '') { - final dialog = AlertDialog( - title: const Text("Invite to ente?"), - content: Text( - "Looks like " + - email + - " hasn't signed up for ente yet. would you like to invite them?", - style: const TextStyle( - height: 1.4, - ), - ), - actions: [ - TextButton( - child: Text( - "Invite", - style: TextStyle( - color: Theme.of(context).colorScheme.greenAlternative, - ), - ), - onPressed: () { + // 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: "Invite to ente", + icon: Icons.info_outline, + body: "$email does not have an ente account\n\nSend them an invite to" + " add them after they sign up", + isDismissible: true, + buttons: [ + ButtonWidget( + buttonType: ButtonType.neutral, + icon: Icons.adaptive.share, + labelText: "Send invite", + isInAlert: true, + onTap: () async { shareText( - "Hey, I have some photos to share. Please install https://ente.io so that I can share them privately.", + "Download ente so we can easily share original quality photos" + " and videos\n\nhttps://ente.io/#download", ); }, ), ], ); - await showDialog( - context: context, - builder: (BuildContext context) { - return dialog; - }, - ); - return null; + return false; } else { - final dialog = createProgressDialog(context, "Sharing..."); - await dialog.show(); try { final newSharees = await CollectionsService.instance .share(collection.id, email, publicKey, role); + await dialog?.hide(); collection.updateSharees(newSharees); - await dialog.hide(); - showShortToast(context, "Shared successfully!"); return true; } catch (e) { - await dialog.hide(); + await dialog?.hide(); if (e is SharingNotPermittedForFreeAccountsError) { _showUnSupportedAlert(context); } else { @@ -267,6 +260,7 @@ class CollectionActions { } } + // deleteCollectionSheet returns true if the album is successfully deleted Future deleteCollectionSheet( BuildContext bContext, Collection collection, @@ -276,6 +270,13 @@ class CollectionActions { if (collection.owner!.id != currentUserID) { throw AssertionError("Can not delete album owned by others"); } + if (collection.hasSharees) { + final bool confirmDelete = + await _confirmSharedAlbumDeletion(bContext, collection); + if (!confirmDelete) { + return false; + } + } final actionResult = await showActionSheet( context: bContext, buttons: [ @@ -293,8 +294,9 @@ class CollectionActions { await moveFilesFromCurrentCollection(bContext, collection, files); // collection should be empty on server now await collectionsService.trashEmptyCollection(collection); - } catch (e) { - logger.severe("Failed to keep photos and delete collection", e); + } catch (e, s) { + logger.severe( + "Failed to keep photos and delete collection", e, s); rethrow; } }, @@ -356,6 +358,23 @@ class CollectionActions { return false; } + // _confirmSharedAlbumDeletion should be shown when user tries to delete an + // album shared with other ente users. + Future _confirmSharedAlbumDeletion( + BuildContext context, + Collection collection, + ) async { + final ButtonAction? result = await showChoiceActionSheet( + context, + isCritical: true, + title: "Delete shared album?", + firstButtonLabel: "Delete album", + body: "The album will be deleted for everyone\n\nYou will lose access to " + "shared photos in this album that are owned by others", + ); + return result != null && result == ButtonAction.first; + } + /* _moveFilesFromCurrentCollection removes the file from the current collection. Based on the file and collection ownership, files will be @@ -380,19 +399,26 @@ class CollectionActions { ) async { final int currentUserID = Configuration.instance.getUserID()!; final isCollectionOwner = collection.owner!.id == currentUserID; - if (!isCollectionOwner) { - // Todo: Support for removing own files from a collection owner by - // someone else will be added along with collaboration changes - showShortToast(context, "Only collection owner can remove"); - return; - } final FilesSplit split = FilesSplit.split( files, Configuration.instance.getUserID()!, ); - if (split.ownedByOtherUsers.isNotEmpty) { - // Todo: Support for removing own files from a collection owner by - // someone else will be added along with collaboration changes + if (isCollectionOwner && split.ownedByOtherUsers.isNotEmpty) { + await collectionsService.removeFromCollection( + collection.id, + split.ownedByOtherUsers, + ); + } else if (!isCollectionOwner && split.ownedByCurrentUser.isNotEmpty) { + // collection is not owned by the user, just remove files owned + // by current user and return + await collectionsService.removeFromCollection( + collection.id, + split.ownedByCurrentUser, + ); + return; + } + + if (!isCollectionOwner && split.ownedByOtherUsers.isNotEmpty) { showShortToast(context, "Can only remove files owned by you"); return; } diff --git a/lib/ui/advanced_settings_screen.dart b/lib/ui/advanced_settings_screen.dart index 0e7817dbfda144c087277adcb611ed592f8f8c71..35cf726046048f7e53c038b2735086e7b2b8eab2 100644 --- a/lib/ui/advanced_settings_screen.dart +++ b/lib/ui/advanced_settings_screen.dart @@ -1,15 +1,12 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:photos/core/constants.dart'; -import 'package:photos/core/event_bus.dart'; -import 'package:photos/events/force_reload_home_gallery_event.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/icon_button_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/title_bar_title_widget.dart'; import 'package:photos/ui/components/title_bar_widget.dart'; import 'package:photos/ui/tools/debug/app_storage_viewer.dart'; +import 'package:photos/ui/viewer/gallery/photo_grid_size_picker_page.dart'; import 'package:photos/utils/local_settings.dart'; import 'package:photos/utils/navigation_util.dart'; @@ -21,12 +18,11 @@ class AdvancedSettingsScreen extends StatefulWidget { } class _AdvancedSettingsScreenState extends State { - late int _photoGridSize, _chosenGridSize; + late int _photoGridSize; @override void initState() { _photoGridSize = LocalSettings.instance.getPhotoGridSize(); - _chosenGridSize = _photoGridSize; super.initState(); } @@ -66,7 +62,15 @@ class _AdvancedSettingsScreenState extends State { children: [ GestureDetector( onTap: () { - _showPhotoGridSizePicker(delegateBuildContext); + routeToPage( + context, + const PhotoGridSizePickerPage(), + ).then((value) { + setState(() { + _photoGridSize = LocalSettings.instance + .getPhotoGridSize(); + }); + }); }, child: MenuItemWidget( captionedTextWidget: CaptionedTextWidget( @@ -78,9 +82,8 @@ class _AdvancedSettingsScreenState extends State { Icons.chevron_right_outlined, color: colorScheme.strokeBase, ), - borderRadius: 8, + singleBorderRadius: 8, alignCaptionedTextToLeft: true, - // isBottomBorderRadiusRemoved: true, isGestureDetectorDisabled: true, ), ), @@ -96,9 +99,9 @@ class _AdvancedSettingsScreenState extends State { Icons.chevron_right_outlined, color: colorScheme.strokeBase, ), - borderRadius: 8, + singleBorderRadius: 8, alignCaptionedTextToLeft: true, - onTap: () { + onTap: () async { routeToPage(context, const AppStorageViewer()); }, ), @@ -116,106 +119,4 @@ class _AdvancedSettingsScreenState extends State { ), ); } - - Future _showPhotoGridSizePicker(BuildContext buildContext) async { - final textTheme = getEnteTextTheme(buildContext); - final List options = []; - for (int gridSize = photoGridSizeMin; - gridSize <= photoGridSizeMax; - gridSize++) { - options.add( - Text( - gridSize.toString(), - style: textTheme.body, - ), - ); - } - return showCupertinoModalPopup( - context: context, - builder: (context) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Container( - decoration: BoxDecoration( - color: getEnteColorScheme(buildContext).backgroundElevated2, - border: const Border( - bottom: BorderSide( - color: Color(0xff999999), - width: 0.0, - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CupertinoButton( - onPressed: () { - Navigator.of(context).pop('cancel'); - }, - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 5.0, - ), - child: Text( - 'Cancel', - style: textTheme.body, - ), - ), - CupertinoButton( - onPressed: () async { - await LocalSettings.instance - .setPhotoGridSize(_chosenGridSize); - Bus.instance.fire( - ForceReloadHomeGalleryEvent("grid size changed"), - ); - _photoGridSize = _chosenGridSize; - setState(() {}); - Navigator.of(context).pop(''); - }, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 2.0, - ), - child: Text( - 'Confirm', - style: textTheme.body, - ), - ) - ], - ), - ), - Container( - height: 220.0, - color: const Color(0xfff7f7f7), - child: CupertinoPicker( - backgroundColor: - getEnteColorScheme(buildContext).backgroundElevated, - onSelectedItemChanged: (index) { - _chosenGridSize = _getPhotoGridSizeFromIndex(index); - setState(() {}); - }, - scrollController: FixedExtentScrollController( - initialItem: _getIndexFromPhotoGridSize(_chosenGridSize), - ), - magnification: 1.3, - useMagnifier: true, - itemExtent: 25, - diameterRatio: 1, - children: options, - ), - ) - ], - ); - }, - ); - } - - int _getPhotoGridSizeFromIndex(int index) { - return index + 2; - } - - int _getIndexFromPhotoGridSize(int gridSize) { - return gridSize - 2; - } } diff --git a/lib/ui/backup_settings_screen.dart b/lib/ui/backup_settings_screen.dart index 8b838bb81bc968a0f92656022be6462a7f6ce2bd..a908b21d9a929ceed09019d103e2113c6bf88c11 100644 --- a/lib/ui/backup_settings_screen.dart +++ b/lib/ui/backup_settings_screen.dart @@ -6,7 +6,7 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/divider_widget.dart'; import 'package:photos/ui/components/icon_button_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_description_widget.dart'; import 'package:photos/ui/components/title_bar_title_widget.dart'; import 'package:photos/ui/components/title_bar_widget.dart'; @@ -65,7 +65,7 @@ class BackupSettingsScreen extends StatelessWidget { ); }, ), - borderRadius: 8, + singleBorderRadius: 8, alignCaptionedTextToLeft: true, isBottomBorderRadiusRemoved: true, isGestureDetectorDisabled: true, @@ -87,7 +87,7 @@ class BackupSettingsScreen extends StatelessWidget { !Configuration.instance.shouldBackupVideos(), ), ), - borderRadius: 8, + singleBorderRadius: 8, alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, isGestureDetectorDisabled: true, @@ -115,7 +115,7 @@ class BackupSettingsScreen extends StatelessWidget { ); }, ), - borderRadius: 8, + singleBorderRadius: 8, alignCaptionedTextToLeft: true, isGestureDetectorDisabled: true, ), diff --git a/lib/ui/common/dialogs.dart b/lib/ui/common/dialogs.dart deleted file mode 100644 index 3b1c2cb4036007c3a590d7414d2cc78ff6e803a0..0000000000000000000000000000000000000000 --- a/lib/ui/common/dialogs.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:photos/ente_theme_data.dart'; - -enum DialogUserChoice { firstChoice, secondChoice } - -enum ActionType { - confirm, - critical, -} - -// if dialog is dismissed by tapping outside, this will return null -Future showChoiceDialog( - BuildContext context, - String title, - String content, { - String firstAction = 'Ok', - Color? firstActionColor, - String secondAction = 'Cancel', - Color? secondActionColor, - ActionType actionType = ActionType.confirm, -}) { - final AlertDialog alert = AlertDialog( - title: Text( - title, - style: TextStyle( - color: actionType == ActionType.critical - ? Colors.red - : Theme.of(context).colorScheme.primary, - ), - ), - content: Text( - content, - style: const TextStyle( - height: 1.4, - ), - ), - actions: [ - TextButton( - child: Text( - firstAction, - style: TextStyle( - color: firstActionColor ?? - (actionType == ActionType.critical - ? Colors.red - : Theme.of(context).colorScheme.onSurface), - ), - ), - onPressed: () { - Navigator.of(context, rootNavigator: true) - .pop(DialogUserChoice.firstChoice); - }, - ), - TextButton( - child: Text( - secondAction, - style: TextStyle( - color: secondActionColor ?? - Theme.of(context).colorScheme.greenAlternative, - ), - ), - onPressed: () { - Navigator.of(context, rootNavigator: true) - .pop(DialogUserChoice.secondChoice); - }, - ), - ], - ); - - return showDialog( - context: context, - builder: (BuildContext context) { - return alert; - }, - barrierColor: Colors.black87, - ); -} diff --git a/lib/ui/components/action_sheet_widget.dart b/lib/ui/components/action_sheet_widget.dart index 305905a33d11a64e2621ef4be418242ea4a42b97..b20aea7bbde0362641b12144e99d645889e48db8 100644 --- a/lib/ui/components/action_sheet_widget.dart +++ b/lib/ui/components/action_sheet_widget.dart @@ -105,7 +105,7 @@ class ActionSheetWidget extends StatelessWidget { isTitleAndBodyNull ? const SizedBox.shrink() : Padding( - padding: const EdgeInsets.only(bottom: 28), + padding: const EdgeInsets.only(bottom: 36), child: ContentContainerWidget( title: title, bodyWidget: bodyWidget, diff --git a/lib/ui/components/album_list_item_widget.dart b/lib/ui/components/album_list_item_widget.dart index da83526924d97806c195a2ce78f206ffbab36485..ba99a0883d2149be645290d55906dad78ef75496 100644 --- a/lib/ui/components/album_list_item_widget.dart +++ b/lib/ui/components/album_list_item_widget.dart @@ -37,6 +37,7 @@ class AlbumListItemWidget extends StatelessWidget { ? ThumbnailWidget( item.thumbnail, showFavForAlbumOnly: true, + shouldShowOwnerAvatar: false, ) : const NoThumbnailWidget( addBorder: false, diff --git a/lib/ui/components/dialog_widget.dart b/lib/ui/components/dialog_widget.dart index 0999c366400ef7492ad96fa0fea4bb678d128bff..df26b12da1c693835ad8f6ee1a674e0b9a2e0b0d 100644 --- a/lib/ui/components/dialog_widget.dart +++ b/lib/ui/components/dialog_widget.dart @@ -112,7 +112,7 @@ class ContentContainer extends StatelessWidget { children: [ Icon( icon, - size: 48, + size: 32, ), ], ), diff --git a/lib/ui/components/expandable_menu_item_widget.dart b/lib/ui/components/expandable_menu_item_widget.dart index fdd6e0dbd235883a2060d30e7c8fd532d776f0ff..6bc9590635c09dedf010f5daa88e6c13c1a5b5fa 100644 --- a/lib/ui/components/expandable_menu_item_widget.dart +++ b/lib/ui/components/expandable_menu_item_widget.dart @@ -2,7 +2,7 @@ import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/ui/settings/inherited_settings_state.dart'; diff --git a/lib/ui/components/menu_item_widget/menu_item_child_widgets.dart b/lib/ui/components/menu_item_widget/menu_item_child_widgets.dart new file mode 100644 index 0000000000000000000000000000000000000000..59f7f69a587133f3b5770d3767eae1bd57c5daa3 --- /dev/null +++ b/lib/ui/components/menu_item_widget/menu_item_child_widgets.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/common/loading_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; + +class TrailingWidget extends StatefulWidget { + final ValueNotifier executionStateNotifier; + final IconData? trailingIcon; + final Color? trailingIconColor; + final Widget? trailingWidget; + final bool trailingIconIsMuted; + final double trailingExtraMargin; + final bool showExecutionStates; + const TrailingWidget({ + required this.executionStateNotifier, + this.trailingIcon, + this.trailingIconColor, + this.trailingWidget, + required this.trailingIconIsMuted, + required this.trailingExtraMargin, + required this.showExecutionStates, + super.key, + }); + @override + State createState() => _TrailingWidgetState(); +} + +class _TrailingWidgetState extends State { + Widget? trailingWidget; + @override + void initState() { + widget.showExecutionStates + ? widget.executionStateNotifier.addListener(_executionStateListener) + : null; + super.initState(); + } + + @override + void dispose() { + widget.executionStateNotifier.removeListener(_executionStateListener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (trailingWidget == null || !widget.showExecutionStates) { + _setTrailingIcon(); + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 175), + switchInCurve: Curves.easeInExpo, + switchOutCurve: Curves.easeOutExpo, + child: trailingWidget, + ); + } + + void _executionStateListener() { + final colorScheme = getEnteColorScheme(context); + setState(() { + if (widget.executionStateNotifier.value == ExecutionState.idle) { + _setTrailingIcon(); + } else if (widget.executionStateNotifier.value == + ExecutionState.inProgress) { + trailingWidget = EnteLoadingWidget( + color: colorScheme.strokeMuted, + ); + } else if (widget.executionStateNotifier.value == + ExecutionState.successful) { + trailingWidget = Icon( + Icons.check_outlined, + size: 22, + color: colorScheme.primary500, + ); + } else { + trailingWidget = const SizedBox.shrink(); + } + }); + } + + void _setTrailingIcon() { + if (widget.trailingIcon != null) { + trailingWidget = Padding( + padding: EdgeInsets.only( + right: widget.trailingExtraMargin, + ), + child: Icon( + widget.trailingIcon, + color: widget.trailingIconIsMuted + ? getEnteColorScheme(context).strokeMuted + : widget.trailingIconColor, + ), + ); + } else { + trailingWidget = widget.trailingWidget ?? const SizedBox.shrink(); + } + } +} + +class ExpansionTrailingIcon extends StatelessWidget { + final bool isExpanded; + final IconData? trailingIcon; + final Color? trailingIconColor; + const ExpansionTrailingIcon({ + required this.isExpanded, + this.trailingIcon, + this.trailingIconColor, + super.key, + }); + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + opacity: isExpanded ? 0 : 1, + child: AnimatedSwitcher( + transitionBuilder: (child, animation) { + return ScaleTransition(scale: animation, child: child); + }, + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + child: isExpanded + ? const SizedBox.shrink() + : Icon( + trailingIcon, + color: trailingIconColor, + ), + ), + ); + } +} + +class LeadingWidget extends StatelessWidget { + final IconData? leadingIcon; + final Color? leadingIconColor; + + final Widget? leadingIconWidget; + // leadIconSize deafult value is 20. + final double leadingIconSize; + const LeadingWidget({ + required this.leadingIconSize, + this.leadingIcon, + this.leadingIconColor, + this.leadingIconWidget, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 10), + child: SizedBox( + height: leadingIconSize, + width: leadingIconSize, + child: leadingIcon == null + ? (leadingIconWidget != null + ? FittedBox( + fit: BoxFit.contain, + child: leadingIconWidget, + ) + : const SizedBox.shrink()) + : FittedBox( + fit: BoxFit.contain, + child: Icon( + leadingIcon, + color: leadingIconColor ?? + getEnteColorScheme(context).strokeBase, + ), + ), + ), + ); + } +} diff --git a/lib/ui/components/menu_item_widget.dart b/lib/ui/components/menu_item_widget/menu_item_widget.dart similarity index 50% rename from lib/ui/components/menu_item_widget.dart rename to lib/ui/components/menu_item_widget/menu_item_widget.dart index c2f6ad85c234d8462c373abaca0d47cdc2a03ece..f89d60a85eb11c00e21b982ec23433f683c3bba7 100644 --- a/lib/ui/components/menu_item_widget.dart +++ b/lib/ui/components/menu_item_widget/menu_item_widget.dart @@ -1,7 +1,17 @@ import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; -import 'package:photos/ente_theme_data.dart'; import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_child_widgets.dart'; +import 'package:photos/utils/debouncer.dart'; + +enum ExecutionState { + idle, + inProgress, + error, + successful; +} + +typedef FutureVoidCallback = Future Function(); class MenuItemWidget extends StatefulWidget { final Widget captionedTextWidget; @@ -13,6 +23,7 @@ class MenuItemWidget extends StatefulWidget { final Color? leadingIconColor; final Widget? leadingIconWidget; + // leadIconSize deafult value is 20. final double leadingIconSize; @@ -25,11 +36,17 @@ class MenuItemWidget extends StatefulWidget { /// If provided, add this much extra spacing to the right of the trailing icon. final double trailingExtraMargin; - final VoidCallback? onTap; + final FutureVoidCallback? onTap; final VoidCallback? onDoubleTap; final Color? menuItemColor; final bool alignCaptionedTextToLeft; - final double borderRadius; + + // singleBorderRadius is applied to the border when it's a standalone menu item. + // Widget will apply singleBorderRadius if value of both isTopBorderRadiusRemoved + // and isBottomBorderRadiusRemoved is false. Otherwise, multipleBorderRadius will + // be applied + final double singleBorderRadius; + final double multipleBorderRadius; final Color? pressedColor; final ExpandableController? expandableController; final bool isBottomBorderRadiusRemoved; @@ -38,6 +55,18 @@ class MenuItemWidget extends StatefulWidget { /// disable gesture detector if not used final bool isGestureDetectorDisabled; + ///Success state will not be shown if this flag is set to true, only idle and + ///loading state + final bool showOnlyLoadingState; + + final bool surfaceExecutionStates; + + ///To show success state even when execution time < debouce time, set this + ///flag to true. If the loading state needs to be shown and success state not, + ///set the showOnlyLoadingState flag to true, setting this flag to false won't + ///help. + final bool alwaysShowSuccessState; + const MenuItemWidget({ required this.captionedTextWidget, this.isExpandable = false, @@ -54,12 +83,16 @@ class MenuItemWidget extends StatefulWidget { this.onDoubleTap, this.menuItemColor, this.alignCaptionedTextToLeft = false, - this.borderRadius = 4.0, + this.singleBorderRadius = 4.0, + this.multipleBorderRadius = 8.0, this.pressedColor, this.expandableController, this.isBottomBorderRadiusRemoved = false, this.isTopBorderRadiusRemoved = false, this.isGestureDetectorDisabled = false, + this.showOnlyLoadingState = false, + this.surfaceExecutionStates = true, + this.alwaysShowSuccessState = false, Key? key, }) : super(key: key); @@ -68,11 +101,20 @@ class MenuItemWidget extends StatefulWidget { } class _MenuItemWidgetState extends State { + final _debouncer = Debouncer(const Duration(milliseconds: 300)); + ValueNotifier executionStateNotifier = + ValueNotifier(ExecutionState.idle); + Color? menuItemColor; + late double borderRadius; @override void initState() { menuItemColor = widget.menuItemColor; + borderRadius = + (widget.isBottomBorderRadiusRemoved || widget.isTopBorderRadiusRemoved) + ? widget.multipleBorderRadius + : widget.singleBorderRadius; if (widget.expandableController != null) { widget.expandableController!.addListener(() { setState(() {}); @@ -87,11 +129,18 @@ class _MenuItemWidgetState extends State { super.didChangeDependencies(); } + @override + void didUpdateWidget(covariant MenuItemWidget oldWidget) { + menuItemColor = widget.menuItemColor; + super.didUpdateWidget(oldWidget); + } + @override void dispose() { if (widget.expandableController != null) { widget.expandableController!.dispose(); } + executionStateNotifier.dispose(); super.dispose(); } @@ -100,7 +149,7 @@ class _MenuItemWidgetState extends State { return widget.isExpandable || widget.isGestureDetectorDisabled ? menuItemWidget(context) : GestureDetector( - onTap: widget.onTap, + onTap: _onTap, onDoubleTap: widget.onDoubleTap, onTapDown: _onTapDown, onTapUp: _onTapUp, @@ -110,16 +159,15 @@ class _MenuItemWidgetState extends State { } Widget menuItemWidget(BuildContext context) { - final enteColorScheme = Theme.of(context).colorScheme.enteTheme.colorScheme; - final borderRadius = Radius.circular(widget.borderRadius); + final circularRadius = Radius.circular(borderRadius); final isExpanded = widget.expandableController?.value; final bottomBorderRadius = (isExpanded != null && isExpanded) || widget.isBottomBorderRadiusRemoved ? const Radius.circular(0) - : borderRadius; + : circularRadius; final topBorderRadius = widget.isTopBorderRadiusRemoved ? const Radius.circular(0) - : borderRadius; + : circularRadius; return AnimatedContainer( duration: const Duration(milliseconds: 20), width: double.infinity, @@ -138,67 +186,75 @@ class _MenuItemWidgetState extends State { children: [ widget.alignCaptionedTextToLeft && widget.leadingIcon == null ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only(right: 10), - child: SizedBox( - height: widget.leadingIconSize, - width: widget.leadingIconSize, - child: widget.leadingIcon == null - ? (widget.leadingIconWidget != null - ? FittedBox( - fit: BoxFit.contain, - child: widget.leadingIconWidget, - ) - : const SizedBox.shrink()) - : FittedBox( - fit: BoxFit.contain, - child: Icon( - widget.leadingIcon, - color: widget.leadingIconColor ?? - enteColorScheme.strokeBase, - ), - ), - ), + : LeadingWidget( + leadingIconSize: widget.leadingIconSize, + leadingIcon: widget.leadingIcon, + leadingIconColor: widget.leadingIconColor, + leadingIconWidget: widget.leadingIconWidget, ), widget.captionedTextWidget, - widget.expandableController != null - ? AnimatedOpacity( - duration: const Duration(milliseconds: 100), - curve: Curves.easeInOut, - opacity: isExpanded! ? 0 : 1, - child: AnimatedSwitcher( - transitionBuilder: (child, animation) { - return ScaleTransition(scale: animation, child: child); - }, - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - child: isExpanded - ? const SizedBox.shrink() - : Icon( - widget.trailingIcon, - color: widget.trailingIconColor, - ), - ), - ) - : widget.trailingIcon != null - ? Padding( - padding: EdgeInsets.only( - right: widget.trailingExtraMargin, - ), - child: Icon( - widget.trailingIcon, - color: widget.trailingIconIsMuted - ? enteColorScheme.strokeMuted - : widget.trailingIconColor, - ), - ) - : widget.trailingWidget ?? const SizedBox.shrink(), + if (widget.expandableController != null) + ExpansionTrailingIcon( + isExpanded: isExpanded!, + trailingIcon: widget.trailingIcon, + trailingIconColor: widget.trailingIconColor, + ) + else + TrailingWidget( + executionStateNotifier: executionStateNotifier, + trailingIcon: widget.trailingIcon, + trailingIconColor: widget.trailingIconColor, + trailingWidget: widget.trailingWidget, + trailingIconIsMuted: widget.trailingIconIsMuted, + trailingExtraMargin: widget.trailingExtraMargin, + showExecutionStates: widget.surfaceExecutionStates, + key: ValueKey(widget.trailingIcon.hashCode), + ), ], ), ); } + Future _onTap() async { + if (executionStateNotifier.value == ExecutionState.inProgress || + executionStateNotifier.value == ExecutionState.successful) return; + _debouncer.run( + () => Future( + () { + executionStateNotifier.value = ExecutionState.inProgress; + }, + ), + ); + await widget.onTap?.call().then( + (value) { + widget.alwaysShowSuccessState + ? executionStateNotifier.value = ExecutionState.successful + : null; + }, + onError: (error, stackTrace) => _debouncer.cancelDebounce(), + ); + _debouncer.cancelDebounce(); + if (widget.alwaysShowSuccessState) { + Future.delayed(const Duration(seconds: 2), () { + executionStateNotifier.value = ExecutionState.idle; + }); + return; + } + if (executionStateNotifier.value == ExecutionState.inProgress) { + if (widget.showOnlyLoadingState) { + executionStateNotifier.value = ExecutionState.idle; + } else { + executionStateNotifier.value = ExecutionState.successful; + Future.delayed(const Duration(seconds: 2), () { + executionStateNotifier.value = ExecutionState.idle; + }); + } + } + } + void _onTapDown(details) { + if (executionStateNotifier.value == ExecutionState.inProgress || + executionStateNotifier.value == ExecutionState.successful) return; setState(() { if (widget.pressedColor == null) { hasPassedGestureCallbacks() @@ -215,6 +271,8 @@ class _MenuItemWidgetState extends State { } void _onTapUp(details) { + if (executionStateNotifier.value == ExecutionState.inProgress || + executionStateNotifier.value == ExecutionState.successful) return; Future.delayed( const Duration(milliseconds: 100), () => setState(() { @@ -224,6 +282,8 @@ class _MenuItemWidgetState extends State { } void _onCancel() { + if (executionStateNotifier.value == ExecutionState.inProgress || + executionStateNotifier.value == ExecutionState.successful) return; setState(() { menuItemColor = widget.menuItemColor; }); diff --git a/lib/ui/components/toggle_switch_widget.dart b/lib/ui/components/toggle_switch_widget.dart index 8968a9f78f03a02396b73a82d7448e03a77ecc8d..7b6a8208418a15aade7ea3b032f4863c7ecb1d60 100644 --- a/lib/ui/components/toggle_switch_widget.dart +++ b/lib/ui/components/toggle_switch_widget.dart @@ -9,12 +9,12 @@ enum ExecutionState { successful, } -typedef FutureVoidCallBack = Future Function(); +typedef FutureVoidCallback = Future Function(); typedef BoolCallBack = bool Function(); class ToggleSwitchWidget extends StatefulWidget { final BoolCallBack value; - final FutureVoidCallBack onChanged; + final FutureVoidCallback onChanged; const ToggleSwitchWidget({ required this.value, required this.onChanged, diff --git a/lib/ui/create_collection_sheet.dart b/lib/ui/create_collection_sheet.dart index 32220f3e94f39aaca568c8d96a9e2fd5de0f134b..c2f5d7ab63cca9c582cfcf66d0a7fdada66f2603 100644 --- a/lib/ui/create_collection_sheet.dart +++ b/lib/ui/create_collection_sheet.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:photos/core/configuration.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/models/collection_items.dart'; @@ -360,11 +361,17 @@ class _CreateCollectionSheetState extends State { } Future _addToCollection(int collectionID) async { - final dialog = createProgressDialog(context, "Uploading files to album..."); + final dialog = createProgressDialog( + context, + "Uploading files to album" + "...", + isDismissible: true, + ); await dialog.show(); try { final List files = []; final List filesPendingUpload = []; + final int currentUserID = Configuration.instance.getUserID()!; if (widget.sharedFiles != null) { filesPendingUpload.addAll( await convertIncomingSharedMediaToFile( @@ -374,8 +381,17 @@ class _CreateCollectionSheetState extends State { ); } else { for (final file in widget.selectedFiles!.files) { - final File? currentFile = - await (FilesDB.instance.getFile(file.generatedID!)); + File? currentFile; + if (file.uploadedFileID != null) { + currentFile = file; + } else if (file.generatedID != null) { + // when file is not uploaded, refresh the state from the db to + // ensure we have latest upload status for given file before + // queueing it up as pending upload + currentFile = await (FilesDB.instance.getFile(file.generatedID!)); + } else if (file.generatedID == null) { + _logger.severe("generated id should not be null"); + } if (currentFile == null) { _logger.severe("Failed to find fileBy genID"); continue; @@ -389,11 +405,20 @@ class _CreateCollectionSheetState extends State { } } if (filesPendingUpload.isNotEmpty) { - // filesPendingUpload might be getting ignored during auto-upload - // because the user deleted these files from ente in the past. - await IgnoredFilesService.instance - .removeIgnoredMappings(filesPendingUpload); - await FilesDB.instance.insertMultiple(filesPendingUpload); + // Newly created collection might not be cached + final Collection? c = + CollectionsService.instance.getCollectionByID(collectionID); + if (c != null && c.owner!.id != currentUserID) { + showToast(context, "Can not upload to albums owned by others"); + await dialog.hide(); + return false; + } else { + // filesPendingUpload might be getting ignored during auto-upload + // because the user deleted these files from ente in the past. + await IgnoredFilesService.instance + .removeIgnoredMappings(filesPendingUpload); + await FilesDB.instance.insertMultiple(filesPendingUpload); + } } if (files.isNotEmpty) { await CollectionsService.instance.addToCollection(collectionID, files); @@ -414,7 +439,7 @@ class _CreateCollectionSheetState extends State { final String message = widget.actionType == CollectionActionType.moveFiles ? "Moving files to album..." : "Unhiding files to album"; - final dialog = createProgressDialog(context, message); + final dialog = createProgressDialog(context, message, isDismissible: true); await dialog.show(); try { final int fromCollectionID = @@ -442,7 +467,8 @@ class _CreateCollectionSheetState extends State { } Future _restoreFilesToCollection(int toCollectionID) async { - final dialog = createProgressDialog(context, "Restoring files..."); + final dialog = createProgressDialog(context, "Restoring files...", + isDismissible: true); await dialog.show(); try { await CollectionsService.instance diff --git a/lib/ui/notification/update/change_log_page.dart b/lib/ui/notification/update/change_log_page.dart index 57f1cc1694b842c6f83a39203ba654346e248581..5b9efe4399f4b0c41ba7f307cfb9a6891d517b16 100644 --- a/lib/ui/notification/update/change_log_page.dart +++ b/lib/ui/notification/update/change_log_page.dart @@ -103,49 +103,34 @@ class _ChangeLogPageState extends State { final List items = []; items.add( ChangeLogEntry( - "Quick links!", - "Select some photos, choose \"Create link\" from the selection " - "options, and, well, that's it! You'll get a link that you can " - "share, end-to-end encrypted and secure.\n\nYour quick links will " - "appear at the bottom of the share tab so that you can remove them " - "when they're no longer needed, or convert them to regular albums " - "by renaming them if you want them to stick around.\n\nDepending on the feedback, we'll iterate on this (automatically prune quick " - "links, directly open the photo if only a single photo is shared, " - "etc). So let us know which direction you wish us to head!", + "Collaborative albums ✨", + "Much awaited, they're here now - create albums where multiple ente " + "users can add photos!\n\nWhen sharing an album, you can specify if" + " you want to add someone as a viewer or a collaborator. Collaborators can add photos " + "to the shared album.\n\nAlbums can have both collaborators and viewers, and as many as " + "you like. Storage is only counted once, for the person who uploaded the photo." + "\n\nHead over to the sharing options for an album to start adding collaborators.", ), ); items.add( ChangeLogEntry( - '''Filename search''', - "You can search for files by their names now.", + "Uncategorized", + "You can now keep photos that do not belong to a specific album." + "\n\nThis will simplify deletion and make it safer since now ente " + "will have a place to put photos that don't belong to any album " + "instead of always deleting them.\n\nThis will also allow you to " + "choose between keeping vs deleting photos present in the album, " + "when deleting an album.\n\nUncategorized photos can be seen from " + "the bottom of the albums tab.", ), ); items.add( ChangeLogEntry( - '''Prune empty albums''', - "There is now a button on the albums tab to remove all empty albums in one go. This will help customers with many empty albums clear out their clutter, and will be visible if you have more than 3 empty albums.", - ), - ); - items.add( - ChangeLogEntry( - '''Clear caches''', - "Under Settings > General > Advanced, you'll now see an option to " - "view and manage how ente uses temporary storage on your device." - "\n\nThe list will show a breakdown of cached files - Attaching a " - "screenshot of this would help if you feel the ente is using more" - " storage than expected.\n\nThere is also an option to clear all " - "these temporarily cached files to free up space on your device.", - ), - ); - - items.add( - ChangeLogEntry( - '''Reset ignored files''', - "We've added help text to clarify when a file in an on-device album " - "is ignored for backups because it was deleted from ente earlier," - " and an option to reset this state.\n\nWe've also fixed a bug " - "where an on-device album would get unmarked from backups after using the free up space option within it.", + '''Cleaner album picker''', + "Among other improvements, the list of albums that is shown when adding " + "or moving photos gets a facelift, and an issue causing the photo " + "zoom to be reset after loading the full resolution photo has been fixed.", isFeature: false, ), ); diff --git a/lib/ui/payment/billing_questions_widget.dart b/lib/ui/payment/billing_questions_widget.dart index b4ea481bb9781bd92bed00955ffeb7fa6ea6c2b4..52dc0bff78f4a5942bc21f1fe5b13801700e35c3 100644 --- a/lib/ui/payment/billing_questions_widget.dart +++ b/lib/ui/payment/billing_questions_widget.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:flutter/material.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/ui/common/loading_widget.dart'; @@ -14,7 +14,7 @@ class BillingQuestionsWidget extends StatelessWidget { @override Widget build(BuildContext context) { return FutureBuilder( - future: Network.instance + future: NetworkClient.instance .getDio() .get("https://static.ente.io/faq.json") .then((response) { diff --git a/lib/ui/payment/child_subscription_widget.dart b/lib/ui/payment/child_subscription_widget.dart index 7ea3912cabfb94adc8342080dc0345aa5fa770d0..b80cbe356d8c58ba3e42a9b1d2fa51563d020568 100644 --- a/lib/ui/payment/child_subscription_widget.dart +++ b/lib/ui/payment/child_subscription_widget.dart @@ -118,7 +118,7 @@ class ChildSubscriptionWidget extends StatelessWidget { } Future _leaveFamilyPlan(BuildContext context) async { - final choice = await showNewChoiceDialog( + final choice = await showChoiceDialog( context, title: "Leave family", body: "Are you sure that you want to leave the family plan?", diff --git a/lib/ui/payment/stripe_subscription_page.dart b/lib/ui/payment/stripe_subscription_page.dart index a91ba652c1d63909391734f458fd28f383730051..1328c31ea3064f32f27473ae9e1cb0fdeff7bb56 100644 --- a/lib/ui/payment/stripe_subscription_page.dart +++ b/lib/ui/payment/stripe_subscription_page.dart @@ -344,7 +344,7 @@ class _StripeSubscriptionPageState extends State { onPressed: () async { bool confirmAction = false; if (isRenewCancelled) { - final choice = await showNewChoiceDialog( + final choice = await showChoiceDialog( context, title: title, body: "Are you sure you want to renew?", @@ -352,7 +352,7 @@ class _StripeSubscriptionPageState extends State { ); confirmAction = choice == ButtonAction.first; } else { - final choice = await showNewChoiceDialog( + final choice = await showChoiceDialog( context, title: title, body: "Are you sure you want to cancel?", @@ -429,7 +429,7 @@ class _StripeSubscriptionPageState extends State { String stripPurChaseAction = 'buy'; if (_isStripeSubscriber && _hasActiveSubscription) { // confirm if user wants to change plan or not - final result = await showNewChoiceDialog( + final result = await showChoiceDialog( context, title: "Confirm plan change", body: "Are you sure you want to change your plan?", diff --git a/lib/ui/settings/about_section_widget.dart b/lib/ui/settings/about_section_widget.dart index 3faae4985a18d2ae4ff765a436ee45d3140fe304..297b558e67bb3dea879635a85ba3a56886e0e710 100644 --- a/lib/ui/settings/about_section_widget.dart +++ b/lib/ui/settings/about_section_widget.dart @@ -4,7 +4,7 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/common/web_page.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/app_update_dialog.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/utils/dialog_util.dart'; @@ -113,7 +113,7 @@ class AboutMenuItemWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { diff --git a/lib/ui/settings/account_section_widget.dart b/lib/ui/settings/account_section_widget.dart index 276647709f6a80d0146ecf87825a9bff5817ea9a..9a965100c90aa9e56e49f76090121cfb1e745ca6 100644 --- a/lib/ui/settings/account_section_widget.dart +++ b/lib/ui/settings/account_section_widget.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; -import 'package:photos/ente_theme_data.dart'; import 'package:photos/services/local_authentication_service.dart'; import 'package:photos/services/user_service.dart'; import 'package:photos/theme/ente_theme.dart'; @@ -12,7 +11,7 @@ import 'package:photos/ui/account/password_entry_page.dart'; import 'package:photos/ui/account/recovery_key_page.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/navigation_util.dart'; @@ -40,6 +39,7 @@ class AccountSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, + showOnlyLoadingState: true, onTap: () async { final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication( @@ -76,6 +76,7 @@ class AccountSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, + showOnlyLoadingState: true, onTap: () async { final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication( @@ -102,6 +103,7 @@ class AccountSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, + showOnlyLoadingState: true, onTap: () async { final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication( @@ -129,7 +131,7 @@ class AccountSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { _onLogoutTapped(context); }, ), @@ -141,7 +143,7 @@ class AccountSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { routeToPage(context, const DeleteAccountPage()); }, ), @@ -156,46 +158,14 @@ class AccountSectionWidget extends StatelessWidget { ); } - Future _onLogoutTapped(BuildContext context) async { - final AlertDialog alert = AlertDialog( - title: const Text( - "Logout", - style: TextStyle( - color: Colors.red, - ), - ), - content: const Text("Are you sure you want to logout?"), - actions: [ - TextButton( - child: const Text( - "Yes, logout", - style: TextStyle( - color: Colors.red, - ), - ), - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop('dialog'); - await UserService.instance.logout(context); - }, - ), - TextButton( - child: Text( - "No", - style: TextStyle( - color: Theme.of(context).colorScheme.greenAlternative, - ), - ), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop('dialog'); - }, - ), - ], - ); - - await showDialog( - context: context, - builder: (BuildContext context) { - return alert; + void _onLogoutTapped(BuildContext context) { + showChoiceActionSheet( + context, + title: "Are you sure you want to logout?", + firstButtonLabel: "Yes, logout", + isCritical: true, + firstButtonOnTap: () async { + await UserService.instance.logout(context); }, ); } diff --git a/lib/ui/settings/app_update_dialog.dart b/lib/ui/settings/app_update_dialog.dart index 34954f9e55f49287b9b1d1b6064571b7d502d1fa..29cb62cfd8fd9cb50279444bb3d6863bc25236f2 100644 --- a/lib/ui/settings/app_update_dialog.dart +++ b/lib/ui/settings/app_update_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; // import 'package:open_file/open_file.dart'; import 'package:photos/core/configuration.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/ente_theme_data.dart'; import 'package:photos/services/update_service.dart'; import 'package:photos/theme/ente_theme.dart'; @@ -183,7 +183,7 @@ class _ApkDownloaderDialogState extends State { Future _downloadApk() async { try { - await Network.instance.getDio().download( + await NetworkClient.instance.getDio().download( widget.versionInfo!.url, _saveUrl, onReceiveProgress: (count, _) { diff --git a/lib/ui/settings/backup_section_widget.dart b/lib/ui/settings/backup_section_widget.dart index f29764e4c5db387646e203695c857bc476c318c2..415d01c93b008f69cd65b0f46150bedd42814ebb 100644 --- a/lib/ui/settings/backup_section_widget.dart +++ b/lib/ui/settings/backup_section_widget.dart @@ -12,7 +12,7 @@ import 'package:photos/ui/backup_settings_screen.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/dialog_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/models/button_type.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/ui/tools/deduplicate_page.dart'; @@ -49,7 +49,7 @@ class BackupSectionWidgetState extends State { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { routeToPage( context, const BackupFolderSelectionPage( @@ -66,7 +66,7 @@ class BackupSectionWidgetState extends State { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { routeToPage( context, const BackupSettingsScreen(), @@ -85,19 +85,16 @@ class BackupSectionWidgetState extends State { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, + showOnlyLoadingState: true, onTap: () async { - final dialog = createProgressDialog(context, "Calculating..."); - await dialog.show(); BackupStatus status; try { status = await SyncService.instance.getBackupStatus(); } catch (e) { - await dialog.hide(); showGenericErrorDialog(context: context); return; } - await dialog.hide(); if (status.localIDs.isEmpty) { showErrorDialog( context, @@ -121,20 +118,17 @@ class BackupSectionWidgetState extends State { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, + showOnlyLoadingState: true, onTap: () async { - final dialog = createProgressDialog(context, "Calculating..."); - await dialog.show(); List duplicates; try { duplicates = await DeduplicationService.instance.getDuplicateFiles(); } catch (e) { - await dialog.hide(); showGenericErrorDialog(context: context); return; } - await dialog.hide(); if (duplicates.isEmpty) { showErrorDialog( context, diff --git a/lib/ui/settings/debug_section_widget.dart b/lib/ui/settings/debug_section_widget.dart index c5b950dacf3510a79d90a2214e403d6c44ff1fcf..60f27066193bcc81bc818cf6cfef6ecf4b94930d 100644 --- a/lib/ui/settings/debug_section_widget.dart +++ b/lib/ui/settings/debug_section_widget.dart @@ -8,7 +8,7 @@ import 'package:photos/services/update_service.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/utils/toast_util.dart'; diff --git a/lib/ui/settings/general_section_widget.dart b/lib/ui/settings/general_section_widget.dart index a3e73bf340681b095e0f70e6a99c3b4ae8c580e2..23b9eff891f225d2a14460c57f3bb1b14f35fdab 100644 --- a/lib/ui/settings/general_section_widget.dart +++ b/lib/ui/settings/general_section_widget.dart @@ -5,10 +5,9 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/advanced_settings_screen.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/payment/subscription.dart'; import 'package:photos/ui/settings/common_settings.dart'; -import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/navigation_util.dart'; class GeneralSectionWidget extends StatelessWidget { @@ -34,7 +33,7 @@ class GeneralSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { _onManageSubscriptionTapped(context); }, ), @@ -46,8 +45,9 @@ class GeneralSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { - _onFamilyPlansTapped(context); + showOnlyLoadingState: true, + onTap: () async { + await _onFamilyPlansTapped(context); }, ), sectionOptionSpacing, @@ -58,7 +58,7 @@ class GeneralSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { _onAdvancedTapped(context); }, ), @@ -78,11 +78,8 @@ class GeneralSectionWidget extends StatelessWidget { } Future _onFamilyPlansTapped(BuildContext context) async { - final dialog = createProgressDialog(context, "Please wait..."); - await dialog.show(); final userDetails = await UserService.instance.getUserDetailsV2(memoryCount: false); - await dialog.hide(); BillingService.instance.launchFamilyPortal(context, userDetails); } diff --git a/lib/ui/settings/security_section_widget.dart b/lib/ui/settings/security_section_widget.dart index e4d7b746ee703b3320fc18698efaf41c2240bfea..e5c16452b946ebe36f7f54e73b23bfa326cb813a 100644 --- a/lib/ui/settings/security_section_widget.dart +++ b/lib/ui/settings/security_section_widget.dart @@ -11,7 +11,7 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/account/sessions_page.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/toggle_switch_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; @@ -119,6 +119,7 @@ class _SecuritySectionWidgetState extends State { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, + showOnlyLoadingState: true, onTap: () async { final hasAuthenticated = await LocalAuthenticationService.instance .requestLocalAuthentication( diff --git a/lib/ui/settings/social_section_widget.dart b/lib/ui/settings/social_section_widget.dart index 801718d00b7095a3c5293035e88a08e60dd78b9a..43572c7a30045f5d2b9869a3fe0939f41a91baf9 100644 --- a/lib/ui/settings/social_section_widget.dart +++ b/lib/ui/settings/social_section_widget.dart @@ -3,7 +3,7 @@ import 'package:photos/services/update_service.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -69,7 +69,7 @@ class SocialsMenuItemWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { launchUrlString(urlSring); }, ); diff --git a/lib/ui/settings/support_section_widget.dart b/lib/ui/settings/support_section_widget.dart index cbd2458a558f66ffe96c4c177a424033cfebd9b7..dd26ab124a64ced891377bbae51f0ab640d61ee8 100644 --- a/lib/ui/settings/support_section_widget.dart +++ b/lib/ui/settings/support_section_widget.dart @@ -7,7 +7,7 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/common/web_page.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/about_section_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/utils/email_util.dart'; @@ -54,7 +54,7 @@ class SupportSectionWidget extends StatelessWidget { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () { + onTap: () async { Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { diff --git a/lib/ui/settings/theme_switch_widget.dart b/lib/ui/settings/theme_switch_widget.dart index 2b84a0e549da5dc8ee03b74604f067eadc164100..a63ca37b0eb23634d9c9842159941201a0d46b63 100644 --- a/lib/ui/settings/theme_switch_widget.dart +++ b/lib/ui/settings/theme_switch_widget.dart @@ -5,7 +5,7 @@ import 'package:photos/ente_theme_data.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/common_settings.dart'; class ThemeSwitchWidget extends StatefulWidget { diff --git a/lib/ui/shared_collections_gallery.dart b/lib/ui/shared_collections_gallery.dart index ac907228b5e3cc73af87945a2d2c7c180af9fe42..2820e60b480c0e56821fe6f838d8bf08abad7de2 100644 --- a/lib/ui/shared_collections_gallery.dart +++ b/lib/ui/shared_collections_gallery.dart @@ -11,6 +11,7 @@ import 'package:photos/events/collection_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/events/tab_changed_event.dart'; import 'package:photos/events/user_logged_out_event.dart'; +import 'package:photos/models/collection.dart'; import 'package:photos/models/collection_items.dart'; import 'package:photos/models/gallery_type.dart'; import 'package:photos/services/collections_service.dart'; @@ -69,12 +70,22 @@ class _SharedCollectionGalleryState extends State final List outgoing = []; final List incoming = []; for (final file in files) { - final c = CollectionsService.instance - .getCollectionByID(file.collectionID!)!; + if (file.collectionID == null) { + _logger.severe("collection id should not be null"); + continue; + } + final Collection? c = + CollectionsService.instance.getCollectionByID(file.collectionID!); + if (c == null) { + _logger + .severe("shared collection is not cached ${file.collectionID}"); + CollectionsService.instance + .fetchCollectionByID(file.collectionID!) + .ignore(); + continue; + } if (c.owner!.id == Configuration.instance.getUserID()) { - if (c.sharees!.isNotEmpty || - c.publicURLs!.isNotEmpty || - c.isSharedFilesCollection()) { + if (c.hasSharees || c.hasLink || c.isSharedFilesCollection()) { outgoing.add( CollectionWithThumbnail( c, @@ -113,8 +124,12 @@ class _SharedCollectionGalleryState extends State if (snapshot.hasData) { return _getSharedCollectionsGallery(snapshot.data!); } else if (snapshot.hasError) { - _logger.shout(snapshot.error); - return Center(child: Text(snapshot.error.toString())); + _logger.severe( + "critical: failed to load share gallery", + snapshot.error, + snapshot.stackTrace, + ); + return const Center(child: Text("Something went wrong.")); } else { return const EnteLoadingWidget(); } @@ -268,27 +283,29 @@ class OutgoingCollectionItem extends StatelessWidget { @override Widget build(BuildContext context) { - final sharees = []; - for (int index = 0; index < c.collection.sharees!.length; index++) { - final sharee = c.collection.sharees![index]!; - final name = - (sharee.name?.isNotEmpty ?? false) ? sharee.name : sharee.email; - if (index < 2) { - sharees.add(name); - } else { - final remaining = c.collection.sharees!.length - index; - if (remaining == 1) { - // If it's the last sharee - sharees.add(name); + final shareesName = []; + if (c.collection.hasSharees) { + for (int index = 0; index < c.collection.sharees!.length; index++) { + final sharee = c.collection.sharees![index]!; + final String name = + (sharee.name?.isNotEmpty ?? false) ? sharee.name! : sharee.email; + if (index < 2) { + shareesName.add(name); } else { - sharees.add( - "and " + - remaining.toString() + - " other" + - (remaining > 1 ? "s" : ""), - ); + final remaining = c.collection.sharees!.length - index; + if (remaining == 1) { + // If it's the last sharee + shareesName.add(name); + } else { + shareesName.add( + "and " + + remaining.toString() + + " other" + + (remaining > 1 ? "s" : ""), + ); + } + break; } - break; } } return GestureDetector( @@ -325,22 +342,22 @@ class OutgoingCollectionItem extends StatelessWidget { ), ), const Padding(padding: EdgeInsets.all(2)), - c.collection.publicURLs!.isEmpty - ? Container() - : (c.collection.publicURLs!.first!.isExpired + c.collection.hasLink + ? (c.collection.publicURLs!.first!.isExpired ? const Icon( Icons.link, color: warning500, ) - : const Icon(Icons.link)), + : const Icon(Icons.link)) + : Container(), ], ), - sharees.isEmpty + shareesName.isEmpty ? Container() : Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 0), child: Text( - "Shared with " + sharees.join(", "), + "Shared with " + shareesName.join(", "), style: TextStyle( fontSize: 14, color: Theme.of(context).primaryColorLight, diff --git a/lib/ui/sharing/add_partipant_page.dart b/lib/ui/sharing/add_partipant_page.dart index 2368e025e545abfcb8911223013e65cd34396916..5c1ae2554c9873934f0e2501cfae01e35497e5c1 100644 --- a/lib/ui/sharing/add_partipant_page.dart +++ b/lib/ui/sharing/add_partipant_page.dart @@ -1,34 +1,33 @@ import 'package:email_validator/email_validator.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; -import 'package:photos/ui/common/gradient_button.dart'; +import 'package:photos/ui/components/button_widget.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/divider_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_description_widget.dart'; import 'package:photos/ui/components/menu_section_title.dart'; +import 'package:photos/ui/components/models/button_type.dart'; import 'package:photos/ui/sharing/user_avator_widget.dart'; -import 'package:photos/utils/toast_util.dart'; class AddParticipantPage extends StatefulWidget { final Collection collection; + final bool isAddingViewer; - const AddParticipantPage(this.collection, {super.key}); + const AddParticipantPage(this.collection, this.isAddingViewer, {super.key}); @override State createState() => _AddParticipantPage(); } class _AddParticipantPage extends State { - late bool selectAsViewer; String selectedEmail = ''; String _email = ''; - bool hideListOfEmails = false; + bool isEmailListEmpty = false; bool _emailIsValid = false; bool isKeypadOpen = false; late CollectionActions collectionActions; @@ -39,7 +38,6 @@ class _AddParticipantPage extends State { @override void initState() { - selectAsViewer = true; collectionActions = CollectionActions(CollectionsService.instance); super.initState(); } @@ -54,12 +52,13 @@ class _AddParticipantPage extends State { Widget build(BuildContext context) { isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; final enteTextTheme = getEnteTextTheme(context); + final enteColorScheme = getEnteColorScheme(context); final List suggestedUsers = _getSuggestedUser(); - hideListOfEmails = suggestedUsers.isEmpty; + isEmailListEmpty = suggestedUsers.isEmpty; return Scaffold( resizeToAvoidBottomInset: isKeypadOpen, appBar: AppBar( - title: const Text("Add people"), + title: Text(widget.isAddingViewer ? "Add viewer" : "Add collaborator"), ), body: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -70,7 +69,8 @@ class _AddParticipantPage extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text( "Add a new email", - style: enteTextTheme.body, + style: enteTextTheme.small + .copyWith(color: enteColorScheme.textMuted), ), ), const SizedBox(height: 4), @@ -78,20 +78,32 @@ class _AddParticipantPage extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: _getEmailField(), ), - (hideListOfEmails || isKeypadOpen) - ? const Expanded(child: SizedBox()) + (isEmailListEmpty && widget.isAddingViewer) + ? const Expanded(child: SizedBox.shrink()) : Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( children: [ - const SizedBox(height: 24), - const MenuSectionTitle( - title: "or pick an existing one", - ), + !isEmailListEmpty + ? const MenuSectionTitle( + title: "Or pick an existing one", + ) + : const SizedBox.shrink(), Expanded( child: ListView.builder( itemBuilder: (context, index) { + if (index >= suggestedUsers.length) { + return const Padding( + padding: EdgeInsets.symmetric( + vertical: 8.0, + ), + child: MenuSectionDescriptionWidget( + content: + "Collaborators can add photos and videos to the shared album.", + ), + ); + } final currentUser = suggestedUsers[index]; return Column( children: [ @@ -137,8 +149,8 @@ class _AddParticipantPage extends State { ], ); }, - itemCount: suggestedUsers.length, - + itemCount: suggestedUsers.length + + (widget.isAddingViewer ? 0 : 1), // physics: const ClampingScrollPhysics(), ), ), @@ -146,9 +158,6 @@ class _AddParticipantPage extends State { ), ), ), - const DividerWidget( - dividerType: DividerType.solid, - ), SafeArea( child: Padding( padding: const EdgeInsets.only( @@ -160,74 +169,34 @@ class _AddParticipantPage extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const MenuSectionTitle(title: "Add as"), - MenuItemWidget( - captionedTextWidget: const CaptionedTextWidget( - title: "Collaborator", - ), - leadingIcon: Icons.edit_outlined, - menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: !selectAsViewer ? Icons.check : null, - onTap: () async { - if (kDebugMode) { - setState(() => {selectAsViewer = false}); - } else { - showShortToast(context, "Coming soon..."); - } - }, - isBottomBorderRadiusRemoved: true, - ), - DividerWidget( - dividerType: DividerType.menu, - bgColor: getEnteColorScheme(context).fillFaint, - ), - MenuItemWidget( - captionedTextWidget: const CaptionedTextWidget( - title: "Viewer", - ), - leadingIcon: Icons.photo_outlined, - menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: selectAsViewer ? Icons.check : null, - onTap: () async { - setState(() => {selectAsViewer = true}); - // showShortToast(context, "yet to implement"); - }, - isTopBorderRadiusRemoved: true, - ), - !isKeypadOpen - ? const MenuSectionDescriptionWidget( - content: - "Collaborators can add photos and videos to the shared album.", - ) - : const SizedBox.shrink(), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: GradientButton( - onTap: (selectedEmail == '' && !_emailIsValid) - ? null - : () async { - final emailToAdd = - selectedEmail == '' ? _email : selectedEmail; - final result = - await collectionActions.addEmailToCollection( - context, - widget.collection, - emailToAdd, - role: selectAsViewer - ? CollectionParticipantRole.viewer - : CollectionParticipantRole.collaborator, - ); - if (result != null && result && mounted) { - Navigator.of(context).pop(true); - } - }, - text: selectAsViewer ? "Add viewer" : "Add collaborator", - ), - ), const SizedBox(height: 8), + ButtonWidget( + buttonType: ButtonType.primary, + buttonSize: ButtonSize.large, + labelText: widget.isAddingViewer + ? "Add viewer" + : "Add collaborator", + isDisabled: (selectedEmail == '' && !_emailIsValid), + onTap: (selectedEmail == '' && !_emailIsValid) + ? null + : () async { + final emailToAdd = + selectedEmail == '' ? _email : selectedEmail; + final result = + await collectionActions.addEmailToCollection( + context, + widget.collection, + emailToAdd, + widget.isAddingViewer + ? CollectionParticipantRole.viewer + : CollectionParticipantRole.collaborator, + ); + if (result && mounted) { + Navigator.of(context).pop(true); + } + }, + ), + const SizedBox(height: 20), ], ), ), @@ -287,13 +256,8 @@ class _AddParticipantPage extends State { selectedEmail = ''; } _email = value.trim(); - if (_emailIsValid != EmailValidator.validate(_email)) { - setState(() { - _emailIsValid = EmailValidator.validate(_email); - }); - } else if (_email.length < 2) { - setState(() {}); - } + _emailIsValid = EmailValidator.validate(_email); + setState(() {}); }, autocorrect: false, keyboardType: TextInputType.emailAddress, @@ -326,7 +290,15 @@ class _AddParticipantPage extends State { suggestedUsers.add(c.owner!); } } + if (_textController.text.trim().isNotEmpty) { + suggestedUsers.removeWhere( + (element) => !element.email + .toLowerCase() + .contains(_textController.text.trim().toLowerCase()), + ); + } suggestedUsers.sort((a, b) => a.email.compareTo(b.email)); + return suggestedUsers; } } diff --git a/lib/ui/sharing/album_participants_page.dart b/lib/ui/sharing/album_participants_page.dart index 4da72b31a8c32027357e438eacd2669ea437fd0c..bf7b7990855238756d38ed1439b6675ffce1ff26 100644 --- a/lib/ui/sharing/album_participants_page.dart +++ b/lib/ui/sharing/album_participants_page.dart @@ -5,7 +5,7 @@ import 'package:photos/models/collection.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/divider_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_title.dart'; import 'package:photos/ui/components/title_bar_title_widget.dart'; import 'package:photos/ui/components/title_bar_widget.dart'; @@ -51,7 +51,7 @@ class _AlbumParticipantsPageState extends State { Future _navigateToAddUser(bool addingViewer) async { await routeToPage( context, - AddParticipantPage(widget.collection), + AddParticipantPage(widget.collection, addingViewer), ); if (mounted) { setState(() => {}); @@ -72,7 +72,9 @@ class _AlbumParticipantsPageState extends State { 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( @@ -110,7 +112,7 @@ class _AlbumParticipantsPageState extends State { ), leadingIconSize: 24, menuItemColor: colorScheme.fillFaint, - borderRadius: 8, + singleBorderRadius: 8, isGestureDetectorDisabled: true, ), ], @@ -153,17 +155,18 @@ class _AlbumParticipantsPageState extends State { currentUserID: currentUserID, ), menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: isOwner ? Icons.chevron_right : null, trailingIconIsMuted: true, - onTap: () async { - if (isOwner) { - await _navigateToManageUser(currentUser); - } - }, + onTap: isOwner + ? () async { + if (isOwner) { + _navigateToManageUser(currentUser); + } + } + : null, isTopBorderRadiusRemoved: listIndex > 0, isBottomBorderRadiusRemoved: true, - borderRadius: 8, + singleBorderRadius: 8, ), DividerWidget( dividerType: DividerType.menu, @@ -174,18 +177,18 @@ class _AlbumParticipantsPageState extends State { } else if (index == (1 + collaborators.length) && isOwner) { return MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: - collaborators.isNotEmpty ? "Add more" : "Add email", + title: collaborators.isNotEmpty + ? "Add more" + : "Add collaborator", makeTextBold: true, ), leadingIcon: Icons.add_outlined, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, onTap: () async { - await _navigateToAddUser(false); + _navigateToAddUser(false); }, isTopBorderRadiusRemoved: collaborators.isNotEmpty, - borderRadius: 8, + singleBorderRadius: 8, ); } return const SizedBox.shrink(); @@ -226,17 +229,18 @@ class _AlbumParticipantsPageState extends State { currentUserID: currentUserID, ), menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: isOwner ? Icons.chevron_right : null, trailingIconIsMuted: true, - onTap: () async { - if (isOwner) { - await _navigateToManageUser(currentUser); - } - }, + onTap: isOwner + ? () async { + if (isOwner) { + await _navigateToManageUser(currentUser); + } + } + : null, isTopBorderRadiusRemoved: listIndex > 0, isBottomBorderRadiusRemoved: !isLastItem, - borderRadius: 8, + singleBorderRadius: 8, ), isLastItem ? const SizedBox.shrink() @@ -249,17 +253,16 @@ class _AlbumParticipantsPageState extends State { } else if (index == (1 + viewers.length) && isOwner) { return MenuItemWidget( captionedTextWidget: CaptionedTextWidget( - title: viewers.isNotEmpty ? "Add more" : "Add Viewer", + title: viewers.isNotEmpty ? "Add more" : "Add viewer", makeTextBold: true, ), leadingIcon: Icons.add_outlined, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, onTap: () async { - await _navigateToAddUser(true); + _navigateToAddUser(true); }, isTopBorderRadiusRemoved: viewers.isNotEmpty, - borderRadius: 8, + singleBorderRadius: 8, ); } return const SizedBox.shrink(); diff --git a/lib/ui/sharing/manage_album_participant.dart b/lib/ui/sharing/manage_album_participant.dart index 532652a9ce14a57b2db3c0cc675754ebe1b7d743..e3c8340a9e4778871e47a7144f1cad6be459cc6c 100644 --- a/lib/ui/sharing/manage_album_participant.dart +++ b/lib/ui/sharing/manage_album_participant.dart @@ -1,17 +1,17 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; +import 'package:photos/ui/components/button_widget.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/divider_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_description_widget.dart'; import 'package:photos/ui/components/menu_section_title.dart'; import 'package:photos/ui/components/title_bar_title_widget.dart'; -import 'package:photos/utils/toast_util.dart'; +import 'package:photos/utils/dialog_util.dart'; class ManageIndividualParticipant extends StatefulWidget { final Collection collection; @@ -36,6 +36,7 @@ class _ManageIndividualParticipantState Widget build(BuildContext context) { final colorScheme = getEnteColorScheme(context); final textTheme = getEnteTextTheme(context); + bool isConvertToViewSuccess = false; return Scaffold( appBar: AppBar(), body: Padding( @@ -71,23 +72,18 @@ class _ManageIndividualParticipantState ), leadingIcon: Icons.edit_outlined, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: widget.user.isCollaborator ? Icons.check : null, onTap: widget.user.isCollaborator ? null : () async { - if (!kDebugMode) { - showShortToast(context, "Coming soon..."); - return; - } final result = await collectionActions.addEmailToCollection( context, widget.collection, widget.user.email, - role: CollectionParticipantRole.collaborator, + CollectionParticipantRole.collaborator, ); - if ((result ?? false) && mounted) { + if (result && mounted) { widget.user.role = CollectionParticipantRole .collaborator .toStringVal(); @@ -107,22 +103,40 @@ class _ManageIndividualParticipantState leadingIcon: Icons.photo_outlined, leadingIconColor: getEnteColorScheme(context).strokeBase, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: widget.user.isViewer ? Icons.check : null, + showOnlyLoadingState: true, onTap: widget.user.isViewer ? null : () async { - final result = - await collectionActions.addEmailToCollection( + final ButtonAction? result = await showChoiceActionSheet( context, - widget.collection, - widget.user.email, - role: CollectionParticipantRole.viewer, + title: "Change permissions?", + firstButtonLabel: "Yes, convert to viewer", + body: + '${widget.user.email} will not be able to add more photos to this album\n\nThey will still be able to remove existing photos added by them', + isCritical: true, ); - if ((result ?? false) && mounted) { - widget.user.role = - CollectionParticipantRole.viewer.toStringVal(); - setState(() => {}); + if (result != null) { + if (result == ButtonAction.first) { + try { + isConvertToViewSuccess = + await collectionActions.addEmailToCollection( + context, + widget.collection, + widget.user.email, + CollectionParticipantRole.viewer, + ); + } catch (e) { + showGenericErrorDialog(context: context); + } + if (isConvertToViewSuccess && mounted) { + // reset value + isConvertToViewSuccess = false; + widget.user.role = + CollectionParticipantRole.viewer.toStringVal(); + setState(() => {}); + } + } } }, isTopBorderRadiusRemoved: true, @@ -142,7 +156,7 @@ class _ManageIndividualParticipantState leadingIcon: Icons.not_interested_outlined, leadingIconColor: warning500, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, + surfaceExecutionStates: false, onTap: () async { final result = await collectionActions.removeParticipant( context, @@ -150,7 +164,7 @@ class _ManageIndividualParticipantState widget.user, ); - if ((result ?? false) && mounted) { + if ((result) && mounted) { Navigator.of(context).pop(true); } }, diff --git a/lib/ui/sharing/manage_links_widget.dart b/lib/ui/sharing/manage_links_widget.dart index aa9237bf7e9236eedc66a49a385525b1c8d5f273..22b13df2bb6ec50e18d31f7efd4c6bbba7a4999d 100644 --- a/lib/ui/sharing/manage_links_widget.dart +++ b/lib/ui/sharing/manage_links_widget.dart @@ -4,9 +4,7 @@ import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_datetime_picker/flutter_datetime_picker.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; -import 'package:photos/ente_theme_data.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/theme/colors.dart'; @@ -14,13 +12,15 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/divider_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_description_widget.dart'; +import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart'; +import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart'; import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/date_time_util.dart'; import 'package:photos/utils/dialog_util.dart'; +import 'package:photos/utils/navigation_util.dart'; import 'package:photos/utils/toast_util.dart'; -import 'package:tuple/tuple.dart'; class ManageSharedLinkWidget extends StatefulWidget { final Collection? collection; @@ -32,26 +32,11 @@ class ManageSharedLinkWidget extends StatefulWidget { } class _ManageSharedLinkWidgetState extends State { - // index, title, milliseconds in future post which link should expire (when >0) - final List> _expiryOptions = [ - const Tuple3(0, "Never", 0), - Tuple3(1, "After 1 hour", const Duration(hours: 1).inMicroseconds), - Tuple3(2, "After 1 day", const Duration(days: 1).inMicroseconds), - Tuple3(3, "After 1 week", const Duration(days: 7).inMicroseconds), - // todo: make this time calculation perfect - Tuple3(4, "After 1 month", const Duration(days: 30).inMicroseconds), - Tuple3(5, "After 1 year", const Duration(days: 365).inMicroseconds), - const Tuple3(6, "Custom", -1), - ]; - - late Tuple3 _selectedExpiry; - int _selectedDeviceLimitIndex = 0; final CollectionActions sharingActions = CollectionActions(CollectionsService.instance); @override void initState() { - _selectedExpiry = _expiryOptions.first; super.initState(); } @@ -81,7 +66,6 @@ class _ManageSharedLinkWidgetState extends State { ), alignCaptionedTextToLeft: true, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingWidget: Switch.adaptive( value: widget.collection!.publicURLs?.firstOrNull ?.enableCollect ?? @@ -113,8 +97,14 @@ class _ManageSharedLinkWidgetState extends State { ), trailingIcon: Icons.chevron_right, menuItemColor: enteColorScheme.fillFaint, + surfaceExecutionStates: false, onTap: () async { - await showPicker(); + routeToPage( + context, + LinkExpiryPickerPage(widget.collection!), + ).then((value) { + setState(() {}); + }); }, ), url.hasExpiry @@ -138,8 +128,14 @@ class _ManageSharedLinkWidgetState extends State { alignCaptionedTextToLeft: true, isBottomBorderRadiusRemoved: true, onTap: () async { - await _showDeviceLimitPicker(); + routeToPage( + context, + DeviceLimitPickerPage(widget.collection!), + ).then((value) { + setState(() {}); + }); }, + surfaceExecutionStates: false, ), DividerWidget( dividerType: DividerType.menuNoIcon, @@ -153,7 +149,6 @@ class _ManageSharedLinkWidgetState extends State { isBottomBorderRadiusRemoved: true, isTopBorderRadiusRemoved: true, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingWidget: Switch.adaptive( value: widget.collection!.publicURLs?.firstOrNull ?.enableDownload ?? @@ -185,7 +180,6 @@ class _ManageSharedLinkWidgetState extends State { alignCaptionedTextToLeft: true, isTopBorderRadiusRemoved: true, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingWidget: Switch.adaptive( value: widget.collection!.publicURLs?.firstOrNull ?.passwordEnabled ?? @@ -224,16 +218,14 @@ class _ManageSharedLinkWidgetState extends State { leadingIcon: Icons.remove_circle_outline, leadingIconColor: warning500, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, + surfaceExecutionStates: false, onTap: () async { - final bool result = await sharingActions.publicLinkToggle( + final bool result = await sharingActions.disableUrl( context, widget.collection!, - false, ); if (result && mounted) { Navigator.of(context).pop(); - // setState(() => {}); } }, ), @@ -246,153 +238,6 @@ class _ManageSharedLinkWidgetState extends State { ); } - Future showPicker() async { - return showCupertinoModalPopup( - context: context, - builder: (context) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.cupertinoPickerTopColor, - border: const Border( - bottom: BorderSide( - color: Color(0xff999999), - width: 0.0, - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CupertinoButton( - onPressed: () { - Navigator.of(context).pop('cancel'); - }, - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 5.0, - ), - child: Text( - 'Cancel', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - CupertinoButton( - onPressed: () async { - int newValidTill = -1; - bool hasSelectedCustom = false; - final int expireAfterInMicroseconds = - _selectedExpiry.item3; - // need to manually select time - if (expireAfterInMicroseconds < 0) { - hasSelectedCustom = true; - Navigator.of(context).pop(''); - final timeInMicrosecondsFromEpoch = - await _showDateTimePicker(); - if (timeInMicrosecondsFromEpoch != null) { - newValidTill = timeInMicrosecondsFromEpoch; - } - } else if (expireAfterInMicroseconds == 0) { - // no expiry - newValidTill = 0; - } else { - newValidTill = DateTime.now().microsecondsSinceEpoch + - expireAfterInMicroseconds; - } - if (!hasSelectedCustom) { - Navigator.of(context).pop(''); - } - if (newValidTill >= 0) { - debugPrint("Setting expirty $newValidTill"); - await updateTime(newValidTill); - } - }, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 2.0, - ), - child: Text( - 'Confirm', - style: Theme.of(context).textTheme.subtitle1, - ), - ) - ], - ), - ), - Container( - height: 220.0, - color: const Color(0xfff7f7f7), - child: CupertinoPicker( - backgroundColor: - Theme.of(context).backgroundColor.withOpacity(0.95), - onSelectedItemChanged: (value) { - final firstWhere = _expiryOptions - .firstWhere((element) => element.item1 == value); - setState(() { - _selectedExpiry = firstWhere; - }); - }, - magnification: 1.3, - useMagnifier: true, - itemExtent: 25, - diameterRatio: 1, - children: _expiryOptions - .map( - (e) => Text( - e.item2, - style: Theme.of(context).textTheme.subtitle1, - ), - ) - .toList(), - ), - ) - ], - ); - }, - ); - } - - Future updateTime(int newValidTill) async { - await _updateUrlSettings( - context, - {'validTill': newValidTill}, - ); - if (mounted) { - // reset to default value. THis is needed will we move to - // new selection menu as per figma/ - _selectedExpiry = _expiryOptions.first; - setState(() {}); - } - } - - // _showDateTimePicker return null if user doesn't select date-time - Future _showDateTimePicker() async { - final dateResult = await DatePicker.showDatePicker( - context, - minTime: DateTime.now(), - currentTime: DateTime.now(), - locale: LocaleType.en, - theme: Theme.of(context).colorScheme.dateTimePickertheme, - ); - if (dateResult == null) { - return null; - } - final dateWithTimeResult = await DatePicker.showTime12hPicker( - context, - showTitleActions: true, - currentTime: dateResult, - locale: LocaleType.en, - theme: Theme.of(context).colorScheme.dateTimePickertheme, - ); - if (dateWithTimeResult == null) { - return null; - } else { - return dateWithTimeResult.microsecondsSinceEpoch; - } - } - final TextEditingController _textFieldController = TextEditingController(); Future _displayLinkPasswordInput(BuildContext context) async { @@ -497,87 +342,4 @@ class _ManageSharedLinkWidgetState extends State { await showGenericErrorDialog(context: context); } } - - Future _showDeviceLimitPicker() async { - final List options = []; - for (int i = 50; i > 0; i--) { - options.add( - Text(i.toString(), style: Theme.of(context).textTheme.subtitle1), - ); - } - return showCupertinoModalPopup( - context: context, - builder: (context) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.cupertinoPickerTopColor, - border: const Border( - bottom: BorderSide( - color: Color(0xff999999), - width: 0.0, - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CupertinoButton( - onPressed: () { - Navigator.of(context).pop('cancel'); - }, - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 5.0, - ), - child: Text( - 'Cancel', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - CupertinoButton( - onPressed: () async { - await _updateUrlSettings(context, { - 'deviceLimit': int.tryParse( - options[_selectedDeviceLimitIndex].data!, - ), - }); - setState(() {}); - Navigator.of(context).pop(''); - }, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 2.0, - ), - child: Text( - 'Confirm', - style: Theme.of(context).textTheme.subtitle1, - ), - ) - ], - ), - ), - Container( - height: 220.0, - color: const Color(0xfff7f7f7), - child: CupertinoPicker( - backgroundColor: - Theme.of(context).backgroundColor.withOpacity(0.95), - onSelectedItemChanged: (value) { - _selectedDeviceLimitIndex = value; - }, - magnification: 1.3, - useMagnifier: true, - itemExtent: 25, - diameterRatio: 1, - children: options, - ), - ) - ], - ); - }, - ); - } } diff --git a/lib/ui/sharing/pickers/device_limit_picker_page.dart b/lib/ui/sharing/pickers/device_limit_picker_page.dart new file mode 100644 index 0000000000000000000000000000000000000000..8a943077305e46bd6ee29edc28c56fcbb81170b9 --- /dev/null +++ b/lib/ui/sharing/pickers/device_limit_picker_page.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:photos/core/constants.dart'; +import 'package:photos/models/collection.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/divider_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_section_description_widget.dart'; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import 'package:photos/utils/dialog_util.dart'; +import 'package:photos/utils/separators_util.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: [ + const TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: "Device Limit", + ), + ), + 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), + ), + const MenuSectionDescriptionWidget( + content: + "When set to the maximum (50), the device limit will be relaxed" + " to allow for temporary spikes of large number of viewers.", + ) + ], + ), + ); + }, + 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", + ), + 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 CollectionsService.instance.updateShareUrl(widget.collection, prop); + } catch (e) { + showGenericErrorDialog(context: context); + rethrow; + } + } +} diff --git a/lib/ui/sharing/pickers/link_expiry_picker_page.dart b/lib/ui/sharing/pickers/link_expiry_picker_page.dart new file mode 100644 index 0000000000000000000000000000000000000000..7c86e437abc8964b273a07b628bd6b7987e7a5ba --- /dev/null +++ b/lib/ui/sharing/pickers/link_expiry_picker_page.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_datetime_picker/flutter_datetime_picker.dart'; +import 'package:photos/ente_theme_data.dart'; +import 'package:photos/models/collection.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/divider_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import 'package:photos/utils/dialog_util.dart'; +import 'package:photos/utils/separators_util.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: [ + const TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: "Link expiry", + ), + ), + 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 StatelessWidget { + final Collection collection; + ItemsWidget(this.collection, {super.key}); + + // index, title, milliseconds in future post which link should expire (when >0) + final List> _expiryOptions = [ + const Tuple2("Never", 0), + Tuple2("After 1 hour", const Duration(hours: 1).inMicroseconds), + Tuple2("After 1 day", const Duration(days: 1).inMicroseconds), + Tuple2("After 1 week", const Duration(days: 7).inMicroseconds), + // todo: make this time calculation perfect + Tuple2("After 1 month", const Duration(days: 30).inMicroseconds), + Tuple2("After 1 year", const Duration(days: 365).inMicroseconds), + const Tuple2("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 timeInMicrosecondsFromEpoch = + await _showDateTimePicker(context); + if (timeInMicrosecondsFromEpoch != null) { + newValidTill = timeInMicrosecondsFromEpoch; + } + } else if (expireAfterInMicroseconds == 0) { + // no expiry + newValidTill = 0; + } else { + newValidTill = + DateTime.now().microsecondsSinceEpoch + expireAfterInMicroseconds; + } + if (newValidTill >= 0) { + debugPrint("Setting expirty $newValidTill"); + await updateTime(newValidTill, context); + } + }, + ); + } + + // _showDateTimePicker return null if user doesn't select date-time + Future _showDateTimePicker(BuildContext context) async { + final dateResult = await DatePicker.showDatePicker( + context, + minTime: DateTime.now(), + currentTime: DateTime.now(), + locale: LocaleType.en, + theme: Theme.of(context).colorScheme.dateTimePickertheme, + ); + if (dateResult == null) { + return null; + } + final dateWithTimeResult = await DatePicker.showTime12hPicker( + context, + showTitleActions: true, + currentTime: dateResult, + locale: LocaleType.en, + theme: Theme.of(context).colorScheme.dateTimePickertheme, + ); + if (dateWithTimeResult == null) { + return null; + } else { + return dateWithTimeResult.microsecondsSinceEpoch; + } + } + + Future updateTime(int newValidTill, BuildContext context) async { + await _updateUrlSettings( + context, + {'validTill': newValidTill}, + ); + } + + Future _updateUrlSettings( + BuildContext context, + Map prop, + ) async { + try { + await CollectionsService.instance.updateShareUrl(collection, prop); + } catch (e) { + showGenericErrorDialog(context: context); + rethrow; + } + } +} diff --git a/lib/ui/sharing/share_collection_page.dart b/lib/ui/sharing/share_collection_page.dart index 526fec43447e43dedb73c00d1d568d5e7232237e..473c0dd6ab69dd3f7b5eb45f70b57f3bb2b4d213 100644 --- a/lib/ui/sharing/share_collection_page.dart +++ b/lib/ui/sharing/share_collection_page.dart @@ -8,7 +8,7 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/divider_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_description_widget.dart'; import 'package:photos/ui/components/menu_section_title.dart'; import 'package:photos/ui/sharing/add_partipant_page.dart'; @@ -46,7 +46,7 @@ class _ShareCollectionPageState extends State { @override Widget build(BuildContext context) { _sharees = widget.collection.sharees ?? []; - final bool hasUrl = widget.collection.publicURLs?.isNotEmpty ?? false; + final bool hasUrl = widget.collection.hasLink; final children = []; children.add( MenuSectionTitle( @@ -66,17 +66,44 @@ class _ShareCollectionPageState extends State { children.add( MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: _sharees.isEmpty ? "Add email" : "Add more", + captionedTextWidget: const CaptionedTextWidget( + title: "Add viewer", makeTextBold: true, ), leadingIcon: Icons.add, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, - borderRadius: 4.0, isTopBorderRadiusRemoved: _sharees.isNotEmpty, + isBottomBorderRadiusRemoved: true, onTap: () async { - routeToPage(context, AddParticipantPage(widget.collection)).then( + routeToPage( + context, + AddParticipantPage(widget.collection, true), + ).then( + (value) => { + if (mounted) {setState(() => {})} + }, + ); + }, + ), + ); + children.add( + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ); + children.add( + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Add collaborator", + makeTextBold: true, + ), + leadingIcon: Icons.add, + menuItemColor: getEnteColorScheme(context).fillFaint, + isTopBorderRadiusRemoved: true, + onTap: () async { + routeToPage(context, AddParticipantPage(widget.collection, false)) + .then( (value) => { if (mounted) {setState(() => {})} }, @@ -101,9 +128,7 @@ class _ShareCollectionPageState extends State { height: 24, ), MenuSectionTitle( - title: hasUrl - ? "Public link enabled" - : (_sharees.isEmpty ? "Or share a link" : "Share a link"), + title: hasUrl ? "Public link enabled" : "Share a link", iconData: Icons.public, ), ]); @@ -118,7 +143,6 @@ class _ShareCollectionPageState extends State { leadingIcon: Icons.error_outline, leadingIconColor: getEnteColorScheme(context).warning500, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, onTap: () async {}, isBottomBorderRadiusRemoved: true, ), @@ -138,7 +162,7 @@ class _ShareCollectionPageState extends State { ), leadingIcon: Icons.copy, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, + showOnlyLoadingState: true, onTap: () async { await Clipboard.setData(ClipboardData(text: url)); showShortToast(context, "Link copied to clipboard"); @@ -156,7 +180,6 @@ class _ShareCollectionPageState extends State { ), leadingIcon: Icons.adaptive.share, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, onTap: () async { shareText(url); }, @@ -181,7 +204,6 @@ class _ShareCollectionPageState extends State { leadingIcon: Icons.link, trailingIcon: Icons.navigate_next, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingIconIsMuted: true, onTap: () async { routeToPage( @@ -198,34 +220,63 @@ class _ShareCollectionPageState extends State { ], ); } else { - children.add( + children.addAll([ MenuItemWidget( captionedTextWidget: const CaptionedTextWidget( title: "Create public link", + makeTextBold: true, ), leadingIcon: Icons.link, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, + isBottomBorderRadiusRemoved: true, + showOnlyLoadingState: true, onTap: () async { - final bool result = await collectionActions.publicLinkToggle( + final bool result = + await collectionActions.enableUrl(context, widget.collection); + if (result && mounted) { + setState(() => {}); + } + }, + ), + _sharees.isEmpty + ? const MenuSectionDescriptionWidget( + content: "Share with non-ente users", + ) + : const SizedBox.shrink(), + const SizedBox( + height: 24, + ), + const MenuSectionTitle( + title: "Collaborative link", + iconData: Icons.public, + ), + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Collect photos", + makeTextBold: true, + ), + leadingIcon: Icons.link, + menuItemColor: getEnteColorScheme(context).fillFaint, + showOnlyLoadingState: true, + onTap: () async { + final bool result = await collectionActions.enableUrl( context, widget.collection, - true, + enableCollect: true, ); if (result && mounted) { setState(() => {}); } }, ), - ); - if (_sharees.isEmpty && !hasUrl) { - children.add( - const MenuSectionDescriptionWidget( - content: - "Links allow people without an ente account to view and add photos to your shared albums.", - ), - ); - } + _sharees.isEmpty + ? const MenuSectionDescriptionWidget( + content: + "Create a link to allow people to add and view photos in " + "your shared album without needing an ente app or account. Great for collecting event photos.", + ) + : const SizedBox.shrink(), + ]); } return Scaffold( @@ -244,6 +295,7 @@ class _ShareCollectionPageState extends State { padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: children, ), ), @@ -282,7 +334,6 @@ class EmailItemWidget extends StatelessWidget { ), leadingIconSize: 24, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingIconIsMuted: true, trailingIcon: Icons.chevron_right, onTap: () async { @@ -308,7 +359,6 @@ class EmailItemWidget extends StatelessWidget { ), leadingIcon: Icons.people_outline, menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: getEnteColorScheme(context).fillFaint, trailingIconIsMuted: true, trailingIcon: Icons.chevron_right, onTap: () async { diff --git a/lib/ui/sharing/user_avator_widget.dart b/lib/ui/sharing/user_avator_widget.dart index 18223183d8c01f591a4001c728c09aff43b390fb..873cba05ab26c25adce638113e866a6de6f3c1ae 100644 --- a/lib/ui/sharing/user_avator_widget.dart +++ b/lib/ui/sharing/user_avator_widget.dart @@ -26,10 +26,13 @@ class UserAvatarWidget extends StatelessWidget { final displayChar = (user.name == null || user.name!.isEmpty) ? ((user.email.isEmpty) ? " " : user.email.substring(0, 1)) : user.name!.substring(0, 1); - final randomColor = colorScheme.avatarColors[ - (user.id ?? 0).remainder(colorScheme.avatarColors.length)]; - final Color decorationColor = - ((user.id ?? -1) == currentUserID) ? Colors.black : randomColor; + Color decorationColor; + if (user.id == null || user.id! <= 0 || user.id == currentUserID) { + decorationColor = Colors.black; + } else { + decorationColor = colorScheme + .avatarColors[(user.id!).remainder(colorScheme.avatarColors.length)]; + } final avatarStyle = getAvatarStyle(context, type); final double size = avatarStyle.item1; diff --git a/lib/ui/tools/debug/app_storage_viewer.dart b/lib/ui/tools/debug/app_storage_viewer.dart index b37b2c84ce11d55e3703a86e0c3401a714dea379..7022ff3880ee2d5e874e5707a7da9d76e0ac3e58 100644 --- a/lib/ui/tools/debug/app_storage_viewer.dart +++ b/lib/ui/tools/debug/app_storage_viewer.dart @@ -9,7 +9,7 @@ import 'package:photos/services/feature_flag_service.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/icon_button_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_title.dart'; import 'package:photos/ui/components/title_bar_title_widget.dart'; import 'package:photos/ui/components/title_bar_widget.dart'; @@ -161,9 +161,8 @@ class _AppStorageViewerState extends State { ), menuItemColor: getEnteColorScheme(context).fillFaint, - pressedColor: - getEnteColorScheme(context).fillFaintPressed, - borderRadius: 8, + singleBorderRadius: 8, + alwaysShowSuccessState: true, onTap: () async { for (var pathItem in paths) { if (pathItem.allowCacheClear) { diff --git a/lib/ui/tools/debug/path_storage_viewer.dart b/lib/ui/tools/debug/path_storage_viewer.dart index 37a31bbcbf081d7d78cac52b929a22c02f5cd972..28de6ad1de63c94daadbd09b5f32b790bf3ff76a 100644 --- a/lib/ui/tools/debug/path_storage_viewer.dart +++ b/lib/ui/tools/debug/path_storage_viewer.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/utils/data_util.dart'; import 'package:photos/utils/directory_content.dart'; @@ -76,6 +76,7 @@ class _PathStorageViewerState extends State { Widget _buildMenuItemWidget(DirectoryStat? stat, Object? err) { return MenuItemWidget( + key: UniqueKey(), alignCaptionedTextToLeft: true, captionedTextWidget: CaptionedTextWidget( title: widget.item.title, @@ -98,10 +99,11 @@ class _PathStorageViewerState extends State { ), trailingIcon: err != null ? Icons.error_outline_outlined : null, trailingIconIsMuted: err != null, - borderRadius: 8, + singleBorderRadius: 8, menuItemColor: getEnteColorScheme(context).fillFaint, isBottomBorderRadiusRemoved: widget.removeBottomRadius, isTopBorderRadiusRemoved: widget.removeTopRadius, + showOnlyLoadingState: true, onTap: () async { if (kDebugMode) { await Clipboard.setData(ClipboardData(text: widget.item.path)); diff --git a/lib/ui/viewer/actions/file_selection_actions_widget.dart b/lib/ui/viewer/actions/file_selection_actions_widget.dart index 65363a2c9735f5f88f967e56a832d9a19c67249b..2f2c65a48325b2c37a68f39882ac7a1ca09f0384 100644 --- a/lib/ui/viewer/actions/file_selection_actions_widget.dart +++ b/lib/ui/viewer/actions/file_selection_actions_widget.dart @@ -49,6 +49,7 @@ class _FileSelectionActionWidgetState extends State { late int currentUserID; late FilesSplit split; late CollectionActions collectionActions; + late bool isCollectionOwner; // _cachedCollectionForSharedLink is primarly used to avoid creating duplicate // links if user keeps on creating Create link button after selecting @@ -61,6 +62,8 @@ class _FileSelectionActionWidgetState extends State { split = FilesSplit.split([], currentUserID); widget.selectedFiles.addListener(_selectFileChangeListener); collectionActions = CollectionActions(CollectionsService.instance); + isCollectionOwner = + widget.collection != null && widget.collection!.isOwner(currentUserID); super.initState(); } @@ -88,17 +91,21 @@ class _FileSelectionActionWidgetState extends State { ? " (${split.ownedByCurrentUser.length})" "" : ""; + final int removeCount = split.ownedByCurrentUser.length + + (isCollectionOwner ? split.ownedByOtherUsers.length : 0); + final String removeSuffix = showPrefix + ? " ($removeCount)" + "" + : ""; final String suffixInPending = split.ownedByOtherUsers.isNotEmpty ? " (${split.ownedByCurrentUser.length + split.pendingUploads.length})" "" : ""; + final bool anyOwnedFiles = split.pendingUploads.isNotEmpty || split.ownedByCurrentUser.isNotEmpty; final bool anyUploadedFiles = split.ownedByCurrentUser.isNotEmpty; - bool showRemoveOption = widget.type.showRemoveFromAlbum(); - if (showRemoveOption && widget.type == GalleryType.sharedCollection) { - showRemoveOption = split.ownedByCurrentUser.isNotEmpty; - } + final bool showRemoveOption = widget.type.showRemoveFromAlbum(); debugPrint('$runtimeType building $mounted'); final colorScheme = getEnteColorScheme(context); final List> items = []; @@ -156,9 +163,9 @@ class _FileSelectionActionWidgetState extends State { secondList.add( BlurMenuItemWidget( leadingIcon: Icons.remove_outlined, - labelText: "Remove from album$suffix", + labelText: "Remove from album$removeSuffix", menuItemColor: colorScheme.fillFaint, - onTap: anyUploadedFiles ? _removeFilesFromAlbum : null, + onTap: removeCount > 0 ? _removeFilesFromAlbum : null, ), ); } @@ -233,6 +240,28 @@ class _FileSelectionActionWidgetState extends State { ); } + if (widget.type.showRestoreOption()) { + secondList.add( + BlurMenuItemWidget( + leadingIcon: Icons.visibility, + labelText: "Restore", + menuItemColor: colorScheme.fillFaint, + onTap: _restore, + ), + ); + } + + if (widget.type.showPermanentlyDeleteOption()) { + secondList.add( + BlurMenuItemWidget( + leadingIcon: Icons.delete_forever_outlined, + labelText: "Permanently delete", + menuItemColor: colorScheme.fillFaint, + onTap: _permanentlyDelete, + ), + ); + } + if (firstList.isNotEmpty || secondList.isNotEmpty) { if (firstList.isNotEmpty) { items.add(firstList); @@ -279,16 +308,21 @@ class _FileSelectionActionWidgetState extends State { } Future _removeFilesFromAlbum() async { - if (split.pendingUploads.isNotEmpty || split.ownedByOtherUsers.isNotEmpty) { + if (split.pendingUploads.isNotEmpty) { widget.selectedFiles .unSelectAll(split.pendingUploads.toSet(), skipNotify: true); + } + if (!isCollectionOwner && split.ownedByOtherUsers.isNotEmpty) { widget.selectedFiles .unSelectAll(split.ownedByOtherUsers.toSet(), skipNotify: true); } + final bool removingOthersFile = + isCollectionOwner && split.ownedByOtherUsers.isNotEmpty; await collectionActions.showRemoveFromCollectionSheetV2( context, widget.collection!, widget.selectedFiles, + removingOthersFile, ); } @@ -420,4 +454,22 @@ class _FileSelectionActionWidgetState extends State { showShortToast(context, "Link copied to clipboard"); } } + + void _restore() { + createCollectionSheet( + widget.selectedFiles, + null, + context, + actionType: CollectionActionType.restoreFiles, + ); + } + + Future _permanentlyDelete() async { + if (await deleteFromTrash( + context, + widget.selectedFiles.files.toList(), + )) { + widget.selectedFiles.clearAll(); + } + } } diff --git a/lib/ui/viewer/actions/file_selection_overlay_bar.dart b/lib/ui/viewer/actions/file_selection_overlay_bar.dart index 6d20241bba46a8e178a2c5b41410fffec4e471e3..3b36dad986df4bf004129a37576e7329777dc61a 100644 --- a/lib/ui/viewer/actions/file_selection_overlay_bar.dart +++ b/lib/ui/viewer/actions/file_selection_overlay_bar.dart @@ -95,11 +95,28 @@ class _FileSelectionOverlayBarState extends State { ), ); } + if (widget.galleryType == GalleryType.trash) { + iconsButton.add( + IconButtonWidget( + icon: Icons.delete_forever_outlined, + iconButtonType: IconButtonType.primary, + iconColor: iconColor, + onTap: () async { + if (await deleteFromTrash( + context, + widget.selectedFiles.files.toList(), + )) { + widget.selectedFiles.clearAll(); + } + }, + ), + ); + } iconsButton.add( IconButtonWidget( icon: Icons.adaptive.share_outlined, iconButtonType: IconButtonType.primary, - iconColor: getEnteColorScheme(context).blurStrokeBase, + iconColor: iconColor, onTap: () => shareSelected( context, shareButtonKey, diff --git a/lib/ui/viewer/file/thumbnail_widget.dart b/lib/ui/viewer/file/thumbnail_widget.dart index 14d24bfc24e6464d48f4c9828382f60de7ccfac2..e5b787465f74ebc44bcbcc3736deb0bf44668e37 100644 --- a/lib/ui/viewer/file/thumbnail_widget.dart +++ b/lib/ui/viewer/file/thumbnail_widget.dart @@ -10,7 +10,6 @@ import 'package:photos/db/files_db.dart'; import 'package:photos/db/trash_db.dart'; import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; -import 'package:photos/extensions/string_ext.dart'; import 'package:photos/models/collection.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/file_type.dart'; @@ -128,11 +127,10 @@ class _ThumbnailWidgetState extends State { ); } else if (widget.file!.pubMagicMetadata!.uploaderName != null) { contentChildren.add( - // Use uploadName hashCode as userID so that different uploader - // get avatar color + // Use -1 as userID for enforcing black avatar color OwnerAvatarOverlayIcon( User( - id: widget.file!.pubMagicMetadata!.uploaderName.sumAsciiValues, + id: -1, email: '', name: widget.file!.pubMagicMetadata!.uploaderName, ), diff --git a/lib/ui/viewer/gallery/collection_page.dart b/lib/ui/viewer/gallery/collection_page.dart index 62623cebe6dfeccf44f1570d45dcfe309e7d3041..cee67ebad7f9799a03cec21d23de11d06e92c078 100644 --- a/lib/ui/viewer/gallery/collection_page.dart +++ b/lib/ui/viewer/gallery/collection_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:photos/core/configuration.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/events/collection_updated_event.dart'; @@ -56,8 +57,8 @@ class _CollectionPageState extends State { if (widget.hasVerifiedLock == false && widget.c.collection.isHidden()) { return const EmptyState(); } - final appBarTypeValue = widget.c.collection.type == CollectionType - .uncategorized ? GalleryType.uncategorized : widget.appBarType; + + final appBarTypeValue = _getGalleryType(widget.c.collection); final List? initialFiles = widget.c.thumbnail != null ? [widget.c.thumbnail!] : null; final gallery = Gallery( @@ -116,6 +117,21 @@ class _CollectionPageState extends State { ); } + GalleryType _getGalleryType(Collection c) { + final currentUserID = Configuration.instance.getUserID()!; + if (!c.isOwner(currentUserID)) { + return GalleryType.sharedCollection; + } + if (c.isDefaultHidden()) { + return GalleryType.hidden; + } else if (c.type == CollectionType.uncategorized) { + return GalleryType.uncategorized; + } else if (c.type == CollectionType.favorites) { + return GalleryType.favorite; + } + return widget.appBarType; + } + _selectedFilesListener() { _selectedFiles.files.isNotEmpty ? _bottomPosition.value = 0.0 diff --git a/lib/ui/viewer/gallery/device_folder_page.dart b/lib/ui/viewer/gallery/device_folder_page.dart index 45c289dd291f96d5ebd62789c8dd188ae44161e3..2c755a78186519b00f381685d21e17ac0b7a69c9 100644 --- a/lib/ui/viewer/gallery/device_folder_page.dart +++ b/lib/ui/viewer/gallery/device_folder_page.dart @@ -15,7 +15,7 @@ import 'package:photos/services/ignored_files_service.dart'; import 'package:photos/services/remote_sync_service.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; -import 'package:photos/ui/components/menu_item_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/components/menu_section_description_widget.dart'; import 'package:photos/ui/components/toggle_switch_widget.dart'; import 'package:photos/ui/viewer/actions/file_selection_overlay_bar.dart'; @@ -30,10 +30,12 @@ class DeviceFolderPage extends StatelessWidget { @override Widget build(Object context) { + final int? userID = Configuration.instance.getUserID(); final gallery = Gallery( asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) { return FilesDB.instance.getFilesInDeviceCollection( deviceCollection, + userID, creationStartTime, creationEndTime, limit: limit, @@ -111,7 +113,7 @@ class _BackupHeaderWidgetState extends State { children: [ MenuItemWidget( captionedTextWidget: const CaptionedTextWidget(title: "Backup"), - borderRadius: 8.0, + singleBorderRadius: 8.0, menuItemColor: colorScheme.fillFaint, alignCaptionedTextToLeft: true, trailingWidget: ToggleSwitchWidget( @@ -184,6 +186,7 @@ class _BackupHeaderWidgetState extends State { Future> _filesInDeviceCollection() async { return (await FilesDB.instance.getFilesInDeviceCollection( widget.deviceCollection, + Configuration.instance.getUserID(), galleryLoadStartTime, galleryLoadEndTime, )) @@ -232,9 +235,10 @@ class _ResetIgnoredFilesWidgetState extends State { captionedTextWidget: const CaptionedTextWidget( title: "Reset ignored files", ), - borderRadius: 8.0, + singleBorderRadius: 8.0, menuItemColor: getEnteColorScheme(context).fillFaint, leadingIcon: Icons.cloud_off_outlined, + alwaysShowSuccessState: true, onTap: () async { await _removeFilesFromIgnoredFiles( widget.filesInDeviceCollection, diff --git a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index 06f947f547c728286e236517d6cc7769315e4a50..8d723222f8fc6345d81c33d82398a001943b617f 100644 --- a/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -18,6 +18,7 @@ import 'package:photos/services/sync_service.dart'; import 'package:photos/services/update_service.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; import 'package:photos/ui/common/rename_dialog.dart'; +import 'package:photos/ui/components/action_sheet_widget.dart'; import 'package:photos/ui/components/button_widget.dart'; import 'package:photos/ui/components/dialog_widget.dart'; import 'package:photos/ui/components/models/button_type.dart'; @@ -138,21 +139,37 @@ class _GalleryAppBarWidgetState extends State { } Future _leaveAlbum(BuildContext context) async { - final result = await showNewChoiceDialog( - context, - title: "Leave shared album", - body: "You will leave the album, and it will stop being visible to you", - firstButtonLabel: "Yes, leave", - isCritical: true, - firstButtonOnTap: () async { - await CollectionsService.instance.leaveAlbum(widget.collection!); - if (mounted) { - Navigator.of(context).pop(); - } - }, + final ButtonAction? result = await showActionSheet( + context: context, + buttons: [ + ButtonWidget( + buttonType: ButtonType.critical, + isInAlert: true, + shouldStickToDarkTheme: true, + buttonAction: ButtonAction.first, + shouldSurfaceExecutionStates: true, + labelText: "Leave album", + onTap: () async { + await CollectionsService.instance.leaveAlbum(widget.collection!); + }, + ), + const ButtonWidget( + buttonType: ButtonType.secondary, + buttonAction: ButtonAction.cancel, + isInAlert: true, + shouldStickToDarkTheme: true, + labelText: "Cancel", + ) + ], + title: "Leave shared album?", + body: "Photos added by you will be removed from the album", ); - if (result == ButtonAction.error) { - showGenericErrorDialog(context: context); + if (result != null && mounted) { + if (result == ButtonAction.error) { + showGenericErrorDialog(context: context); + } else if (result == ButtonAction.first) { + Navigator.of(context).pop(); + } } } diff --git a/lib/ui/viewer/gallery/gallery_overlay_widget.dart b/lib/ui/viewer/gallery/gallery_overlay_widget.dart deleted file mode 100644 index a98ea0d0b2b96e48f10ec9d240adeaefe42e30e4..0000000000000000000000000000000000000000 --- a/lib/ui/viewer/gallery/gallery_overlay_widget.dart +++ /dev/null @@ -1,627 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:ui'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:photos/core/configuration.dart'; -import 'package:photos/core/event_bus.dart'; -import 'package:photos/ente_theme_data.dart'; -import 'package:photos/events/subscription_purchased_event.dart'; -import 'package:photos/models/collection.dart'; -import 'package:photos/models/gallery_type.dart'; -import 'package:photos/models/magic_metadata.dart'; -import 'package:photos/models/selected_files.dart'; -import 'package:photos/services/collections_service.dart'; -import 'package:photos/services/hidden_service.dart'; -import 'package:photos/ui/create_collection_sheet.dart'; -import 'package:photos/utils/delete_file_util.dart'; -import 'package:photos/utils/dialog_util.dart'; -import 'package:photos/utils/magic_util.dart'; -import 'package:photos/utils/share_util.dart'; -import 'package:photos/utils/toast_util.dart'; - -class GalleryOverlayWidget extends StatefulWidget { - final GalleryType type; - final SelectedFiles selectedFiles; - final String? path; - final Collection? collection; - - const GalleryOverlayWidget( - this.type, - this.selectedFiles, { - this.path, - this.collection, - Key? key, - }) : super(key: key); - - @override - State createState() => _GalleryOverlayWidgetState(); -} - -class _GalleryOverlayWidgetState extends State { - late StreamSubscription _userAuthEventSubscription; - late Function() _selectedFilesListener; - final GlobalKey shareButtonKey = GlobalKey(); - - @override - void initState() { - _selectedFilesListener = () { - setState(() {}); - }; - widget.selectedFiles.addListener(_selectedFilesListener); - _userAuthEventSubscription = - Bus.instance.on().listen((event) { - setState(() {}); - }); - super.initState(); - } - - @override - void dispose() { - _userAuthEventSubscription.cancel(); - widget.selectedFiles.removeListener(_selectedFilesListener); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final bool filesAreSelected = widget.selectedFiles.files.isNotEmpty; - final bottomPadding = Platform.isAndroid ? 0.0 : 12.0; - return Padding( - padding: EdgeInsets.only(bottom: bottomPadding), - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - height: filesAreSelected ? 108 : 0, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: filesAreSelected ? 1.0 : 0.0, - curve: Curves.easeIn, - child: IgnorePointer( - ignoring: !filesAreSelected, - child: OverlayWidget( - widget.type, - widget.selectedFiles, - path: widget.path, - collection: widget.collection, - ), - ), - ), - ), - ); - } -} - -class OverlayWidget extends StatefulWidget { - final GalleryType type; - final SelectedFiles selectedFiles; - final String? path; - final Collection? collection; - - const OverlayWidget( - this.type, - this.selectedFiles, { - this.path, - this.collection, - Key? key, - }) : super(key: key); - - @override - State createState() => _OverlayWidgetState(); -} - -class _OverlayWidgetState extends State { - final _logger = Logger("GalleryOverlay"); - late StreamSubscription _userAuthEventSubscription; - late Function() _selectedFilesListener; - final GlobalKey shareButtonKey = GlobalKey(); - - @override - void initState() { - _selectedFilesListener = () { - setState(() {}); - }; - widget.selectedFiles.addListener(_selectedFilesListener); - _userAuthEventSubscription = - Bus.instance.on().listen((event) { - setState(() {}); - }); - super.initState(); - } - - @override - void dispose() { - _userAuthEventSubscription.cancel(); - widget.selectedFiles.removeListener(_selectedFilesListener); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - color: Colors.transparent, - child: ListView( - //ListView is for animation to work without render overflow - physics: const NeverScrollableScrollPhysics(), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Container( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), - child: Container( - color: Theme.of(context) - .colorScheme - .frostyBlurBackdropFilterColor, - width: double.infinity, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(13, 13, 0, 13), - child: Text( - widget.selectedFiles.files.length.toString() + - ' selected', - style: Theme.of(context) - .textTheme - .subtitle2! - .copyWith( - fontWeight: FontWeight.w600, - color: - Theme.of(context).colorScheme.iconColor, - ), - ), - ), - Row( - children: _getActions(context), - ) - ], - ), - ), - ), - ), - ), - ), - const Padding(padding: EdgeInsets.symmetric(vertical: 8)), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(24), - child: GestureDetector( - onTap: _clearSelectedFiles, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - //height: 32, - width: 86, - color: Theme.of(context) - .colorScheme - .frostyBlurBackdropFilterColor, - child: Center( - child: Text( - 'Cancel', - style: Theme.of(context) - .textTheme - .subtitle2! - .copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.iconColor, - ), - ), - ), - ), - ), - ), - ), - ], - ), - ], - ), - ); - } - - void _clearSelectedFiles() { - widget.selectedFiles.clearAll(); - } - - List _getActions(BuildContext context) { - final List actions = []; - if (widget.type == GalleryType.trash) { - _addTrashAction(actions); - return actions; - } - // skip add button for incoming collection till this feature is implemented - if (Configuration.instance.hasConfiguredAccount() && - widget.type != GalleryType.sharedCollection && - widget.type != GalleryType.hidden) { - IconData iconData = Platform.isAndroid ? Icons.add : CupertinoIcons.add; - // show upload icon instead of add for files selected in local gallery - if (widget.type == GalleryType.localFolder) { - iconData = Icons.cloud_upload_outlined; - } - actions.add( - Tooltip( - message: "add", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: Icon(iconData), - onPressed: () async { - await onActionSelected("add"); - }, - ), - ), - ); - } - - if (Configuration.instance.hasConfiguredAccount() && - widget.type == GalleryType.ownedCollection && - widget.collection!.type != CollectionType.favorites) { - actions.add( - Tooltip( - message: "Move", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: Icon( - Platform.isAndroid - ? Icons.arrow_forward - : CupertinoIcons.arrow_right, - ), - onPressed: () { - onActionSelected('move'); - }, - ), - ), - ); - } - actions.add( - Tooltip( - message: "Share", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - key: shareButtonKey, - icon: Icon(Platform.isAndroid ? Icons.share : CupertinoIcons.share), - onPressed: () { - _shareSelected(context); - }, - ), - ), - ); - if (widget.type == GalleryType.homepage || - widget.type == GalleryType.archive || - widget.type == GalleryType.hidden || - widget.type == GalleryType.localFolder || - widget.type == GalleryType.searchResults) { - actions.add( - Tooltip( - message: "Delete", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: - Icon(Platform.isAndroid ? Icons.delete : CupertinoIcons.delete), - onPressed: () { - _showDeleteSheet(context); - }, - ), - ), - ); - } else if (widget.type == GalleryType.ownedCollection) { - if (widget.collection!.type == CollectionType.folder) { - actions.add( - Tooltip( - message: "Delete", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: Icon( - Platform.isAndroid ? Icons.delete : CupertinoIcons.delete, - ), - onPressed: () { - _showDeleteSheet(context); - }, - ), - ), - ); - } else { - actions.add( - Tooltip( - message: "Remove", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: const Icon( - Icons.remove_circle_rounded, - ), - onPressed: () { - _showRemoveFromCollectionSheet(context); - }, - ), - ), - ); - } - } - - if (widget.type == GalleryType.homepage || - widget.type == GalleryType.archive) { - final bool showArchive = widget.type == GalleryType.homepage; - if (showArchive) { - actions.add( - Tooltip( - message: 'Archive', - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: const Icon( - Icons.archive_outlined, - ), - onPressed: () { - onActionSelected('archive'); - }, - ), - ), - ); - } else { - actions.insert( - 0, - Tooltip( - message: 'Unarchive', - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: const Icon( - Icons.unarchive, - ), - onPressed: () { - onActionSelected('unarchive'); - }, - ), - ), - ); - } - } - - return actions; - } - - Future onActionSelected(String value) async { - debugPrint("Action Selected $value"); - switch (value.toLowerCase()) { - case 'hide': - await _handleHideRequest(context); - break; - case 'archive': - await _handleVisibilityChangeRequest(context, visibilityArchive); - break; - case 'unarchive': - await _handleVisibilityChangeRequest(context, visibilityVisible); - break; - default: - break; - } - } - - void _addTrashAction(List actions) { - actions.add( - Tooltip( - message: "Restore", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: const Icon( - Icons.restore, - ), - onPressed: () { - createCollectionSheet( - widget.selectedFiles, - null, - context, - actionType: CollectionActionType.restoreFiles, - ); - }, - ), - ), - ); - actions.add( - Tooltip( - message: "Delete permanently", - child: IconButton( - color: Theme.of(context).colorScheme.iconColor, - icon: const Icon( - Icons.delete_forever, - ), - onPressed: () async { - if (await deleteFromTrash( - context, - widget.selectedFiles.files.toList(), - )) { - _clearSelectedFiles(); - } - }, - ), - ), - ); - } - - Future _handleVisibilityChangeRequest( - BuildContext context, - int newVisibility, - ) async { - try { - await changeVisibility( - context, - widget.selectedFiles.files.toList(), - newVisibility, - ); - } catch (e, s) { - _logger.severe("failed to update file visibility", e, s); - await showGenericErrorDialog(context: context); - } finally { - _clearSelectedFiles(); - } - } - - // note: Keeping this method here so that it can be used whenever we move to - // to bottom UI - Future _handleHideRequest(BuildContext context) async { - try { - final hideResult = await CollectionsService.instance - .hideFiles(context, widget.selectedFiles.files.toList()); - if (hideResult) { - _clearSelectedFiles(); - } - } catch (e, s) { - _logger.severe("failed to update file visibility", e, s); - await showGenericErrorDialog(context: context); - } - } - - void _shareSelected(BuildContext context) { - share( - context, - widget.selectedFiles.files.toList(), - shareButtonKey: shareButtonKey, - ); - } - - void _showDeleteSheet(BuildContext context) { - final count = widget.selectedFiles.files.length; - bool containsUploadedFile = false, containsLocalFile = false; - for (final file in widget.selectedFiles.files) { - if (file.uploadedFileID != null) { - containsUploadedFile = true; - } - if (file.localID != null) { - containsLocalFile = true; - } - } - final actions = []; - if (containsUploadedFile && containsLocalFile) { - actions.add( - CupertinoActionSheetAction( - isDestructiveAction: true, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - await deleteFilesOnDeviceOnly( - context, - widget.selectedFiles.files.toList(), - ); - _clearSelectedFiles(); - showToast(context, "Files deleted from device"); - }, - child: const Text("Device"), - ), - ); - actions.add( - CupertinoActionSheetAction( - isDestructiveAction: true, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - await deleteFilesFromRemoteOnly( - context, - widget.selectedFiles.files.toList(), - ); - _clearSelectedFiles(); - showShortToast(context, "Moved to trash"); - }, - child: const Text("ente"), - ), - ); - actions.add( - CupertinoActionSheetAction( - isDestructiveAction: true, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - await deleteFilesFromEverywhere( - context, - widget.selectedFiles.files.toList(), - ); - _clearSelectedFiles(); - }, - child: const Text("Everywhere"), - ), - ); - } else { - actions.add( - CupertinoActionSheetAction( - isDestructiveAction: true, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - await deleteFilesFromEverywhere( - context, - widget.selectedFiles.files.toList(), - ); - _clearSelectedFiles(); - }, - child: const Text("Delete"), - ), - ); - } - final action = CupertinoActionSheet( - title: Text( - "Delete " + - count.toString() + - " file" + - (count == 1 ? "" : "s") + - (containsUploadedFile && containsLocalFile ? " from" : "?"), - ), - actions: actions, - cancelButton: CupertinoActionSheetAction( - child: const Text("Cancel"), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop(); - }, - ), - ); - showCupertinoModalPopup( - context: context, - builder: (_) => action, - barrierColor: Colors.black.withOpacity(0.75), - ); - } - - void _showRemoveFromCollectionSheet(BuildContext context) { - final count = widget.selectedFiles.files.length; - final action = CupertinoActionSheet( - title: Text( - "Remove " + - count.toString() + - " file" + - (count == 1 ? "" : "s") + - " from " + - widget.collection!.name! + - "?", - ), - actions: [ - CupertinoActionSheetAction( - isDestructiveAction: true, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - final dialog = createProgressDialog(context, "Removing files..."); - await dialog.show(); - try { - await CollectionsService.instance.removeFromCollection( - widget.collection!.id, - widget.selectedFiles.files.toList(), - ); - await dialog.hide(); - widget.selectedFiles.clearAll(); - } catch (e, s) { - _logger.severe(e, s); - await dialog.hide(); - showGenericErrorDialog(context: context); - } - }, - child: const Text("Remove"), - ), - ], - cancelButton: CupertinoActionSheetAction( - child: const Text("Cancel"), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop(); - }, - ), - ); - showCupertinoModalPopup(context: context, builder: (_) => action); - } -} diff --git a/lib/ui/viewer/gallery/photo_grid_size_picker_page.dart b/lib/ui/viewer/gallery/photo_grid_size_picker_page.dart new file mode 100644 index 0000000000000000000000000000000000000000..a14787d5e7dd97513bd4572c42f0685c5370513d --- /dev/null +++ b/lib/ui/viewer/gallery/photo_grid_size_picker_page.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:photos/core/constants.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/events/force_reload_home_gallery_event.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/captioned_text_widget.dart'; +import 'package:photos/ui/components/divider_widget.dart'; +import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import 'package:photos/utils/local_settings.dart'; +import 'package:photos/utils/separators_util.dart'; + +class PhotoGridSizePickerPage extends StatelessWidget { + const PhotoGridSizePickerPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + const TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: "Photo grid size", + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: ItemsWidget(), + ), + ], + ), + ); + }, + childCount: 1, + ), + ), + const SliverPadding(padding: EdgeInsets.symmetric(vertical: 12)), + ], + ), + ); + } +} + +class ItemsWidget extends StatefulWidget { + const ItemsWidget({super.key}); + + @override + State createState() => _ItemsWidgetState(); +} + +class _ItemsWidgetState extends State { + late int currentGridSize; + List items = []; + final List gridSizes = []; + @override + void initState() { + currentGridSize = LocalSettings.instance.getPhotoGridSize(); + for (int gridSize = photoGridSizeMin; + gridSize <= photoGridSizeMax; + gridSize++) { + gridSizes.add(gridSize); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + items.clear(); + for (int girdSize in gridSizes) { + items.add( + _menuItemForPicker(girdSize), + ); + } + items = addSeparators( + items, + DividerWidget( + dividerType: DividerType.menuNoIcon, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: items, + ); + } + + Widget _menuItemForPicker(int gridSize) { + return MenuItemWidget( + key: ValueKey(gridSize), + menuItemColor: getEnteColorScheme(context).fillFaint, + captionedTextWidget: CaptionedTextWidget( + title: "$gridSize", + ), + trailingIcon: currentGridSize == gridSize ? Icons.check : null, + alignCaptionedTextToLeft: true, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + showOnlyLoadingState: true, + onTap: () async { + await LocalSettings.instance.setPhotoGridSize(gridSize).then( + (value) => setState(() { + currentGridSize = gridSize; + }), + ); + Bus.instance.fire( + ForceReloadHomeGalleryEvent("grid size changed"), + ); + }, + ); + } +} diff --git a/lib/ui/viewer/gallery/trash_page.dart b/lib/ui/viewer/gallery/trash_page.dart index c56431d0628ebf4f3e027c445e0c5125d7e45c67..6a60e365f17558f7d19fc58667de2a1cff0fe92d 100644 --- a/lib/ui/viewer/gallery/trash_page.dart +++ b/lib/ui/viewer/gallery/trash_page.dart @@ -9,9 +9,9 @@ import 'package:photos/events/force_reload_trash_page_event.dart'; import 'package:photos/models/gallery_type.dart'; import 'package:photos/models/selected_files.dart'; import 'package:photos/ui/common/bottom_shadow.dart'; +import 'package:photos/ui/viewer/actions/file_selection_overlay_bar.dart'; import 'package:photos/ui/viewer/gallery/gallery.dart'; import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart'; -import 'package:photos/ui/viewer/gallery/gallery_overlay_widget.dart'; import 'package:photos/utils/delete_file_util.dart'; class TrashPage extends StatefulWidget { @@ -109,10 +109,7 @@ class _TrashPageState extends State { ), ), ), - GalleryOverlayWidget( - widget.overlayType, - widget._selectedFiles, - ) + FileSelectionOverlayBar(GalleryType.trash, widget._selectedFiles) ], ), ); @@ -126,7 +123,7 @@ class _TrashPageState extends State { return Padding( padding: const EdgeInsets.all(16), child: Text( - 'Items show the number the days remaining before permanent deletion', + 'Items show the number of days remaining before permanent deletion', style: Theme.of(context).textTheme.caption!.copyWith(fontSize: 16), ), diff --git a/lib/utils/crypto_util.dart b/lib/utils/crypto_util.dart index 36a5d71611c15331a035f5259448b213997d7a4f..192b4f6e1bcac8ae5bb33c595303ab95934b3bb4 100644 --- a/lib/utils/crypto_util.dart +++ b/lib/utils/crypto_util.dart @@ -290,13 +290,17 @@ class CryptoUtil { int memLimit = Sodium.cryptoPwhashMemlimitSensitive; int opsLimit = Sodium.cryptoPwhashOpslimitSensitive; Uint8List key; - while (memLimit > Sodium.cryptoPwhashMemlimitMin && - opsLimit < Sodium.cryptoPwhashOpslimitMax) { + while (memLimit >= Sodium.cryptoPwhashMemlimitMin && + opsLimit <= Sodium.cryptoPwhashOpslimitMax) { try { key = await deriveKey(password, salt, memLimit, opsLimit); return DerivedKeyResult(key, memLimit, opsLimit); } catch (e, s) { - logger.severe(e, s); + logger.severe( + "failed to derive memLimit: $memLimit and opsLimit: $opsLimit", + e, + s, + ); } memLimit = (memLimit / 2).round(); opsLimit = opsLimit * 2; diff --git a/lib/utils/delete_file_util.dart b/lib/utils/delete_file_util.dart index 2c285fc6ddd52152a2aaacd2ab26d987b081e24f..cf86f1aa0286112b86b8d557657350e77828a72a 100644 --- a/lib/utils/delete_file_util.dart +++ b/lib/utils/delete_file_util.dart @@ -20,7 +20,6 @@ import 'package:photos/models/trash_item_request.dart'; import 'package:photos/services/remote_sync_service.dart'; import 'package:photos/services/sync_service.dart'; import 'package:photos/services/trash_sync_service.dart'; -import 'package:photos/ui/common/dialogs.dart'; import 'package:photos/ui/common/linear_progress_dialog.dart'; import 'package:photos/ui/components/action_sheet_widget.dart'; import 'package:photos/ui/components/button_widget.dart'; @@ -35,8 +34,6 @@ Future deleteFilesFromEverywhere( BuildContext context, List files, ) async { - final dialog = createProgressDialog(context, "Deleting..."); - await dialog.show(); _logger.info("Trying to deleteFilesFromEverywhere " + files.toString()); final List localAssetIDs = []; final List localSharedMediaIDs = []; @@ -60,7 +57,6 @@ Future deleteFilesFromEverywhere( if (hasLocalOnlyFiles && Platform.isAndroid) { final shouldProceed = await shouldProceedWithDeletion(context); if (!shouldProceed) { - await dialog.hide(); return; } } @@ -102,12 +98,9 @@ Future deleteFilesFromEverywhere( uploadedFilesToBeTrashed.map((item) => item.fileID).toList(); await TrashSyncService.instance .trashFilesOnServer(uploadedFilesToBeTrashed); - // await SyncService.instance - // .deleteFilesOnServer(fileIDs); await FilesDB.instance.deleteMultipleUploadedFiles(fileIDs); } catch (e) { _logger.severe(e); - await dialog.hide(); showGenericErrorDialog(context: context); rethrow; } @@ -138,7 +131,6 @@ Future deleteFilesFromEverywhere( showShortToast(context, "Moved to trash"); } } - await dialog.hide(); if (uploadedFilesToBeTrashed.isNotEmpty) { RemoteSyncService.instance.sync(silently: true); } @@ -153,8 +145,6 @@ Future deleteFilesFromRemoteOnly( showToast(context, "Selected files are not on ente"); return; } - final dialog = createProgressDialog(context, "Deleting..."); - await dialog.show(); _logger.info( "Trying to deleteFilesFromRemoteOnly " + files.map((f) => f.uploadedFileID).toString(), @@ -172,7 +162,6 @@ Future deleteFilesFromRemoteOnly( await FilesDB.instance.deleteMultipleUploadedFiles(uploadedFileIDs); } catch (e, s) { _logger.severe("Failed to delete files from remote", e, s); - await dialog.hide(); showGenericErrorDialog(context: context); rethrow; } @@ -194,7 +183,6 @@ Future deleteFilesFromRemoteOnly( ), ); SyncService.instance.sync(); - await dialog.hide(); RemoteSyncService.instance.sync(silently: true); } @@ -202,8 +190,6 @@ Future deleteFilesOnDeviceOnly( BuildContext context, List files, ) async { - final dialog = createProgressDialog(context, "Deleting..."); - await dialog.show(); _logger.info("Trying to deleteFilesOnDeviceOnly" + files.toString()); final List localAssetIDs = []; final List localSharedMediaIDs = []; @@ -227,7 +213,6 @@ Future deleteFilesOnDeviceOnly( if (hasLocalOnlyFiles && Platform.isAndroid) { final shouldProceed = await shouldProceedWithDeletion(context); if (!shouldProceed) { - await dialog.hide(); return; } } @@ -258,18 +243,19 @@ Future deleteFilesOnDeviceOnly( ), ); } - await dialog.hide(); } Future deleteFromTrash(BuildContext context, List files) async { - final result = await showNewChoiceDialog( + bool didDeletionStart = false; + final result = await showChoiceActionSheet( context, - title: "Delete permanently", + title: "Permanently delete?", body: "This action cannot be undone", firstButtonLabel: "Delete", isCritical: true, firstButtonOnTap: () async { try { + didDeletionStart = true; await TrashSyncService.instance.deleteFromTrash(files); Bus.instance.fire( FilesUpdatedEvent( @@ -289,16 +275,18 @@ Future deleteFromTrash(BuildContext context, List files) async { return false; } if (result == null || result == ButtonAction.cancel) { - return false; + return didDeletionStart ? true : false; } else { return true; } } Future emptyTrash(BuildContext context) async { - final result = await showNewChoiceDialog( + final result = await showChoiceActionSheet( context, - title: "Empty trash", + title: "Empty trash?", + body: + "All items in trash will be permanently deleted\n\nThis action cannot be undone", firstButtonLabel: "Empty", isCritical: true, firstButtonOnTap: () async { @@ -479,22 +467,25 @@ Future> _tryDeleteSharedMediaFiles(List localIDs) { } Future shouldProceedWithDeletion(BuildContext context) async { - final choice = await showChoiceDialog( + final choice = await showChoiceActionSheet( context, - "Are you sure?", - "Some of the files you are trying to delete are only available on your device and cannot be recovered if deleted", - firstAction: "Cancel", - secondAction: "Delete", - secondActionColor: Colors.red, + title: "Permanently delete from device?", + body: + "Some of the files you are trying to delete are only available on your device and cannot be recovered if deleted", + firstButtonLabel: "Delete", + isCritical: true, ); - return choice == DialogUserChoice.secondChoice; + if (choice == null) { + return false; + } else { + return choice == ButtonAction.first; + } } Future showDeleteSheet( BuildContext context, SelectedFiles selectedFiles, ) async { - final count = selectedFiles.files.length; bool containsUploadedFile = false, containsLocalFile = false; for (final file in selectedFiles.files) { if (file.uploadedFileID != null) { @@ -576,8 +567,6 @@ Future showDeleteSheet( context, selectedFiles.files.toList(), ); - // Navigator.of(context, rootNavigator: true).pop(); - // widget.onFileRemoved(file); }, ), ); diff --git a/lib/utils/dialog_util.dart b/lib/utils/dialog_util.dart index 108d5edd6fc546704fbf4065e3f4721936d138fe..d94764de603af30c8c8e37d47e6edbd13992e065 100644 --- a/lib/utils/dialog_util.dart +++ b/lib/utils/dialog_util.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/ui/common/loading_widget.dart'; import 'package:photos/ui/common/progress_dialog.dart'; +import 'package:photos/ui/components/action_sheet_widget.dart'; import 'package:photos/ui/components/button_widget.dart'; import 'package:photos/ui/components/dialog_widget.dart'; import 'package:photos/ui/components/models/button_type.dart'; @@ -43,7 +44,8 @@ Future showGenericErrorDialog({ context: context, title: "Error", icon: Icons.error_outline_outlined, - body: "It looks like something went wrong. Please try again.", + body: + "It looks like something went wrong. Please retry after some time. If the error persists, please contact our support team.", isDismissible: isDismissible, buttons: const [ ButtonWidget( @@ -90,7 +92,7 @@ DialogWidget choiceDialog({ } ///Will return null if dismissed by tapping outside -Future showNewChoiceDialog( +Future showChoiceDialog( BuildContext context, { required String title, String? body, @@ -132,6 +134,50 @@ Future showNewChoiceDialog( ); } +///Will return null if dismissed by tapping outside +Future showChoiceActionSheet( + BuildContext context, { + required String title, + String? body, + required String firstButtonLabel, + String secondButtonLabel = "Cancel", + ButtonType firstButtonType = ButtonType.neutral, + ButtonType secondButtonType = ButtonType.secondary, + ButtonAction firstButtonAction = ButtonAction.first, + ButtonAction secondButtonAction = ButtonAction.cancel, + FutureVoidCallback? firstButtonOnTap, + FutureVoidCallback? secondButtonOnTap, + bool isCritical = false, + IconData? icon, + bool isDismissible = true, +}) async { + final buttons = [ + ButtonWidget( + buttonType: isCritical ? ButtonType.critical : firstButtonType, + labelText: firstButtonLabel, + isInAlert: true, + onTap: firstButtonOnTap, + buttonAction: firstButtonAction, + shouldStickToDarkTheme: true, + ), + ButtonWidget( + buttonType: secondButtonType, + labelText: secondButtonLabel, + isInAlert: true, + onTap: secondButtonOnTap, + buttonAction: secondButtonAction, + shouldStickToDarkTheme: true, + ), + ]; + return showActionSheet( + context: context, + title: title, + body: body, + buttons: buttons, + isDismissible: isDismissible, + ); +} + ProgressDialog createProgressDialog( BuildContext context, String message, { diff --git a/lib/utils/diff_fetcher.dart b/lib/utils/diff_fetcher.dart index 53afb7a642bc727e7bb610dfb7ec09ada3a36d75..f0f7b7396fb1e5bf4b89a24346e7df8e6580e08a 100644 --- a/lib/utils/diff_fetcher.dart +++ b/lib/utils/diff_fetcher.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:logging/logging.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/magic_metadata.dart'; @@ -12,7 +12,7 @@ import 'package:photos/utils/file_download_util.dart'; class DiffFetcher { final _logger = Logger("DiffFetcher"); - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; Future getEncryptedFilesDiff(int collectionID, int sinceTime) async { _logger.info( diff --git a/lib/utils/email_util.dart b/lib/utils/email_util.dart index e1264a5460f8891224945c5f0a567a227afa2cf6..63d1e061ff8c0fa40da4ac00f2fdfd74082004b6 100644 --- a/lib/utils/email_util.dart +++ b/lib/utils/email_util.dart @@ -253,7 +253,7 @@ Future _clientInfo() async { } void _showNoMailAppsDialog(BuildContext context, String toEmail) { - showNewChoiceDialog( + showChoiceDialog( context, icon: Icons.email_outlined, title: 'Please email us at $toEmail', diff --git a/lib/utils/file_download_util.dart b/lib/utils/file_download_util.dart index 8edb783bb65e97b88bc428bfdd464bda9e509d83..a5bec8d9a82a4470d350e992ba32d4cfe9226f37 100644 --- a/lib/utils/file_download_util.dart +++ b/lib/utils/file_download_util.dart @@ -5,7 +5,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/models/file.dart' as ente; import 'package:photos/services/collections_service.dart'; import 'package:photos/utils/crypto_util.dart'; @@ -22,7 +22,7 @@ Future downloadAndDecrypt( ".encrypted"; final encryptedFile = io.File(encryptedFilePath); final startTime = DateTime.now().millisecondsSinceEpoch; - return Network.instance + return NetworkClient.instance .getDio() .download( file.downloadUrl, diff --git a/lib/utils/file_uploader.dart b/lib/utils/file_uploader.dart index d6b56419dd23c25d883bf08788770ab9cefca6af..ec1f6ece302728ee5d8b57836dbb6c4a23dee8b7 100644 --- a/lib/utils/file_uploader.dart +++ b/lib/utils/file_uploader.dart @@ -15,7 +15,7 @@ import 'package:path/path.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/errors.dart'; import 'package:photos/core/event_bus.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/db/upload_locks_db.dart'; import 'package:photos/events/files_updated_event.dart'; @@ -44,8 +44,8 @@ class FileUploader { static const kFileUploadTimeout = Duration(minutes: 50); final _logger = Logger("FileUploader"); - final _dio = Network.instance.getDio(); - final _enteDio = Network.instance.enteDio; + final _dio = NetworkClient.instance.getDio(); + final _enteDio = NetworkClient.instance.enteDio; final LinkedHashMap _queue = LinkedHashMap(); final _uploadLocks = UploadLocksDB.instance; final kSafeBufferForLockExpiry = const Duration(days: 1).inMicroseconds; @@ -603,7 +603,9 @@ class FileUploader { // case c and d final File? fileExistsButDifferentCollection = existingUploadedFiles.firstWhereOrNull( - (e) => e.collectionID != toCollectionID, + (e) => + e.collectionID != toCollectionID && + (e.localID == null || e.localID == fileToUpload.localID), ); if (fileExistsButDifferentCollection != null) { _logger.fine( diff --git a/lib/utils/thumbnail_util.dart b/lib/utils/thumbnail_util.dart index 4dd09f7ee6f0cb2cc86f76dd3382880a4039c505..916c9ff0bb6383fa401d570063c1d32ecfd895ae 100644 --- a/lib/utils/thumbnail_util.dart +++ b/lib/utils/thumbnail_util.dart @@ -11,7 +11,7 @@ import 'package:photos/core/cache/thumbnail_in_memory_cache.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/errors.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/models/file.dart'; import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/file_download_util.dart'; @@ -129,7 +129,7 @@ Future _downloadAndDecryptThumbnail(FileDownloadItem item) async { final file = item.file; Uint8List encryptedThumbnail; try { - encryptedThumbnail = (await Network.instance.getDio().get( + encryptedThumbnail = (await NetworkClient.instance.getDio().get( file.thumbnailUrl, options: Options( headers: {"X-Auth-Token": Configuration.instance.getToken()}, diff --git a/lib/utils/trash_diff_fetcher.dart b/lib/utils/trash_diff_fetcher.dart index a9f8aab8fc834bc04045ceee3a16e05181be4cde..5283e5464b348816f4931210a3ef16f90e5d477f 100644 --- a/lib/utils/trash_diff_fetcher.dart +++ b/lib/utils/trash_diff_fetcher.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:logging/logging.dart'; -import 'package:photos/core/network.dart'; +import 'package:photos/core/network/network.dart'; import 'package:photos/models/magic_metadata.dart'; import 'package:photos/models/trash_file.dart'; import 'package:photos/utils/crypto_util.dart'; @@ -11,7 +11,7 @@ import 'package:photos/utils/file_download_util.dart'; class TrashDiffFetcher { final _logger = Logger("TrashDiffFetcher"); - final _enteDio = Network.instance.enteDio; + final _enteDio = NetworkClient.instance.enteDio; Future getTrashFilesDiff(int sinceTime) async { try { diff --git a/pubspec.yaml b/pubspec.yaml index c4437297b886de090f323d9a13ef0022c3dcaa1b..5002de450b67544e6fc01e910b4dc6ae43e510f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.7.8+408 +version: 0.7.20+420 environment: sdk: '>=2.17.0 <3.0.0'