Parcourir la source

Sort option in album (#1149)

Neeraj Gupta il y a 2 ans
Parent
commit
68392fa8c6

+ 20 - 0
lib/db/collections_db.dart

@@ -30,6 +30,9 @@ class CollectionsDB {
   // MMD -> Magic Metadata
   static const columnMMdEncodedJson = 'mmd_encoded_json';
   static const columnMMdVersion = 'mmd_ver';
+
+  static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
+  static const columnPubMMdVersion = 'pub_mmd_ver';
   static const columnUpdationTime = 'updation_time';
   static const columnIsDeleted = 'is_deleted';
 
@@ -41,6 +44,7 @@ class CollectionsDB {
     ...addIsDeleted(),
     ...addPublicURLs(),
     ...addPrivateMetadata(),
+    ...addPublicMetadata(),
   ];
 
   final dbConfig = MigrationConfig(
@@ -156,6 +160,18 @@ class CollectionsDB {
     ];
   }
 
+  static List<String> addPublicMetadata() {
+    return [
+      '''
+        ALTER TABLE $table ADD COLUMN $columnPubMMdEncodedJson TEXT DEFAULT '
+        {}';
+      ''',
+      '''
+        ALTER TABLE $table ADD COLUMN $columnPubMMdVersion INTEGER DEFAULT 0;
+      '''
+    ];
+  }
+
   Future<void> insert(List<Collection> collections) async {
     final db = await instance.database;
     var batch = db.batch();
@@ -238,6 +254,8 @@ class CollectionsDB {
     }
     row[columnMMdVersion] = collection.mMdVersion;
     row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}';
+    row[columnPubMMdVersion] = collection.mMbPubVersion;
+    row[columnPubMMdEncodedJson] = collection.mMdPubEncodedJson ?? '{}';
     return row;
   }
 
@@ -271,6 +289,8 @@ class CollectionsDB {
     );
     result.mMdVersion = row[columnMMdVersion] ?? 0;
     result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
+    result.mMbPubVersion = row[columnPubMMdVersion] ?? 0;
+    result.mMdPubEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
     return result;
   }
 }

+ 16 - 0
lib/events/collection_meta_event.dart

@@ -0,0 +1,16 @@
+import 'package:photos/events/event.dart';
+
+class CollectionMetaEvent extends Event {
+  final int id;
+  final CollectionMetaEventType type;
+
+  CollectionMetaEvent(this.id, this.type);
+}
+
+enum CollectionMetaEventType {
+  created,
+  deleted,
+  archived,
+  sortChanged,
+  thumbnailChanged,
+}

+ 3 - 0
lib/generated/intl/messages_en.dart

@@ -1096,6 +1096,9 @@ class MessageLookup extends MessageLookupByLibrary {
         "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease":
             MessageLookupByLibrary.simpleMessage(
                 "Sorry, we could not generate secure keys on this device.\n\nplease sign up from a different device."),
+        "sortAlbumsBy": MessageLookupByLibrary.simpleMessage("Sort by"),
+        "sortNewestFirst": MessageLookupByLibrary.simpleMessage("Newest first"),
+        "sortOldestFirst": MessageLookupByLibrary.simpleMessage("Oldest first"),
         "sparkleSuccess": MessageLookupByLibrary.simpleMessage("✨ Success"),
         "startBackup": MessageLookupByLibrary.simpleMessage("Start backup"),
         "storage": MessageLookupByLibrary.simpleMessage("Storage"),

+ 30 - 0
lib/generated/l10n.dart

@@ -5233,6 +5233,36 @@ class S {
     );
   }
 
+  /// `Sort by`
+  String get sortAlbumsBy {
+    return Intl.message(
+      'Sort by',
+      name: 'sortAlbumsBy',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Newest first`
+  String get sortNewestFirst {
+    return Intl.message(
+      'Newest first',
+      name: 'sortNewestFirst',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Oldest first`
+  String get sortOldestFirst {
+    return Intl.message(
+      'Oldest first',
+      name: 'sortOldestFirst',
+      desc: '',
+      args: [],
+    );
+  }
+
   /// `Rename`
   String get rename {
     return Intl.message(

+ 3 - 0
lib/l10n/intl_en.arb

@@ -748,6 +748,9 @@
   },
   "deleteAll": "Delete All",
   "renameAlbum": "Rename album",
+  "sortAlbumsBy": "Sort by",
+  "sortNewestFirst": "Newest first",
+  "sortOldestFirst": "Oldest first",
   "rename": "Rename",
   "leaveSharedAlbum": "Leave shared album?",
   "leaveAlbum": "Leave album",

+ 12 - 1
lib/models/collection.dart

@@ -22,13 +22,22 @@ class Collection {
   final int updationTime;
   final bool isDeleted;
   String? mMdEncodedJson;
+  String? mMdPubEncodedJson;
   int mMdVersion = 0;
+  int mMbPubVersion = 0;
   CollectionMagicMetadata? _mmd;
+  CollectionPubMagicMetadata? _pubMmd;
 
   CollectionMagicMetadata get magicMetadata =>
       _mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
 
-  set magicMetadata(val) => _mmd = val;
+  CollectionPubMagicMetadata get pubMagicMetadata =>
+      _pubMmd ??
+      CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson ?? '{}');
+
+  set magicMetadata(CollectionMagicMetadata? val) => _mmd = val;
+
+  set pubMagicMetadata(CollectionPubMagicMetadata? val) => _pubMmd = val;
 
   Collection(
     this.id,
@@ -162,6 +171,8 @@ class Collection {
     );
     result.mMdVersion = mMdVersion ?? this.mMdVersion;
     result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
+    result.mMbPubVersion = mMbPubVersion;
+    result.mMdPubEncodedJson = mMdPubEncodedJson;
     return result;
   }
 

+ 32 - 0
lib/models/metadata/collection_magic.dart

@@ -44,3 +44,35 @@ class CollectionMagicMetadata {
     );
   }
 }
+
+class CollectionPubMagicMetadata {
+  // sort order while showing collection
+  bool? asc;
+
+  // cover photo id for the collection
+  int? coverID;
+
+  CollectionPubMagicMetadata({this.asc, this.coverID});
+
+  Map<String, dynamic> toJson() {
+    final Map<String, dynamic> result = {"asc": asc ?? false};
+    if (coverID != null) {
+      result["coverID"] = coverID!;
+    }
+    return result;
+  }
+
+  factory CollectionPubMagicMetadata.fromEncodedJson(String encodedJson) =>
+      CollectionPubMagicMetadata.fromJson(jsonDecode(encodedJson));
+
+  factory CollectionPubMagicMetadata.fromJson(dynamic json) =>
+      CollectionPubMagicMetadata.fromMap(json);
+
+  static fromMap(Map<String, dynamic>? map) {
+    if (map == null) return null;
+    return CollectionPubMagicMetadata(
+      asc: map["asc"] as bool?,
+      coverID: map["coverID"],
+    );
+  }
+}

+ 76 - 2
lib/services/collections_service.dart

@@ -610,6 +610,65 @@ class CollectionsService {
     }
   }
 
+  Future<void> updatePublicMagicMetadata(
+    Collection collection,
+    Map<String, dynamic> newMetadataUpdate,
+  ) async {
+    final int ownerID = Configuration.instance.getUserID()!;
+    try {
+      if (collection.owner?.id != ownerID) {
+        throw AssertionError("cannot modify albums not owned by you");
+      }
+      // read the existing magic metadata and apply new updates to existing data
+      // current update is simple replace. This will be enhanced in the future,
+      // as required.
+      final Map<String, dynamic> jsonToUpdate =
+          jsonDecode(collection.mMdPubEncodedJson ?? '{}');
+      newMetadataUpdate.forEach((key, value) {
+        jsonToUpdate[key] = value;
+      });
+
+      final key = getCollectionKey(collection.id);
+      final encryptedMMd = await CryptoUtil.encryptChaCha(
+        utf8.encode(jsonEncode(jsonToUpdate)) as Uint8List,
+        key,
+      );
+      // for required field, the json validator on golang doesn't treat 0 as valid
+      // value. Instead of changing version to ptr, decided to start version with 1.
+      final int currentVersion = max(collection.mMbPubVersion, 1);
+      final params = UpdateMagicMetadataRequest(
+        id: collection.id,
+        magicMetadata: MetadataRequest(
+          version: currentVersion,
+          count: jsonToUpdate.length,
+          data: CryptoUtil.bin2base64(encryptedMMd.encryptedData!),
+          header: CryptoUtil.bin2base64(encryptedMMd.header!),
+        ),
+      );
+      await _enteDio.put(
+        "/collections/public-magic-metadata",
+        data: params,
+      );
+      // update the local information so that it's reflected on UI
+      collection.mMdPubEncodedJson = jsonEncode(jsonToUpdate);
+      collection.pubMagicMetadata =
+          CollectionPubMagicMetadata.fromJson(jsonToUpdate);
+      collection.mMbPubVersion = currentVersion + 1;
+      _cacheCollectionAttributes(collection);
+      // trigger sync to fetch the latest collection state from server
+      sync().ignore();
+    } on DioError catch (e) {
+      if (e.response != null && e.response?.statusCode == 409) {
+        _logger.severe('collection magic data out of sync');
+        sync().ignore();
+      }
+      rethrow;
+    } catch (e, s) {
+      _logger.severe("failed to sync magic metadata", e, s);
+      rethrow;
+    }
+  }
+
   Future<void> createShareUrl(
     Collection collection, {
     bool enableCollect = false,
@@ -701,9 +760,9 @@ class CollectionsService {
       final c = response.data["collections"];
       for (final collectionData in c) {
         final collection = Collection.fromMap(collectionData);
+        final collectionKey =
+            _getAndCacheDecryptedKey(collection, source: "fetchCollection");
         if (collectionData['magicMetadata'] != null) {
-          final collectionKey =
-              _getAndCacheDecryptedKey(collection, source: "fetchCollection");
           final utfEncodedMmd = await CryptoUtil.decryptChaCha(
             CryptoUtil.base642bin(collectionData['magicMetadata']['data']),
             collectionKey,
@@ -715,6 +774,21 @@ class CollectionsService {
             collection.mMdEncodedJson,
           );
         }
+
+        if (collectionData['pubMagicMetadata'] != null) {
+          final utfEncodedMmd = await CryptoUtil.decryptChaCha(
+            CryptoUtil.base642bin(collectionData['pubMagicMetadata']['data']),
+            collectionKey,
+            CryptoUtil.base642bin(collectionData['pubMagicMetadata']['header']),
+          );
+          collection.mMdPubEncodedJson = utf8.decode(utfEncodedMmd);
+          collection.mMbPubVersion =
+              collectionData['pubMagicMetadata']['version'];
+          collection.pubMagicMetadata =
+              CollectionPubMagicMetadata.fromEncodedJson(
+            collection.mMdEncodedJson,
+          );
+        }
         collections.add(collection);
       }
       return collections;

+ 9 - 1
lib/ui/viewer/gallery/collection_page.dart

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
 import 'package:photos/core/configuration.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/db/files_db.dart';
+import "package:photos/events/collection_meta_event.dart";
 import 'package:photos/events/collection_updated_event.dart';
 import 'package:photos/events/files_updated_event.dart';
 import 'package:photos/models/collection.dart';
@@ -65,6 +66,13 @@ class CollectionPage extends StatelessWidget {
       reloadEvent: Bus.instance
           .on<CollectionUpdatedEvent>()
           .where((event) => event.collectionID == c.collection.id),
+      forceReloadEvents: [
+        Bus.instance.on<CollectionMetaEvent>().where(
+              (event) =>
+                  event.id == c.collection.id &&
+                  event.type == CollectionMetaEventType.sortChanged,
+            )
+      ],
       removalEventTypes: const {
         EventType.deletedFromRemote,
         EventType.deletedFromEverywhere,
@@ -74,7 +82,7 @@ class CollectionPage extends StatelessWidget {
       selectedFiles: _selectedFiles,
       initialFiles: initialFiles,
       albumName: c.collection.name,
-      sortOrderAsc: false,
+      sortAsyncFn: () => c.collection.pubMagicMetadata.asc ?? false,
     );
     return Scaffold(
       appBar: PreferredSize(

+ 14 - 6
lib/ui/viewer/gallery/gallery.dart

@@ -25,6 +25,8 @@ typedef GalleryLoader = Future<FileLoadResult> Function(
   bool? asc,
 });
 
+typedef SortAscFn = bool Function();
+
 class Gallery extends StatefulWidget {
   final GalleryLoader asyncLoader;
   final List<File>? initialFiles;
@@ -42,7 +44,9 @@ class Gallery extends StatefulWidget {
   final Widget loadingWidget;
   final bool disableScroll;
   final bool limitSelectionToOne;
-  final bool sortOrderAsc;
+
+  // add a Function variable to get sort value in bool
+  final SortAscFn? sortAsyncFn;
 
   const Gallery({
     required this.asyncLoader,
@@ -61,7 +65,7 @@ class Gallery extends StatefulWidget {
     this.loadingWidget = const EnteLoadingWidget(),
     this.disableScroll = false,
     this.limitSelectionToOne = false,
-    this.sortOrderAsc = false,
+    this.sortAsyncFn,
     Key? key,
   }) : super(key: key);
 
@@ -84,6 +88,7 @@ class _GalleryState extends State<Gallery> {
   StreamSubscription<TabDoubleTapEvent>? _tabDoubleTapEvent;
   final _forceReloadEventSubscriptions = <StreamSubscription<Event>>[];
   late String _logTag;
+  bool _sortOrderAsc = false;
 
   @override
   void initState() {
@@ -91,6 +96,7 @@ class _GalleryState extends State<Gallery> {
         "Gallery_${widget.tagPrefix}${kDebugMode ? "_" + widget.albumName! : ""}";
     _logger = Logger(_logTag);
     _logger.finest("init Gallery");
+    _sortOrderAsc = widget.sortAsyncFn != null ? widget.sortAsyncFn!() : false;
     _itemScroller = ItemScrollController();
     if (widget.reloadEvent != null) {
       _reloadEventSubscription = widget.reloadEvent!.listen((event) async {
@@ -122,13 +128,15 @@ class _GalleryState extends State<Gallery> {
         _forceReloadEventSubscriptions.add(
           event.listen((event) async {
             _logger.finest("Force refresh all files on ${event.reason}");
+            _sortOrderAsc =
+                widget.sortAsyncFn != null ? widget.sortAsyncFn!() : false;
             final result = await _loadFiles();
             _setFilesAndReload(result.files);
           }),
         );
       }
     }
-    if (widget.initialFiles != null && !widget.sortOrderAsc) {
+    if (widget.initialFiles != null && !_sortOrderAsc) {
       _onFilesLoaded(widget.initialFiles!);
     }
     _loadFiles(limit: kInitialLoadLimit).then((result) async {
@@ -156,7 +164,7 @@ class _GalleryState extends State<Gallery> {
         galleryLoadStartTime,
         galleryLoadEndTime,
         limit: limit,
-        asc: widget.sortOrderAsc,
+        asc: _sortOrderAsc,
       );
       final endTime = DateTime.now().microsecondsSinceEpoch;
       final duration = Duration(microseconds: endTime - startTime);
@@ -216,7 +224,7 @@ class _GalleryState extends State<Gallery> {
       disableScroll: widget.disableScroll,
       emptyState: widget.emptyState,
       asyncLoader: widget.asyncLoader,
-      sortOrderAsc: widget.sortOrderAsc,
+      sortOrderAsc: _sortOrderAsc,
       removalEventTypes: widget.removalEventTypes,
       tagPrefix: widget.tagPrefix,
       scrollBottomSafeArea: widget.scrollBottomSafeArea,
@@ -248,7 +256,7 @@ class _GalleryState extends State<Gallery> {
     if (dailyFiles.isNotEmpty) {
       collatedFiles.add(dailyFiles);
     }
-    if (widget.sortOrderAsc) {
+    if (_sortOrderAsc) {
       collatedFiles
           .sort((a, b) => a[0].creationTime!.compareTo(b[0].creationTime!));
     } else {

+ 43 - 0
lib/ui/viewer/gallery/gallery_app_bar_widget.dart

@@ -283,6 +283,22 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
       // Do not show archive option for favorite collection. If collection is
       // already archived, allow user to unarchive that collection.
       if (isArchived || widget.collection!.type != CollectionType.favorites) {
+        items.add(
+          PopupMenuItem(
+            value: 6,
+            child: Row(
+              children: [
+                const Icon(Icons.sort_outlined),
+                const Padding(
+                  padding: EdgeInsets.all(8),
+                ),
+                Text(
+                  S.of(context).sortAlbumsBy,
+                ),
+              ],
+            ),
+          ),
+        );
         items.add(
           PopupMenuItem(
             value: 2,
@@ -375,6 +391,8 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
               await _leaveAlbum(context);
             } else if (value == 5) {
               await _deleteBackedUpFiles(context);
+            } else if (value == 6) {
+              await _showSortOption(context);
             } else {
               showToast(context, S.of(context).somethingWentWrong);
             }
@@ -386,6 +404,31 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
     return actions;
   }
 
+  Future<void> _showSortOption(BuildContext bContext) async {
+    final bool? sortByAsc = await showMenu<bool>(
+      context: bContext,
+      position: RelativeRect.fromLTRB(
+        MediaQuery.of(context).size.width,
+        kToolbarHeight + 12,
+        12,
+        0,
+      ),
+      items: [
+        PopupMenuItem(
+          value: false,
+          child: Text(S.of(context).sortNewestFirst),
+        ),
+        PopupMenuItem(
+          value: true,
+          child: Text(S.of(context).sortOldestFirst),
+        ),
+      ],
+    );
+    if (sortByAsc != null) {
+      changeSortOrder(bContext, widget.collection!, sortByAsc);
+    }
+  }
+
   Future<void> _trashCollection() async {
     final collectionWithThumbnail =
         await CollectionsService.instance.getCollectionsWithThumbnails();

+ 20 - 0
lib/utils/magic_util.dart

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
 import 'package:path/path.dart';
 import 'package:photos/core/event_bus.dart';
+import "package:photos/events/collection_meta_event.dart";
 import 'package:photos/events/force_reload_home_gallery_event.dart';
 import "package:photos/generated/l10n.dart";
 import 'package:photos/models/collection.dart';
@@ -77,6 +78,25 @@ Future<void> changeCollectionVisibility(
   }
 }
 
+Future<void> changeSortOrder(
+  BuildContext context,
+  Collection collection,
+  bool sortedInAscOrder,
+) async {
+  try {
+    final Map<String, dynamic> update = {"asc": sortedInAscOrder};
+    await CollectionsService.instance
+        .updatePublicMagicMetadata(collection, update);
+    Bus.instance.fire(
+      CollectionMetaEvent(collection.id, CollectionMetaEventType.sortChanged),
+    );
+  } catch (e, s) {
+    _logger.severe("failed to update collection visibility", e, s);
+    showShortToast(context, S.of(context).somethingWentWrong);
+    rethrow;
+  }
+}
+
 Future<bool> editTime(
   BuildContext context,
   List<File> files,