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