Pārlūkot izejas kodu

feat(location_tag): add location tags (#908)

Neeraj Gupta 2 gadi atpakaļ
vecāks
revīzija
06c9ead3e2
55 mainītis faili ar 3820 papildinājumiem un 310 dzēšanām
  1. 8 0
      lib/core/constants.dart
  2. 65 0
      lib/db/entities_db.dart
  3. 72 8
      lib/db/files_db.dart
  4. 16 0
      lib/events/location_tag_updated_event.dart
  5. 114 0
      lib/gateways/entity_gw.dart
  6. 5 0
      lib/main.dart
  7. 55 0
      lib/models/api/entity/data.dart
  8. 46 0
      lib/models/api/entity/key.dart
  9. 26 0
      lib/models/api/entity/type.dart
  10. 4 3
      lib/models/file.dart
  11. 11 1
      lib/models/gallery_type.dart
  12. 48 0
      lib/models/local_entity_data.dart
  13. 0 9
      lib/models/location.dart
  14. 16 0
      lib/models/location/location.dart
  15. 168 0
      lib/models/location/location.freezed.dart
  16. 18 0
      lib/models/location/location.g.dart
  17. 25 0
      lib/models/location_tag/location_tag.dart
  18. 252 0
      lib/models/location_tag/location_tag.freezed.dart
  19. 26 0
      lib/models/location_tag/location_tag.g.dart
  20. 1 1
      lib/models/search/search_result.dart
  21. 4 0
      lib/models/typedefs.dart
  22. 192 0
      lib/services/entity_service.dart
  23. 11 0
      lib/services/files_service.dart
  24. 212 0
      lib/services/location_service.dart
  25. 47 61
      lib/services/search_service.dart
  26. 77 0
      lib/states/location_screen_state.dart
  27. 132 0
      lib/states/location_state.dart
  28. 1 0
      lib/theme/colors.dart
  29. 1 1
      lib/ui/actions/file/file_actions.dart
  30. 3 2
      lib/ui/collection_action_sheet.dart
  31. 10 8
      lib/ui/components/buttons/chip_button_widget.dart
  32. 14 8
      lib/ui/components/divider_widget.dart
  33. 53 16
      lib/ui/components/text_input_widget.dart
  34. 166 86
      lib/ui/components/title_bar_widget.dart
  35. 1 1
      lib/ui/home/memories_widget.dart
  36. 6 0
      lib/ui/huge_listview/huge_listview.dart
  37. 121 79
      lib/ui/huge_listview/lazy_loading_gallery.dart
  38. 5 2
      lib/ui/tools/editor/image_editor_page.dart
  39. 3 3
      lib/ui/viewer/file/fading_bottom_bar.dart
  40. 3 3
      lib/ui/viewer/file/file_caption_widget.dart
  41. 67 4
      lib/ui/viewer/file/file_details_widget.dart
  42. 118 0
      lib/ui/viewer/file_details/location_tags_widget.dart
  43. 16 4
      lib/ui/viewer/gallery/gallery.dart
  44. 265 0
      lib/ui/viewer/location/add_location_sheet.dart
  45. 143 0
      lib/ui/viewer/location/dynamic_location_gallery_widget.dart
  46. 69 0
      lib/ui/viewer/location/edit_center_point_tile_widget.dart
  47. 274 0
      lib/ui/viewer/location/edit_location_sheet.dart
  48. 300 0
      lib/ui/viewer/location/location_screen.dart
  49. 196 0
      lib/ui/viewer/location/pick_center_point_widget.dart
  50. 142 0
      lib/ui/viewer/location/radius_picker_widget.dart
  51. 9 7
      lib/ui/viewer/search/search_widget.dart
  52. 3 2
      lib/utils/file_uploader_util.dart
  53. 14 0
      lib/utils/lat_lon_util.dart
  54. 161 1
      pubspec.lock
  55. 5 0
      pubspec.yaml

+ 8 - 0
lib/core/constants.dart

@@ -57,3 +57,11 @@ const double restrictedMaxWidth = 430;
 const double mobileSmallThreshold = 336;
 
 const publicLinkDeviceLimits = [50, 25, 10, 5, 2, 1];
+
+const kilometersPerDegree = 111.16;
+
+const radiusValues = <int>[2, 10, 20, 40, 80, 200, 400, 1200];
+
+const defaultRadiusValueIndex = 4;
+
+const galleryGridSpacing = 2.0;

+ 65 - 0
lib/db/entities_db.dart

@@ -0,0 +1,65 @@
+import 'package:flutter/foundation.dart';
+import 'package:photos/db/files_db.dart';
+import "package:photos/models/api/entity/type.dart";
+import "package:photos/models/local_entity_data.dart";
+import 'package:sqflite/sqlite_api.dart';
+
+extension EntitiesDB on FilesDB {
+  Future<void> upsertEntities(
+    List<LocalEntityData> data, {
+    ConflictAlgorithm conflictAlgorithm = ConflictAlgorithm.replace,
+  }) async {
+    debugPrint("Inserting missing PathIDToLocalIDMapping");
+    final db = await database;
+    var batch = db.batch();
+    int batchCounter = 0;
+    for (LocalEntityData e in data) {
+      if (batchCounter == 400) {
+        await batch.commit(noResult: true);
+        batch = db.batch();
+        batchCounter = 0;
+      }
+      batch.insert(
+        "entities",
+        e.toJson(),
+        conflictAlgorithm: conflictAlgorithm,
+      );
+      batchCounter++;
+    }
+    await batch.commit(noResult: true);
+  }
+
+  Future<void> deleteEntities(
+    List<String> ids,
+  ) async {
+    final db = await database;
+    var batch = db.batch();
+    int batchCounter = 0;
+    for (String id in ids) {
+      if (batchCounter == 400) {
+        await batch.commit(noResult: true);
+        batch = db.batch();
+        batchCounter = 0;
+      }
+      batch.delete(
+        "entities",
+        where: "id = ?",
+        whereArgs: [id],
+      );
+      batchCounter++;
+    }
+    await batch.commit(noResult: true);
+  }
+
+  Future<List<LocalEntityData>> getEntities(EntityType type) async {
+    final db = await database;
+    final List<Map<String, dynamic>> maps = await db.query(
+      "entities",
+      where: "type = ?",
+      whereArgs: [type.typeToString()],
+    );
+    return List.generate(maps.length, (i) {
+      return LocalEntityData.fromJson(maps[i]);
+    });
+  }
+}

+ 72 - 8
lib/db/files_db.dart

@@ -1,3 +1,4 @@
+import 'dart:developer' as dev;
 import 'dart:io' as io;
 
 import 'package:flutter/foundation.dart';
@@ -8,7 +9,7 @@ import 'package:photos/models/backup_status.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_load_result.dart';
 import 'package:photos/models/file_type.dart';
-import 'package:photos/models/location.dart';
+import 'package:photos/models/location/location.dart';
 import 'package:photos/models/magic_metadata.dart';
 import 'package:photos/utils/file_uploader_util.dart';
 import 'package:sqflite/sqflite.dart';
@@ -79,6 +80,7 @@ class FilesDB {
     ...createOnDeviceFilesAndPathCollection(),
     ...addFileSizeColumn(),
     ...updateIndexes(),
+    ...createEntityDataTable(),
   ];
 
   final dbConfig = MigrationConfig(
@@ -331,6 +333,20 @@ class FilesDB {
     ];
   }
 
+  static List<String> createEntityDataTable() {
+    return [
+      '''
+       CREATE TABLE IF NOT EXISTS entities (
+          id TEXT PRIMARY KEY NOT NULL,
+          type TEXT NOT NULL,
+          ownerID INTEGER NOT NULL,
+          data TEXT NOT NULL DEFAULT '{}',
+          updatedAt INTEGER NOT NULL
+      );
+      '''
+    ];
+  }
+
   static List<String> addFileSizeColumn() {
     return [
       '''
@@ -485,14 +501,20 @@ class FilesDB {
     bool? asc,
     int visibility = visibilityVisible,
     Set<int>? ignoredCollectionIDs,
+    bool onlyFilesWithLocation = false,
   }) async {
+    final stopWatch = Stopwatch()..start();
+
     final db = await instance.database;
     final order = (asc ?? false ? 'ASC' : 'DESC');
     final results = await db.query(
       filesTable,
-      where:
-          '$columnCreationTime >= ? AND $columnCreationTime <= ? AND  ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)'
-          ' AND $columnMMdVisibility = ?',
+      where: onlyFilesWithLocation
+          ? '$columnLatitude IS NOT NULL AND $columnLongitude IS NOT NULL AND ($columnLatitude IS NOT 0 OR $columnLongitude IS NOT 0)'
+              'AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND  ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)'
+              'AND $columnMMdVisibility = ?'
+          : '$columnCreationTime >= ? AND $columnCreationTime <= ? AND  ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)'
+              ' AND $columnMMdVisibility = ?',
       whereArgs: [startTime, endTime, ownerID, visibility],
       orderBy:
           '$columnCreationTime ' + order + ', $columnModificationTime ' + order,
@@ -501,6 +523,9 @@ class FilesDB {
     final files = convertToFiles(results);
     final List<File> deduplicatedFiles =
         _deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
+    dev.log(
+        "getAllPendingOrUploadedFiles time taken: ${stopWatch.elapsedMilliseconds} ms");
+    stopWatch.stop();
     return FileLoadResult(deduplicatedFiles, files.length == limit);
   }
 
@@ -511,14 +536,19 @@ class FilesDB {
     int? limit,
     bool? asc,
     Set<int>? ignoredCollectionIDs,
+    bool onlyFilesWithLocation = false,
   }) async {
     final db = await instance.database;
     final order = (asc ?? false ? 'ASC' : 'DESC');
     final results = await db.query(
       filesTable,
-      where:
-          '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?)  AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)'
-          ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))',
+      where: onlyFilesWithLocation
+          ? '$columnLatitude IS NOT NULL AND $columnLongitude IS NOT NULL AND ($columnLatitude IS NOT 0 OR $columnLongitude IS NOT 0)'
+              ' AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND '
+              '($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)'
+              ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))'
+          : '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?)  AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)'
+              ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))',
       whereArgs: [startTime, endTime, ownerID, visibilityVisible],
       orderBy:
           '$columnCreationTime ' + order + ', $columnModificationTime ' + order,
@@ -1376,6 +1406,33 @@ class FilesDB {
     return filesCount;
   }
 
+  Future<FileLoadResult> getAllUploadedAndSharedFiles(
+    int startTime,
+    int endTime, {
+    int? limit,
+    bool? asc,
+    Set<int>? ignoredCollectionIDs,
+  }) async {
+    final db = await instance.database;
+    final order = (asc ?? false ? 'ASC' : 'DESC');
+    final results = await db.query(
+      filesTable,
+      where:
+          '$columnLatitude IS NOT NULL AND $columnLongitude IS NOT NULL AND ($columnLatitude IS NOT 0 OR $columnLongitude IS NOT 0)'
+          ' AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND '
+          '($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)'
+          ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))',
+      whereArgs: [startTime, endTime, visibilityVisible],
+      orderBy:
+          '$columnCreationTime ' + order + ', $columnModificationTime ' + order,
+      limit: limit,
+    );
+    final files = convertToFiles(results);
+    final List<File> deduplicatedFiles =
+        _deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
+    return FileLoadResult(deduplicatedFiles, files.length == limit);
+  }
+
   Map<String, dynamic> _getRowForFile(File file) {
     final row = <String, dynamic>{};
     if (file.generatedID != null) {
@@ -1387,6 +1444,10 @@ class FilesDB {
     row[columnCollectionID] = file.collectionID ?? -1;
     row[columnTitle] = file.title;
     row[columnDeviceFolder] = file.deviceFolder;
+    // if (file.location == null ||
+    //     (file.location!.latitude == null && file.location!.longitude == null)) {
+    //   file.location = Location.randomLocation();
+    // }
     if (file.location != null) {
       row[columnLatitude] = file.location!.latitude;
       row[columnLongitude] = file.location!.longitude;
@@ -1471,7 +1532,10 @@ class FilesDB {
     file.title = row[columnTitle];
     file.deviceFolder = row[columnDeviceFolder];
     if (row[columnLatitude] != null && row[columnLongitude] != null) {
-      file.location = Location(row[columnLatitude], row[columnLongitude]);
+      file.location = Location(
+        latitude: row[columnLatitude],
+        longitude: row[columnLongitude],
+      );
     }
     file.fileType = getFileType(row[columnFileType]);
     file.creationTime = row[columnCreationTime];

+ 16 - 0
lib/events/location_tag_updated_event.dart

@@ -0,0 +1,16 @@
+import 'package:photos/events/event.dart';
+import "package:photos/models/local_entity_data.dart";
+import "package:photos/models/location_tag/location_tag.dart";
+
+class LocationTagUpdatedEvent extends Event {
+  final List<LocalEntity<LocationTag>>? updatedLocTagEntities;
+  final LocTagEventType type;
+
+  LocationTagUpdatedEvent(this.type, {this.updatedLocTagEntities});
+}
+
+enum LocTagEventType {
+  add,
+  update,
+  delete,
+}

+ 114 - 0
lib/gateways/entity_gw.dart

@@ -0,0 +1,114 @@
+import "package:dio/dio.dart";
+import "package:photos/models/api/entity/data.dart";
+import "package:photos/models/api/entity/key.dart";
+import "package:photos/models/api/entity/type.dart";
+
+class EntityGateway {
+  final Dio _enteDio;
+
+  EntityGateway(this._enteDio);
+
+  Future<void> createKey(
+    EntityType entityType,
+    String encKey,
+    String header,
+  ) async {
+    await _enteDio.post(
+      "/user-entity/key",
+      data: {
+        "type": entityType.typeToString(),
+        "encryptedKey": encKey,
+        "header": header,
+      },
+    );
+  }
+
+  Future<EntityKey> getKey(EntityType type) async {
+    try {
+      final response = await _enteDio.get(
+        "/user-entity/key",
+        queryParameters: {
+          "type": type.typeToString(),
+        },
+      );
+      return EntityKey.fromMap(response.data);
+    } on DioError catch (e) {
+      if (e.response != null && (e.response!.statusCode ?? 0) == 404) {
+        throw EntityKeyNotFound();
+      } else {
+        rethrow;
+      }
+    } catch (e) {
+      rethrow;
+    }
+  }
+
+  Future<EntityData> createEntity(
+    EntityType type,
+    String encryptedData,
+    String header,
+  ) async {
+    final response = await _enteDio.post(
+      "/user-entity/entity",
+      data: {
+        "encryptedData": encryptedData,
+        "header": header,
+        "type": type.typeToString(),
+      },
+    );
+    return EntityData.fromMap(response.data);
+  }
+
+  Future<EntityData> updateEntity(
+    EntityType type,
+    String id,
+    String encryptedData,
+    String header,
+  ) async {
+    final response = await _enteDio.put(
+      "/user-entity/entity",
+      data: {
+        "id": id,
+        "encryptedData": encryptedData,
+        "header": header,
+        "type": type.typeToString(),
+      },
+    );
+    return EntityData.fromMap(response.data);
+  }
+
+  Future<void> deleteEntity(
+    String id,
+  ) async {
+    await _enteDio.delete(
+      "/user-entity/entity",
+      queryParameters: {
+        "id": id,
+      },
+    );
+  }
+
+  Future<List<EntityData>> getDiff(
+    EntityType type,
+    int sinceTime, {
+    int limit = 500,
+  }) async {
+    final response = await _enteDio.get(
+      "/user-entity/entity/diff",
+      queryParameters: {
+        "sinceTime": sinceTime,
+        "limit": limit,
+        "type": type.typeToString(),
+      },
+    );
+    final List<EntityData> authEntities = <EntityData>[];
+    final diff = response.data["diff"] as List;
+    for (var entry in diff) {
+      final EntityData entity = EntityData.fromMap(entry);
+      authEntities.add(entity);
+    }
+    return authEntities;
+  }
+}
+
+class EntityKeyNotFound extends Error {}

+ 5 - 0
lib/main.dart

@@ -21,10 +21,12 @@ import "package:photos/l10n/l10n.dart";
 import 'package:photos/services/app_lifecycle_service.dart';
 import 'package:photos/services/billing_service.dart';
 import 'package:photos/services/collections_service.dart';
+import "package:photos/services/entity_service.dart";
 import 'package:photos/services/favorites_service.dart';
 import 'package:photos/services/feature_flag_service.dart';
 import 'package:photos/services/local_file_update_service.dart';
 import 'package:photos/services/local_sync_service.dart';
+import "package:photos/services/location_service.dart";
 import 'package:photos/services/memories_service.dart';
 import 'package:photos/services/notification_service.dart';
 import "package:photos/services/object_detection/object_detection_service.dart";
@@ -156,6 +158,9 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
   await NetworkClient.instance.init();
   await Configuration.instance.init();
   await UserService.instance.init();
+  await EntityService.instance.init();
+  LocationService.instance.init(preferences);
+
   await UserRemoteFlagService.instance.init();
   await UpdateService.instance.init();
   BillingService.instance.init();

+ 55 - 0
lib/models/api/entity/data.dart

@@ -0,0 +1,55 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+
+@immutable
+class EntityData {
+  final String id;
+
+  // encryptedData will be null for diff items when item is deleted
+  final String? encryptedData;
+  final String? header;
+  final bool isDeleted;
+  final int createdAt;
+  final int updatedAt;
+  final int userID;
+
+  const EntityData(
+    this.id,
+    this.userID,
+    this.encryptedData,
+    this.header,
+    this.isDeleted,
+    this.createdAt,
+    this.updatedAt,
+  );
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'userID': userID,
+      'encryptedData': encryptedData,
+      'header': header,
+      'isDeleted': isDeleted,
+      'createdAt': createdAt,
+      'updatedAt': updatedAt,
+    };
+  }
+
+  factory EntityData.fromMap(Map<String, dynamic> map) {
+    return EntityData(
+      map['id'],
+      map['userID'],
+      map['encryptedData'],
+      map['header'],
+      map['isDeleted']!,
+      map['createdAt']!,
+      map['updatedAt']!,
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory EntityData.fromJson(String source) =>
+      EntityData.fromMap(json.decode(source));
+}

+ 46 - 0
lib/models/api/entity/key.dart

@@ -0,0 +1,46 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import "package:photos/models/api/entity/type.dart";
+
+@immutable
+class EntityKey {
+  final int userID;
+  final String encryptedKey;
+  final EntityType type;
+  final String header;
+  final int createdAt;
+
+  const EntityKey(
+    this.userID,
+    this.encryptedKey,
+    this.header,
+    this.createdAt,
+    this.type,
+  );
+
+  Map<String, dynamic> toMap() {
+    return {
+      'userID': userID,
+      'type': type.typeToString(),
+      'encryptedKey': encryptedKey,
+      'header': header,
+      'createdAt': createdAt,
+    };
+  }
+
+  factory EntityKey.fromMap(Map<String, dynamic> map) {
+    return EntityKey(
+      map['userID']?.toInt() ?? 0,
+      map['encryptedKey']!,
+      map['header']!,
+      map['createdAt']?.toInt() ?? 0,
+      typeFromString(map['type']!),
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory EntityKey.fromJson(String source) =>
+      EntityKey.fromMap(json.decode(source));
+}

+ 26 - 0
lib/models/api/entity/type.dart

@@ -0,0 +1,26 @@
+import "package:flutter/foundation.dart";
+
+enum EntityType {
+  location,
+  unknown,
+}
+
+EntityType typeFromString(String type) {
+  switch (type) {
+    case "location":
+      return EntityType.location;
+  }
+  debugPrint("unexpected collection type $type");
+  return EntityType.unknown;
+}
+
+extension EntityTypeExtn on EntityType {
+  String typeToString() {
+    switch (this) {
+      case EntityType.location:
+        return "location";
+      case EntityType.unknown:
+        return "unknown";
+    }
+  }
+}

+ 4 - 3
lib/models/file.dart

@@ -8,7 +8,7 @@ import 'package:photos/core/configuration.dart';
 import 'package:photos/core/constants.dart';
 import 'package:photos/models/ente_file.dart';
 import 'package:photos/models/file_type.dart';
-import 'package:photos/models/location.dart';
+import 'package:photos/models/location/location.dart';
 import 'package:photos/models/magic_metadata.dart';
 import 'package:photos/services/feature_flag_service.dart';
 import 'package:photos/utils/date_time_util.dart';
@@ -72,7 +72,8 @@ class File extends EnteFile {
     file.localID = asset.id;
     file.title = asset.title;
     file.deviceFolder = pathName;
-    file.location = Location(asset.latitude, asset.longitude);
+    file.location =
+        Location(latitude: asset.latitude, longitude: asset.longitude);
     file.fileType = _fileTypeFromAsset(asset);
     file.creationTime = parseFileCreationTime(file.title, asset);
     file.modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
@@ -147,7 +148,7 @@ class File extends EnteFile {
     if (latitude == null || longitude == null) {
       location = null;
     } else {
-      location = Location(latitude, longitude);
+      location = Location(latitude: latitude, longitude: longitude);
     }
     fileType = getFileType(metadata["fileType"] ?? -1);
     fileSubType = metadata["subType"] ?? -1;

+ 11 - 1
lib/models/gallery_type.dart

@@ -9,7 +9,8 @@ enum GalleryType {
   // indicator for gallery view of collections shared with the user
   sharedCollection,
   ownedCollection,
-  searchResults
+  searchResults,
+  locationTag,
 }
 
 extension GalleyTypeExtension on GalleryType {
@@ -21,6 +22,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.ownedCollection:
       case GalleryType.searchResults:
       case GalleryType.favorite:
+      case GalleryType.locationTag:
         return true;
 
       case GalleryType.hidden:
@@ -45,6 +47,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.homepage:
       case GalleryType.trash:
       case GalleryType.sharedCollection:
+      case GalleryType.locationTag:
         return false;
     }
   }
@@ -59,6 +62,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.favorite:
       case GalleryType.localFolder:
       case GalleryType.uncategorized:
+      case GalleryType.locationTag:
         return true;
       case GalleryType.trash:
       case GalleryType.archive:
@@ -78,6 +82,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.archive:
       case GalleryType.hidden:
       case GalleryType.localFolder:
+      case GalleryType.locationTag:
         return true;
       case GalleryType.trash:
       case GalleryType.sharedCollection:
@@ -93,6 +98,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.favorite:
       case GalleryType.archive:
       case GalleryType.uncategorized:
+      case GalleryType.locationTag:
         return true;
       case GalleryType.hidden:
       case GalleryType.localFolder:
@@ -115,6 +121,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.archive:
       case GalleryType.localFolder:
       case GalleryType.trash:
+      case GalleryType.locationTag:
         return false;
     }
   }
@@ -133,6 +140,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.localFolder:
       case GalleryType.trash:
       case GalleryType.sharedCollection:
+      case GalleryType.locationTag:
         return false;
     }
   }
@@ -148,6 +156,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.searchResults:
       case GalleryType.archive:
       case GalleryType.uncategorized:
+      case GalleryType.locationTag:
         return true;
 
       case GalleryType.hidden:
@@ -169,6 +178,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.homepage:
       case GalleryType.searchResults:
       case GalleryType.uncategorized:
+      case GalleryType.locationTag:
         return true;
 
       case GalleryType.hidden:

+ 48 - 0
lib/models/local_entity_data.dart

@@ -0,0 +1,48 @@
+import "package:equatable/equatable.dart";
+import "package:photos/models/api/entity/type.dart";
+
+class LocalEntityData {
+  final String id;
+  final EntityType type;
+  final String data;
+  final int ownerID;
+  final int updatedAt;
+
+  LocalEntityData({
+    required this.id,
+    required this.type,
+    required this.data,
+    required this.ownerID,
+    required this.updatedAt,
+  });
+
+  Map<String, dynamic> toJson() {
+    return {
+      "id": id,
+      "type": type.typeToString(),
+      "data": data,
+      "ownerID": ownerID,
+      "updatedAt": updatedAt,
+    };
+  }
+
+  factory LocalEntityData.fromJson(Map<String, dynamic> json) {
+    return LocalEntityData(
+      id: json["id"],
+      type: typeFromString(json["type"]),
+      data: json["data"],
+      ownerID: json["ownerID"] as int,
+      updatedAt: json["updatedAt"] as int,
+    );
+  }
+}
+
+class LocalEntity<T> extends Equatable {
+  final T item;
+  final String id;
+
+  const LocalEntity(this.item, this.id);
+
+  @override
+  List<Object?> get props => [item, id];
+}

+ 0 - 9
lib/models/location.dart

@@ -1,9 +0,0 @@
-class Location {
-  final double? latitude;
-  final double? longitude;
-
-  Location(this.latitude, this.longitude);
-
-  @override
-  String toString() => 'Location(latitude: $latitude, longitude: $longitude)';
-}

+ 16 - 0
lib/models/location/location.dart

@@ -0,0 +1,16 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'location.freezed.dart';
+
+part 'location.g.dart';
+
+@freezed
+class Location with _$Location {
+  const factory Location({
+    required double? latitude,
+    required double? longitude,
+  }) = _Location;
+
+  factory Location.fromJson(Map<String, Object?> json) =>
+      _$LocationFromJson(json);
+}

+ 168 - 0
lib/models/location/location.freezed.dart

@@ -0,0 +1,168 @@
+// coverage:ignore-file
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'location.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+T _$identity<T>(T value) => value;
+
+final _privateConstructorUsedError = UnsupportedError(
+    'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
+
+Location _$LocationFromJson(Map<String, dynamic> json) {
+  return _Location.fromJson(json);
+}
+
+/// @nodoc
+mixin _$Location {
+  double? get latitude => throw _privateConstructorUsedError;
+  double? get longitude => throw _privateConstructorUsedError;
+
+  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
+  @JsonKey(ignore: true)
+  $LocationCopyWith<Location> get copyWith =>
+      throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class $LocationCopyWith<$Res> {
+  factory $LocationCopyWith(Location value, $Res Function(Location) then) =
+      _$LocationCopyWithImpl<$Res, Location>;
+  @useResult
+  $Res call({double? latitude, double? longitude});
+}
+
+/// @nodoc
+class _$LocationCopyWithImpl<$Res, $Val extends Location>
+    implements $LocationCopyWith<$Res> {
+  _$LocationCopyWithImpl(this._value, this._then);
+
+  // ignore: unused_field
+  final $Val _value;
+  // ignore: unused_field
+  final $Res Function($Val) _then;
+
+  @pragma('vm:prefer-inline')
+  @override
+  $Res call({
+    Object? latitude = freezed,
+    Object? longitude = freezed,
+  }) {
+    return _then(_value.copyWith(
+      latitude: freezed == latitude
+          ? _value.latitude
+          : latitude // ignore: cast_nullable_to_non_nullable
+              as double?,
+      longitude: freezed == longitude
+          ? _value.longitude
+          : longitude // ignore: cast_nullable_to_non_nullable
+              as double?,
+    ) as $Val);
+  }
+}
+
+/// @nodoc
+abstract class _$$_LocationCopyWith<$Res> implements $LocationCopyWith<$Res> {
+  factory _$$_LocationCopyWith(
+          _$_Location value, $Res Function(_$_Location) then) =
+      __$$_LocationCopyWithImpl<$Res>;
+  @override
+  @useResult
+  $Res call({double? latitude, double? longitude});
+}
+
+/// @nodoc
+class __$$_LocationCopyWithImpl<$Res>
+    extends _$LocationCopyWithImpl<$Res, _$_Location>
+    implements _$$_LocationCopyWith<$Res> {
+  __$$_LocationCopyWithImpl(
+      _$_Location _value, $Res Function(_$_Location) _then)
+      : super(_value, _then);
+
+  @pragma('vm:prefer-inline')
+  @override
+  $Res call({
+    Object? latitude = freezed,
+    Object? longitude = freezed,
+  }) {
+    return _then(_$_Location(
+      latitude: freezed == latitude
+          ? _value.latitude
+          : latitude // ignore: cast_nullable_to_non_nullable
+              as double?,
+      longitude: freezed == longitude
+          ? _value.longitude
+          : longitude // ignore: cast_nullable_to_non_nullable
+              as double?,
+    ));
+  }
+}
+
+/// @nodoc
+@JsonSerializable()
+class _$_Location implements _Location {
+  const _$_Location({required this.latitude, required this.longitude});
+
+  factory _$_Location.fromJson(Map<String, dynamic> json) =>
+      _$$_LocationFromJson(json);
+
+  @override
+  final double? latitude;
+  @override
+  final double? longitude;
+
+  @override
+  String toString() {
+    return 'Location(latitude: $latitude, longitude: $longitude)';
+  }
+
+  @override
+  bool operator ==(dynamic other) {
+    return identical(this, other) ||
+        (other.runtimeType == runtimeType &&
+            other is _$_Location &&
+            (identical(other.latitude, latitude) ||
+                other.latitude == latitude) &&
+            (identical(other.longitude, longitude) ||
+                other.longitude == longitude));
+  }
+
+  @JsonKey(ignore: true)
+  @override
+  int get hashCode => Object.hash(runtimeType, latitude, longitude);
+
+  @JsonKey(ignore: true)
+  @override
+  @pragma('vm:prefer-inline')
+  _$$_LocationCopyWith<_$_Location> get copyWith =>
+      __$$_LocationCopyWithImpl<_$_Location>(this, _$identity);
+
+  @override
+  Map<String, dynamic> toJson() {
+    return _$$_LocationToJson(
+      this,
+    );
+  }
+}
+
+abstract class _Location implements Location {
+  const factory _Location(
+      {required final double? latitude,
+      required final double? longitude}) = _$_Location;
+
+  factory _Location.fromJson(Map<String, dynamic> json) = _$_Location.fromJson;
+
+  @override
+  double? get latitude;
+  @override
+  double? get longitude;
+  @override
+  @JsonKey(ignore: true)
+  _$$_LocationCopyWith<_$_Location> get copyWith =>
+      throw _privateConstructorUsedError;
+}

+ 18 - 0
lib/models/location/location.g.dart

@@ -0,0 +1,18 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'location.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+_$_Location _$$_LocationFromJson(Map<String, dynamic> json) => _$_Location(
+      latitude: (json['latitude'] as num?)?.toDouble(),
+      longitude: (json['longitude'] as num?)?.toDouble(),
+    );
+
+Map<String, dynamic> _$$_LocationToJson(_$_Location instance) =>
+    <String, dynamic>{
+      'latitude': instance.latitude,
+      'longitude': instance.longitude,
+    };

+ 25 - 0
lib/models/location_tag/location_tag.dart

@@ -0,0 +1,25 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+import "package:photos/core/constants.dart";
+import 'package:photos/models/location/location.dart';
+
+part 'location_tag.freezed.dart';
+part 'location_tag.g.dart';
+
+@freezed
+class LocationTag with _$LocationTag {
+  const LocationTag._();
+  const factory LocationTag({
+    required String name,
+    required int radius,
+    required double aSquare,
+    required double bSquare,
+    required Location centerPoint,
+  }) = _LocationTag;
+
+  factory LocationTag.fromJson(Map<String, Object?> json) =>
+      _$LocationTagFromJson(json);
+
+  int get radiusIndex {
+    return radiusValues.indexOf(radius);
+  }
+}

+ 252 - 0
lib/models/location_tag/location_tag.freezed.dart

@@ -0,0 +1,252 @@
+// coverage:ignore-file
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'location_tag.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+T _$identity<T>(T value) => value;
+
+final _privateConstructorUsedError = UnsupportedError(
+    'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
+
+LocationTag _$LocationTagFromJson(Map<String, dynamic> json) {
+  return _LocationTag.fromJson(json);
+}
+
+/// @nodoc
+mixin _$LocationTag {
+  String get name => throw _privateConstructorUsedError;
+  int get radius => throw _privateConstructorUsedError;
+  double get aSquare => throw _privateConstructorUsedError;
+  double get bSquare => throw _privateConstructorUsedError;
+  Location get centerPoint => throw _privateConstructorUsedError;
+
+  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
+  @JsonKey(ignore: true)
+  $LocationTagCopyWith<LocationTag> get copyWith =>
+      throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class $LocationTagCopyWith<$Res> {
+  factory $LocationTagCopyWith(
+          LocationTag value, $Res Function(LocationTag) then) =
+      _$LocationTagCopyWithImpl<$Res, LocationTag>;
+  @useResult
+  $Res call(
+      {String name,
+      int radius,
+      double aSquare,
+      double bSquare,
+      Location centerPoint});
+
+  $LocationCopyWith<$Res> get centerPoint;
+}
+
+/// @nodoc
+class _$LocationTagCopyWithImpl<$Res, $Val extends LocationTag>
+    implements $LocationTagCopyWith<$Res> {
+  _$LocationTagCopyWithImpl(this._value, this._then);
+
+  // ignore: unused_field
+  final $Val _value;
+  // ignore: unused_field
+  final $Res Function($Val) _then;
+
+  @pragma('vm:prefer-inline')
+  @override
+  $Res call({
+    Object? name = null,
+    Object? radius = null,
+    Object? aSquare = null,
+    Object? bSquare = null,
+    Object? centerPoint = null,
+  }) {
+    return _then(_value.copyWith(
+      name: null == name
+          ? _value.name
+          : name // ignore: cast_nullable_to_non_nullable
+              as String,
+      radius: null == radius
+          ? _value.radius
+          : radius // ignore: cast_nullable_to_non_nullable
+              as int,
+      aSquare: null == aSquare
+          ? _value.aSquare
+          : aSquare // ignore: cast_nullable_to_non_nullable
+              as double,
+      bSquare: null == bSquare
+          ? _value.bSquare
+          : bSquare // ignore: cast_nullable_to_non_nullable
+              as double,
+      centerPoint: null == centerPoint
+          ? _value.centerPoint
+          : centerPoint // ignore: cast_nullable_to_non_nullable
+              as Location,
+    ) as $Val);
+  }
+
+  @override
+  @pragma('vm:prefer-inline')
+  $LocationCopyWith<$Res> get centerPoint {
+    return $LocationCopyWith<$Res>(_value.centerPoint, (value) {
+      return _then(_value.copyWith(centerPoint: value) as $Val);
+    });
+  }
+}
+
+/// @nodoc
+abstract class _$$_LocationTagCopyWith<$Res>
+    implements $LocationTagCopyWith<$Res> {
+  factory _$$_LocationTagCopyWith(
+          _$_LocationTag value, $Res Function(_$_LocationTag) then) =
+      __$$_LocationTagCopyWithImpl<$Res>;
+  @override
+  @useResult
+  $Res call(
+      {String name,
+      int radius,
+      double aSquare,
+      double bSquare,
+      Location centerPoint});
+
+  @override
+  $LocationCopyWith<$Res> get centerPoint;
+}
+
+/// @nodoc
+class __$$_LocationTagCopyWithImpl<$Res>
+    extends _$LocationTagCopyWithImpl<$Res, _$_LocationTag>
+    implements _$$_LocationTagCopyWith<$Res> {
+  __$$_LocationTagCopyWithImpl(
+      _$_LocationTag _value, $Res Function(_$_LocationTag) _then)
+      : super(_value, _then);
+
+  @pragma('vm:prefer-inline')
+  @override
+  $Res call({
+    Object? name = null,
+    Object? radius = null,
+    Object? aSquare = null,
+    Object? bSquare = null,
+    Object? centerPoint = null,
+  }) {
+    return _then(_$_LocationTag(
+      name: null == name
+          ? _value.name
+          : name // ignore: cast_nullable_to_non_nullable
+              as String,
+      radius: null == radius
+          ? _value.radius
+          : radius // ignore: cast_nullable_to_non_nullable
+              as int,
+      aSquare: null == aSquare
+          ? _value.aSquare
+          : aSquare // ignore: cast_nullable_to_non_nullable
+              as double,
+      bSquare: null == bSquare
+          ? _value.bSquare
+          : bSquare // ignore: cast_nullable_to_non_nullable
+              as double,
+      centerPoint: null == centerPoint
+          ? _value.centerPoint
+          : centerPoint // ignore: cast_nullable_to_non_nullable
+              as Location,
+    ));
+  }
+}
+
+/// @nodoc
+@JsonSerializable()
+class _$_LocationTag extends _LocationTag {
+  const _$_LocationTag(
+      {required this.name,
+      required this.radius,
+      required this.aSquare,
+      required this.bSquare,
+      required this.centerPoint})
+      : super._();
+
+  factory _$_LocationTag.fromJson(Map<String, dynamic> json) =>
+      _$$_LocationTagFromJson(json);
+
+  @override
+  final String name;
+  @override
+  final int radius;
+  @override
+  final double aSquare;
+  @override
+  final double bSquare;
+  @override
+  final Location centerPoint;
+
+  @override
+  String toString() {
+    return 'LocationTag(name: $name, radius: $radius, aSquare: $aSquare, bSquare: $bSquare, centerPoint: $centerPoint)';
+  }
+
+  @override
+  bool operator ==(dynamic other) {
+    return identical(this, other) ||
+        (other.runtimeType == runtimeType &&
+            other is _$_LocationTag &&
+            (identical(other.name, name) || other.name == name) &&
+            (identical(other.radius, radius) || other.radius == radius) &&
+            (identical(other.aSquare, aSquare) || other.aSquare == aSquare) &&
+            (identical(other.bSquare, bSquare) || other.bSquare == bSquare) &&
+            (identical(other.centerPoint, centerPoint) ||
+                other.centerPoint == centerPoint));
+  }
+
+  @JsonKey(ignore: true)
+  @override
+  int get hashCode =>
+      Object.hash(runtimeType, name, radius, aSquare, bSquare, centerPoint);
+
+  @JsonKey(ignore: true)
+  @override
+  @pragma('vm:prefer-inline')
+  _$$_LocationTagCopyWith<_$_LocationTag> get copyWith =>
+      __$$_LocationTagCopyWithImpl<_$_LocationTag>(this, _$identity);
+
+  @override
+  Map<String, dynamic> toJson() {
+    return _$$_LocationTagToJson(
+      this,
+    );
+  }
+}
+
+abstract class _LocationTag extends LocationTag {
+  const factory _LocationTag(
+      {required final String name,
+      required final int radius,
+      required final double aSquare,
+      required final double bSquare,
+      required final Location centerPoint}) = _$_LocationTag;
+  const _LocationTag._() : super._();
+
+  factory _LocationTag.fromJson(Map<String, dynamic> json) =
+      _$_LocationTag.fromJson;
+
+  @override
+  String get name;
+  @override
+  int get radius;
+  @override
+  double get aSquare;
+  @override
+  double get bSquare;
+  @override
+  Location get centerPoint;
+  @override
+  @JsonKey(ignore: true)
+  _$$_LocationTagCopyWith<_$_LocationTag> get copyWith =>
+      throw _privateConstructorUsedError;
+}

+ 26 - 0
lib/models/location_tag/location_tag.g.dart

@@ -0,0 +1,26 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'location_tag.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+_$_LocationTag _$$_LocationTagFromJson(Map<String, dynamic> json) =>
+    _$_LocationTag(
+      name: json['name'] as String,
+      radius: json['radius'] as int,
+      aSquare: (json['aSquare'] as num).toDouble(),
+      bSquare: (json['bSquare'] as num).toDouble(),
+      centerPoint:
+          Location.fromJson(json['centerPoint'] as Map<String, dynamic>),
+    );
+
+Map<String, dynamic> _$$_LocationTagToJson(_$_LocationTag instance) =>
+    <String, dynamic>{
+      'name': instance.name,
+      'radius': instance.radius,
+      'aSquare': instance.aSquare,
+      'bSquare': instance.bSquare,
+      'centerPoint': instance.centerPoint,
+    };

+ 1 - 1
lib/models/search/search_result.dart

@@ -23,5 +23,5 @@ enum ResultType {
   fileType,
   fileExtension,
   fileCaption,
-  event
+  event,
 }

+ 4 - 0
lib/models/typedefs.dart

@@ -1,7 +1,11 @@
 import 'dart:async';
 
+import "package:photos/models/location/location.dart";
+
 typedef FutureVoidCallback = Future<void> Function();
 typedef BoolCallBack = bool Function();
 typedef FutureVoidCallbackParamStr = Future<void> Function(String);
 typedef VoidCallbackParamStr = void Function(String);
 typedef FutureOrVoidCallback = FutureOr<void> Function();
+typedef VoidCallbackParamInt = void Function(int);
+typedef VoidCallbackParamLocation = void Function(Location);

+ 192 - 0
lib/services/entity_service.dart

@@ -0,0 +1,192 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter_sodium/flutter_sodium.dart';
+import 'package:logging/logging.dart';
+import "package:photos/core/configuration.dart";
+import "package:photos/core/network/network.dart";
+import "package:photos/db/entities_db.dart";
+import "package:photos/db/files_db.dart";
+import "package:photos/gateways/entity_gw.dart";
+import "package:photos/models/api/entity/data.dart";
+import "package:photos/models/api/entity/key.dart";
+import "package:photos/models/api/entity/type.dart";
+import "package:photos/models/local_entity_data.dart";
+import "package:photos/utils/crypto_util.dart";
+import 'package:shared_preferences/shared_preferences.dart';
+
+class EntityService {
+  static const int fetchLimit = 500;
+  final _logger = Logger((EntityService).toString());
+  final _config = Configuration.instance;
+  late SharedPreferences _prefs;
+  late EntityGateway _gateway;
+  late FilesDB _db;
+
+  EntityService._privateConstructor();
+
+  static final EntityService instance = EntityService._privateConstructor();
+
+  Future<void> init() async {
+    _prefs = await SharedPreferences.getInstance();
+    _db = FilesDB.instance;
+    _gateway = EntityGateway(NetworkClient.instance.enteDio);
+  }
+
+  String _getEntityKeyPrefix(EntityType type) {
+    return "entity_key_" + type.typeToString();
+  }
+
+  String _getEntityHeaderPrefix(EntityType type) {
+    return "entity_key_header_" + type.typeToString();
+  }
+
+  String _getEntityLastSyncTimePrefix(EntityType type) {
+    return "entity_last_sync_time_" + type.typeToString();
+  }
+
+  Future<List<LocalEntityData>> getEntities(EntityType type) async {
+    return await _db.getEntities(type);
+  }
+
+  Future<LocalEntityData> addOrUpdate(
+    EntityType type,
+    String plainText, {
+    String? id,
+  }) async {
+    final key = await getOrCreateEntityKey(type);
+    final encryptedKeyData = await CryptoUtil.encryptChaCha(
+      utf8.encode(plainText) as Uint8List,
+      key,
+    );
+    final String encryptedData =
+        Sodium.bin2base64(encryptedKeyData.encryptedData!);
+    final String header = Sodium.bin2base64(encryptedKeyData.header!);
+    debugPrint("Adding entity of type: " + type.typeToString());
+    final EntityData data = id == null
+        ? await _gateway.createEntity(type, encryptedData, header)
+        : await _gateway.updateEntity(type, id, encryptedData, header);
+    final LocalEntityData localData = LocalEntityData(
+      id: data.id,
+      type: type,
+      data: plainText,
+      ownerID: data.userID,
+      updatedAt: data.updatedAt,
+    );
+    await _db.upsertEntities([localData]);
+    syncEntities().ignore();
+    return localData;
+  }
+
+  Future<void> deleteEntry(String id) async {
+    await _gateway.deleteEntity(id);
+    await _db.deleteEntities([id]);
+  }
+
+  Future<void> syncEntities() async {
+    try {
+      await _remoteToLocalSync(EntityType.location);
+    } catch (e) {
+      _logger.severe("Failed to sync entities", e);
+    }
+  }
+
+  Future<void> _remoteToLocalSync(EntityType type) async {
+    final int lastSyncTime =
+        _prefs.getInt(_getEntityLastSyncTimePrefix(type)) ?? 0;
+    final List<EntityData> result = await _gateway.getDiff(
+      type,
+      lastSyncTime,
+      limit: fetchLimit,
+    );
+    if (result.isEmpty) {
+      debugPrint("No $type entries to sync");
+      return;
+    }
+    final bool hasMoreItems = result.length == fetchLimit;
+    _logger.info("${result.length} entries of type $type fetched");
+    final maxSyncTime = result.map((e) => e.updatedAt).reduce(max);
+    final List<String> deletedIDs =
+        result.where((element) => element.isDeleted).map((e) => e.id).toList();
+    if (deletedIDs.isNotEmpty) {
+      _logger.info("${deletedIDs.length} entries of type $type deleted");
+      await _db.deleteEntities(deletedIDs);
+    }
+    result.removeWhere((element) => element.isDeleted);
+    if (result.isNotEmpty) {
+      final entityKey = await getOrCreateEntityKey(type);
+      final List<LocalEntityData> entities = [];
+      for (EntityData e in result) {
+        try {
+          final decryptedValue = await CryptoUtil.decryptChaCha(
+            Sodium.base642bin(e.encryptedData!),
+            entityKey,
+            Sodium.base642bin(e.header!),
+          );
+          final String plainText = utf8.decode(decryptedValue);
+          entities.add(
+            LocalEntityData(
+              id: e.id,
+              type: type,
+              data: plainText,
+              ownerID: e.userID,
+              updatedAt: e.updatedAt,
+            ),
+          );
+        } catch (e, s) {
+          _logger.severe("Failed to decrypted data for key $type", e, s);
+        }
+      }
+      if (entities.isNotEmpty) {
+        await _db.upsertEntities(entities);
+      }
+    }
+    _prefs.setInt(_getEntityLastSyncTimePrefix(type), maxSyncTime);
+    if (hasMoreItems) {
+      _logger.info("Diff limit reached, pulling again");
+      await _remoteToLocalSync(type);
+    }
+  }
+
+  Future<Uint8List> getOrCreateEntityKey(EntityType type) async {
+    late String encryptedKey;
+    late String header;
+    try {
+      if (_prefs.containsKey(_getEntityKeyPrefix(type)) &&
+          _prefs.containsKey(_getEntityHeaderPrefix(type))) {
+        encryptedKey = _prefs.getString(_getEntityKeyPrefix(type))!;
+        header = _prefs.getString(_getEntityHeaderPrefix(type))!;
+      } else {
+        final EntityKey response = await _gateway.getKey(type);
+        encryptedKey = response.encryptedKey;
+        header = response.header;
+        _prefs.setString(_getEntityKeyPrefix(type), encryptedKey);
+        _prefs.setString(_getEntityHeaderPrefix(type), header);
+      }
+      final entityKey = CryptoUtil.decryptSync(
+        Sodium.base642bin(encryptedKey),
+        _config.getKey()!,
+        Sodium.base642bin(header),
+      );
+      return entityKey;
+    } on EntityKeyNotFound catch (e) {
+      _logger.info(
+          "EntityKeyNotFound generating key for type $type ${e.stackTrace}");
+      final key = CryptoUtil.generateKey();
+      final encryptedKeyData = CryptoUtil.encryptSync(key, _config.getKey()!);
+      await _gateway.createKey(
+        type,
+        Sodium.bin2base64(encryptedKeyData.encryptedData!),
+        Sodium.bin2base64(encryptedKeyData.nonce!),
+      );
+      _prefs.setString(_getEntityKeyPrefix(type), encryptedKey);
+      _prefs.setString(_getEntityHeaderPrefix(type), header);
+      return key;
+    } catch (e, s) {
+      _logger.severe("Failed to getOrCreateKey for type $type", e, s);
+      rethrow;
+    }
+  }
+}

+ 11 - 0
lib/services/files_service.dart

@@ -6,8 +6,10 @@ import 'package:photos/core/network/network.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/extensions/list.dart';
 import 'package:photos/models/file.dart';
+import "package:photos/models/file_load_result.dart";
 import 'package:photos/models/magic_metadata.dart';
 import 'package:photos/services/file_magic_service.dart';
+import "package:photos/services/ignored_files_service.dart";
 import 'package:photos/utils/date_time_util.dart';
 
 class FilesService {
@@ -94,6 +96,15 @@ class FilesService {
     );
     return timeResult?.microsecondsSinceEpoch;
   }
+
+  Future<void> removeIgnoredFiles(Future<FileLoadResult> result) async {
+    final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
+    (await result).files.removeWhere(
+          (f) =>
+              f.uploadedFileID == null &&
+              IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f),
+        );
+  }
 }
 
 enum EditTimeSource {

+ 212 - 0
lib/services/location_service.dart

@@ -0,0 +1,212 @@
+import "dart:convert";
+import "dart:math";
+
+import "package:logging/logging.dart";
+import "package:photos/core/constants.dart";
+import "package:photos/core/event_bus.dart";
+import "package:photos/events/location_tag_updated_event.dart";
+import "package:photos/models/api/entity/type.dart";
+import "package:photos/models/local_entity_data.dart";
+import "package:photos/models/location/location.dart";
+import 'package:photos/models/location_tag/location_tag.dart';
+import "package:photos/services/entity_service.dart";
+import "package:shared_preferences/shared_preferences.dart";
+
+class LocationService {
+  late SharedPreferences prefs;
+  final Logger _logger = Logger((LocationService).toString());
+
+  LocationService._privateConstructor();
+
+  static final LocationService instance = LocationService._privateConstructor();
+
+  void init(SharedPreferences preferences) {
+    prefs = preferences;
+  }
+
+  Future<Iterable<LocalEntity<LocationTag>>> _getStoredLocationTags() async {
+    final data = await EntityService.instance.getEntities(EntityType.location);
+    return data.map(
+      (e) => LocalEntity(LocationTag.fromJson(json.decode(e.data)), e.id),
+    );
+  }
+
+  Future<Iterable<LocalEntity<LocationTag>>> getLocationTags() {
+    return _getStoredLocationTags();
+  }
+
+  Future<void> addLocation(
+    String location,
+    Location centerPoint,
+    int radius,
+  ) async {
+    //The area enclosed by the location tag will be a circle on a 3D spherical
+    //globe and an ellipse on a 2D Mercator projection (2D map)
+    //a & b are the semi-major and semi-minor axes of the ellipse
+    //Converting the unit from kilometers to degrees for a and b as that is
+    //the unit on the caritesian plane
+
+    final a =
+        (radius * _scaleFactor(centerPoint.latitude!)) / kilometersPerDegree;
+    final b = radius / kilometersPerDegree;
+    final locationTag = LocationTag(
+      name: location,
+      radius: radius,
+      aSquare: a * a,
+      bSquare: b * b,
+      centerPoint: centerPoint,
+    );
+    await EntityService.instance
+        .addOrUpdate(EntityType.location, json.encode(locationTag.toJson()));
+    Bus.instance.fire(LocationTagUpdatedEvent(LocTagEventType.add));
+  }
+
+  ///The area bounded by the location tag becomes more elliptical with increase
+  ///in the magnitude of the latitude on the caritesian plane. When latitude is
+  ///0 degrees, the ellipse is a circle with a = b = r. When latitude incrases,
+  ///the major axis (a) has to be scaled by the secant of the latitude.
+  double _scaleFactor(double lat) {
+    return 1 / cos(lat * (pi / 180));
+  }
+
+  Future<List<LocalEntity<LocationTag>>> enclosingLocationTags(
+    Location fileCoordinates,
+  ) async {
+    try {
+      final result = List<LocalEntity<LocationTag>>.of([]);
+      final locationTagEntities = await getLocationTags();
+      for (LocalEntity<LocationTag> locationTagEntity in locationTagEntities) {
+        final locationTag = locationTagEntity.item;
+        final x = fileCoordinates.latitude! - locationTag.centerPoint.latitude!;
+        final y =
+            fileCoordinates.longitude! - locationTag.centerPoint.longitude!;
+        if ((x * x) / (locationTag.aSquare) + (y * y) / (locationTag.bSquare) <=
+            1) {
+          result.add(
+            locationTagEntity,
+          );
+        }
+      }
+      return result;
+    } catch (e, s) {
+      _logger.severe("Failed to get enclosing location tags", e, s);
+      rethrow;
+    }
+  }
+
+  bool isFileInsideLocationTag(
+    Location centerPoint,
+    Location fileCoordinates,
+    int radius,
+  ) {
+    final a =
+        (radius * _scaleFactor(centerPoint.latitude!)) / kilometersPerDegree;
+    final b = radius / kilometersPerDegree;
+    final x = centerPoint.latitude! - fileCoordinates.latitude!;
+    final y = centerPoint.longitude! - fileCoordinates.longitude!;
+    if ((x * x) / (a * a) + (y * y) / (b * b) <= 1) {
+      return true;
+    }
+    return false;
+  }
+
+  String convertLocationToDMS(Location centerPoint) {
+    final lat = centerPoint.latitude!;
+    final long = centerPoint.longitude!;
+    final latRef = lat >= 0 ? "N" : "S";
+    final longRef = long >= 0 ? "E" : "W";
+    final latDMS = convertCoordinateToDMS(lat.abs());
+    final longDMS = convertCoordinateToDMS(long.abs());
+    return "${latDMS[0]}°${latDMS[1]}'${latDMS[2]}\"$latRef, ${longDMS[0]}°${longDMS[1]}'${longDMS[2]}\"$longRef";
+  }
+
+  List<int> convertCoordinateToDMS(double coordinate) {
+    final degrees = coordinate.floor();
+    final minutes = ((coordinate - degrees) * 60).floor();
+    final seconds = ((coordinate - degrees - minutes / 60) * 3600).floor();
+    return [degrees, minutes, seconds];
+  }
+
+  ///Will only update if there is a change in the locationTag's properties
+  Future<void> updateLocationTag({
+    required LocalEntity<LocationTag> locationTagEntity,
+    int? newRadius,
+    Location? newCenterPoint,
+    String? newName,
+  }) async {
+    try {
+      final radius = newRadius ?? locationTagEntity.item.radius;
+      final centerPoint = newCenterPoint ?? locationTagEntity.item.centerPoint;
+      final name = newName ?? locationTagEntity.item.name;
+
+      final locationTag = locationTagEntity.item;
+      //Exit if there is no change in locationTag's properties
+      if (radius == locationTag.radius &&
+          centerPoint == locationTag.centerPoint &&
+          name == locationTag.name) {
+        return;
+      }
+      final a =
+          (radius * _scaleFactor(centerPoint.latitude!)) / kilometersPerDegree;
+      final b = radius / kilometersPerDegree;
+      final updatedLoationTag = locationTagEntity.item.copyWith(
+        centerPoint: centerPoint,
+        aSquare: a * a,
+        bSquare: b * b,
+        radius: radius,
+        name: name,
+      );
+
+      await EntityService.instance.addOrUpdate(
+        EntityType.location,
+        json.encode(updatedLoationTag.toJson()),
+        id: locationTagEntity.id,
+      );
+      Bus.instance.fire(
+        LocationTagUpdatedEvent(
+          LocTagEventType.update,
+          updatedLocTagEntities: [
+            LocalEntity(updatedLoationTag, locationTagEntity.id)
+          ],
+        ),
+      );
+    } catch (e, s) {
+      _logger.severe("Failed to update location tag", e, s);
+      rethrow;
+    }
+  }
+
+  Future<void> deleteLocationTag(String locTagEntityId) async {
+    try {
+      await EntityService.instance.deleteEntry(
+        locTagEntityId,
+      );
+      Bus.instance.fire(
+        LocationTagUpdatedEvent(
+          LocTagEventType.delete,
+        ),
+      );
+    } catch (e, s) {
+      _logger.severe("Failed to delete location tag", e, s);
+      rethrow;
+    }
+  }
+}
+
+class GPSData {
+  final String latRef;
+  final List<double> lat;
+  final String longRef;
+  final List<double> long;
+
+  GPSData(this.latRef, this.lat, this.longRef, this.long);
+
+  Location toLocationObj() {
+    final latSign = latRef == "N" ? 1 : -1;
+    final longSign = longRef == "E" ? 1 : -1;
+    return Location(
+      latitude: latSign * lat[0] + lat[1] / 60 + lat[2] / 3600,
+      longitude: longSign * long[0] + long[1] / 60 + long[2] / 3600,
+    );
+  }
+}

+ 47 - 61
lib/services/search_service.dart

@@ -9,12 +9,12 @@ import 'package:photos/models/collection.dart';
 import 'package:photos/models/collection_items.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_type.dart';
-import 'package:photos/models/location.dart';
+import "package:photos/models/location_tag/location_tag.dart";
 import 'package:photos/models/search/album_search_result.dart';
 import 'package:photos/models/search/generic_search_result.dart';
-import 'package:photos/models/search/location_api_response.dart';
 import 'package:photos/models/search/search_result.dart';
 import 'package:photos/services/collections_service.dart';
+import "package:photos/services/location_service.dart";
 import 'package:photos/utils/date_time_util.dart';
 import 'package:tuple/tuple.dart';
 
@@ -53,46 +53,6 @@ class SearchService {
     _cachedFilesFuture = null;
   }
 
-  Future<List<GenericSearchResult>> getLocationSearchResults(
-    String query,
-  ) async {
-    final List<GenericSearchResult> searchResults = [];
-    try {
-      final List<File> allFiles = await _getAllFiles();
-      // This code used an deprecated API earlier. We've retained the
-      // scaffolding for when we implement a client side location search, and
-      // meanwhile have replaced the API response.data with an empty map here.
-      final matchedLocationSearchResults = LocationApiResponse.fromMap({});
-
-      for (var locationData in matchedLocationSearchResults.results) {
-        final List<File> filesInLocation = [];
-
-        for (var file in allFiles) {
-          if (_isValidLocation(file.location) &&
-              _isLocationWithinBounds(file.location!, locationData)) {
-            filesInLocation.add(file);
-          }
-        }
-        filesInLocation.sort(
-          (first, second) =>
-              second.creationTime!.compareTo(first.creationTime!),
-        );
-        if (filesInLocation.isNotEmpty) {
-          searchResults.add(
-            GenericSearchResult(
-              ResultType.location,
-              locationData.place,
-              filesInLocation,
-            ),
-          );
-        }
-      }
-    } catch (e) {
-      _logger.severe(e);
-    }
-    return searchResults;
-  }
-
   // getFilteredCollectionsWithThumbnail removes deleted or archived or
   // collections which don't have a file from search result
   Future<List<AlbumSearchResult>> getCollectionSearchResults(
@@ -263,6 +223,51 @@ class SearchService {
     return searchResults;
   }
 
+  Future<List<GenericSearchResult>> getLocationResults(
+    String query,
+  ) async {
+    final locations =
+        (await LocationService.instance.getLocationTags()).map((e) => e.item);
+    final Map<LocationTag, List<File>> result = {};
+
+    final List<GenericSearchResult> searchResults = [];
+
+    for (LocationTag tag in locations) {
+      if (tag.name.toLowerCase().contains(query.toLowerCase())) {
+        result[tag] = [];
+      }
+    }
+    if (result.isEmpty) {
+      return searchResults;
+    }
+    final allFiles = await _getAllFiles();
+    for (File file in allFiles) {
+      if (file.hasLocation) {
+        for (LocationTag tag in result.keys) {
+          if (LocationService.instance.isFileInsideLocationTag(
+            tag.centerPoint,
+            file.location!,
+            tag.radius,
+          )) {
+            result[tag]!.add(file);
+          }
+        }
+      }
+    }
+    for (MapEntry<LocationTag, List<File>> entry in result.entries) {
+      if (entry.value.isNotEmpty) {
+        searchResults.add(
+          GenericSearchResult(
+            ResultType.location,
+            entry.key.name,
+            entry.value,
+          ),
+        );
+      }
+    }
+    return searchResults;
+  }
+
   Future<List<GenericSearchResult>> getMonthSearchResults(String query) async {
     final List<GenericSearchResult> searchResults = [];
     for (var month in _getMatchingMonths(query)) {
@@ -363,25 +368,6 @@ class SearchService {
     return durationsOfMonthInEveryYear;
   }
 
-  bool _isValidLocation(Location? location) {
-    return location != null &&
-        location.latitude != null &&
-        location.latitude != 0 &&
-        location.longitude != null &&
-        location.longitude != 0;
-  }
-
-  bool _isLocationWithinBounds(
-    Location location,
-    LocationDataFromResponse locationData,
-  ) {
-    //format returned by the api is [lng,lat,lng,lat] where indexes 0 & 1 are southwest and 2 & 3 northeast
-    return location.longitude! > locationData.bbox[0] &&
-        location.latitude! > locationData.bbox[1] &&
-        location.longitude! < locationData.bbox[2] &&
-        location.latitude! < locationData.bbox[3];
-  }
-
   List<Tuple3<int, MonthData, int?>> _getPossibleEventDate(String query) {
     final List<Tuple3<int, MonthData, int?>> possibleEvents = [];
     if (query.trim().isEmpty) {

+ 77 - 0
lib/states/location_screen_state.dart

@@ -0,0 +1,77 @@
+import "dart:async";
+
+import "package:flutter/material.dart";
+import "package:photos/core/event_bus.dart";
+import "package:photos/events/location_tag_updated_event.dart";
+import "package:photos/models/local_entity_data.dart";
+import 'package:photos/models/location_tag/location_tag.dart';
+
+class LocationScreenStateProvider extends StatefulWidget {
+  final LocalEntity<LocationTag> locationTagEntity;
+  final Widget child;
+  const LocationScreenStateProvider(
+    this.locationTagEntity,
+    this.child, {
+    super.key,
+  });
+
+  @override
+  State<LocationScreenStateProvider> createState() =>
+      _LocationScreenStateProviderState();
+}
+
+class _LocationScreenStateProviderState
+    extends State<LocationScreenStateProvider> {
+  late LocalEntity<LocationTag> _locationTagEntity;
+  late final StreamSubscription _locTagUpdateListener;
+  @override
+  void initState() {
+    _locationTagEntity = widget.locationTagEntity;
+    _locTagUpdateListener =
+        Bus.instance.on<LocationTagUpdatedEvent>().listen((event) {
+      if (event.type == LocTagEventType.update) {
+        setState(() {
+          _locationTagEntity = event.updatedLocTagEntities!.first;
+        });
+      }
+    });
+    super.initState();
+  }
+
+  @override
+  dispose() {
+    _locTagUpdateListener.cancel();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return InheritedLocationScreenState(
+      _locationTagEntity,
+      child: widget.child,
+    );
+  }
+}
+
+class InheritedLocationScreenState extends InheritedWidget {
+  final LocalEntity<LocationTag> locationTagEntity;
+  const InheritedLocationScreenState(
+    this.locationTagEntity, {
+    super.key,
+    required super.child,
+  });
+
+  //This is used to show loading state when memory count is beign computed and to
+  //show count after computation.
+  static final memoryCountNotifier = ValueNotifier<int?>(null);
+
+  static InheritedLocationScreenState of(BuildContext context) {
+    return context
+        .dependOnInheritedWidgetOfExactType<InheritedLocationScreenState>()!;
+  }
+
+  @override
+  bool updateShouldNotify(covariant InheritedLocationScreenState oldWidget) {
+    return oldWidget.locationTagEntity != locationTagEntity;
+  }
+}

+ 132 - 0
lib/states/location_state.dart

@@ -0,0 +1,132 @@
+import "dart:async";
+
+import "package:flutter/material.dart";
+import "package:photos/core/constants.dart";
+import "package:photos/core/event_bus.dart";
+import "package:photos/events/location_tag_updated_event.dart";
+import "package:photos/models/local_entity_data.dart";
+import "package:photos/models/location/location.dart";
+import "package:photos/models/location_tag/location_tag.dart";
+import "package:photos/models/typedefs.dart";
+import "package:photos/utils/debouncer.dart";
+
+class LocationTagStateProvider extends StatefulWidget {
+  final LocalEntity<LocationTag>? locationTagEntity;
+  final Location? centerPoint;
+  final Widget child;
+  const LocationTagStateProvider(
+    this.child, {
+    this.centerPoint,
+    this.locationTagEntity,
+    super.key,
+  });
+
+  @override
+  State<LocationTagStateProvider> createState() =>
+      _LocationTagStateProviderState();
+}
+
+class _LocationTagStateProviderState extends State<LocationTagStateProvider> {
+  int _selectedRaduisIndex = defaultRadiusValueIndex;
+  late Location? _centerPoint;
+  late LocalEntity<LocationTag>? _locationTagEntity;
+  final Debouncer _selectedRadiusDebouncer =
+      Debouncer(const Duration(milliseconds: 300));
+  late final StreamSubscription _locTagEntityListener;
+  @override
+  void initState() {
+    _locationTagEntity = widget.locationTagEntity;
+    _centerPoint = widget.centerPoint;
+    assert(_centerPoint != null || _locationTagEntity != null);
+    _centerPoint = _locationTagEntity?.item.centerPoint ?? _centerPoint!;
+    _selectedRaduisIndex =
+        _locationTagEntity?.item.radiusIndex ?? defaultRadiusValueIndex;
+    _locTagEntityListener =
+        Bus.instance.on<LocationTagUpdatedEvent>().listen((event) {
+      _locationTagUpdateListener(event);
+    });
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    _locTagEntityListener.cancel();
+    super.dispose();
+  }
+
+  void _locationTagUpdateListener(LocationTagUpdatedEvent event) {
+    if (event.type == LocTagEventType.update) {
+      if (event.updatedLocTagEntities!.first.id == _locationTagEntity!.id) {
+        //Update state when locationTag is updated.
+        setState(() {
+          final updatedLocTagEntity = event.updatedLocTagEntities!.first;
+          _selectedRaduisIndex = updatedLocTagEntity.item.radiusIndex;
+          _centerPoint = updatedLocTagEntity.item.centerPoint;
+          _locationTagEntity = updatedLocTagEntity;
+        });
+      }
+    }
+  }
+
+  void _updateSelectedIndex(int index) {
+    _selectedRadiusDebouncer.cancelDebounce();
+    _selectedRadiusDebouncer.run(() async {
+      if (mounted) {
+        setState(() {
+          _selectedRaduisIndex = index;
+        });
+      }
+    });
+  }
+
+  void _updateCenterPoint(Location centerPoint) {
+    if (mounted) {
+      setState(() {
+        _centerPoint = centerPoint;
+      });
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return InheritedLocationTagData(
+      _selectedRaduisIndex,
+      _centerPoint!,
+      _updateSelectedIndex,
+      _locationTagEntity,
+      _updateCenterPoint,
+      child: widget.child,
+    );
+  }
+}
+
+///This InheritedWidget's state is used in add & edit location sheets
+class InheritedLocationTagData extends InheritedWidget {
+  final int selectedRadiusIndex;
+  final Location centerPoint;
+  //locationTag is null when we are creating a new location tag in add location sheet
+  final LocalEntity<LocationTag>? locationTagEntity;
+  final VoidCallbackParamInt updateSelectedIndex;
+  final VoidCallbackParamLocation updateCenterPoint;
+  const InheritedLocationTagData(
+    this.selectedRadiusIndex,
+    this.centerPoint,
+    this.updateSelectedIndex,
+    this.locationTagEntity,
+    this.updateCenterPoint, {
+    required super.child,
+    super.key,
+  });
+
+  static InheritedLocationTagData of(BuildContext context) {
+    return context
+        .dependOnInheritedWidgetOfExactType<InheritedLocationTagData>()!;
+  }
+
+  @override
+  bool updateShouldNotify(InheritedLocationTagData oldWidget) {
+    return oldWidget.selectedRadiusIndex != selectedRadiusIndex ||
+        oldWidget.centerPoint != centerPoint ||
+        oldWidget.locationTagEntity != locationTagEntity;
+  }
+}

+ 1 - 0
lib/theme/colors.dart

@@ -216,6 +216,7 @@ const Color tabIconDark = Color.fromRGBO(255, 255, 255, 0.80);
 // Fixed Colors
 
 const Color fixedStrokeMutedWhite = Color.fromRGBO(255, 255, 255, 0.50);
+const Color strokeSolidMutedLight = Color.fromRGBO(147, 147, 147, 1);
 
 const Color _primary700 = Color.fromRGBO(0, 179, 60, 1);
 const Color _primary500 = Color.fromRGBO(29, 185, 84, 1);

+ 1 - 1
lib/ui/actions/file/file_actions.dart

@@ -131,7 +131,7 @@ Future<void> showSingleFileDeleteSheet(
   }
 }
 
-Future<void> showInfoSheet(BuildContext context, File file) async {
+Future<void> showDetailsSheet(BuildContext context, File file) async {
   final colorScheme = getEnteColorScheme(context);
   return showBarModalBottomSheet(
     topControl: const SizedBox.shrink(),

+ 3 - 2
lib/ui/collection_action_sheet.dart

@@ -159,8 +159,9 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
                                 _searchQuery = value;
                               });
                             },
-                            cancellable: true,
-                            shouldUnfocusOnCancelOrSubmit: true,
+                            isClearable: true,
+                            shouldUnfocusOnClearOrSubmit: true,
+                            borderRadius: 2,
                           ),
                         ),
                         _getCollectionItems(filesCount),

+ 10 - 8
lib/ui/components/buttons/chip_button_widget.dart

@@ -37,14 +37,16 @@ class ChipButtonWidget extends StatelessWidget {
                       size: 17,
                     )
                   : const SizedBox.shrink(),
-              const SizedBox(width: 4),
-              Padding(
-                padding: const EdgeInsets.symmetric(horizontal: 4),
-                child: Text(
-                  label ?? "",
-                  style: getEnteTextTheme(context).smallBold,
-                ),
-              )
+              if (label != null && leadingIcon != null)
+                const SizedBox(width: 4),
+              if (label != null)
+                Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 4),
+                  child: Text(
+                    label!,
+                    style: getEnteTextTheme(context).smallBold,
+                  ),
+                )
             ],
           ),
         ),

+ 14 - 8
lib/ui/components/divider_widget.dart

@@ -28,17 +28,23 @@ class DividerWidget extends StatelessWidget {
         : getEnteColorScheme(context).strokeFaint;
 
     if (dividerType == DividerType.solid) {
-      return Container(
-        color: getEnteColorScheme(context).strokeFaint,
-        width: double.infinity,
-        height: 1,
+      return Padding(
+        padding: padding ?? EdgeInsets.zero,
+        child: Container(
+          color: getEnteColorScheme(context).strokeFaint,
+          width: double.infinity,
+          height: 1,
+        ),
       );
     }
     if (dividerType == DividerType.bottomBar) {
-      return Container(
-        color: dividerColor,
-        width: double.infinity,
-        height: 1,
+      return Padding(
+        padding: padding ?? EdgeInsets.zero,
+        child: Container(
+          color: dividerColor,
+          width: double.infinity,
+          height: 1,
+        ),
       );
     }
 

+ 53 - 16
lib/ui/components/text_input_widget.dart

@@ -16,10 +16,15 @@ class TextInputWidget extends StatefulWidget {
   final Alignment? alignMessage;
   final bool? autoFocus;
   final int? maxLength;
+  final double borderRadius;
 
   ///TextInputWidget will listen to this notifier and executes onSubmit when
   ///notified.
   final ValueNotifier? submitNotifier;
+
+  ///TextInputWidget will listen to this notifier and clears and unfocuses the
+  ///textFiled when notified.
+  final ValueNotifier? cancelNotifier;
   final bool alwaysShowSuccessState;
   final bool showOnlyLoadingState;
   final FutureVoidCallbackParamStr? onSubmit;
@@ -28,8 +33,14 @@ class TextInputWidget extends StatefulWidget {
   final bool shouldSurfaceExecutionStates;
   final TextCapitalization? textCapitalization;
   final bool isPasswordInput;
-  final bool cancellable;
-  final bool shouldUnfocusOnCancelOrSubmit;
+
+  ///Clear comes in the form of a suffix icon. It is unrelated to onCancel.
+  final bool isClearable;
+  final bool shouldUnfocusOnClearOrSubmit;
+  final FocusNode? focusNode;
+  final VoidCallback? onCancel;
+  final TextEditingController? textEditingController;
+  final ValueNotifier? isEmptyNotifier;
   const TextInputWidget({
     this.onSubmit,
     this.onChange,
@@ -42,14 +53,20 @@ class TextInputWidget extends StatefulWidget {
     this.autoFocus,
     this.maxLength,
     this.submitNotifier,
+    this.cancelNotifier,
     this.alwaysShowSuccessState = false,
     this.showOnlyLoadingState = false,
     this.popNavAfterSubmission = false,
     this.shouldSurfaceExecutionStates = true,
     this.textCapitalization = TextCapitalization.none,
     this.isPasswordInput = false,
-    this.cancellable = false,
-    this.shouldUnfocusOnCancelOrSubmit = false,
+    this.isClearable = false,
+    this.shouldUnfocusOnClearOrSubmit = false,
+    this.borderRadius = 8,
+    this.focusNode,
+    this.onCancel,
+    this.textEditingController,
+    this.isEmptyNotifier,
     super.key,
   });
 
@@ -59,7 +76,7 @@ class TextInputWidget extends StatefulWidget {
 
 class _TextInputWidgetState extends State<TextInputWidget> {
   ExecutionState executionState = ExecutionState.idle;
-  final _textController = TextEditingController();
+  late final TextEditingController _textController;
   final _debouncer = Debouncer(const Duration(milliseconds: 300));
   late final ValueNotifier<bool> _obscureTextNotifier;
 
@@ -70,6 +87,8 @@ class _TextInputWidgetState extends State<TextInputWidget> {
   @override
   void initState() {
     widget.submitNotifier?.addListener(_onSubmit);
+    widget.cancelNotifier?.addListener(_onCancel);
+    _textController = widget.textEditingController ?? TextEditingController();
 
     if (widget.initialValue != null) {
       _textController.value = TextEditingValue(
@@ -84,14 +103,22 @@ class _TextInputWidgetState extends State<TextInputWidget> {
     }
     _obscureTextNotifier = ValueNotifier(widget.isPasswordInput);
     _obscureTextNotifier.addListener(_safeRefresh);
+
+    if (widget.isEmptyNotifier != null) {
+      _textController.addListener(() {
+        widget.isEmptyNotifier!.value = _textController.text.isEmpty;
+      });
+    }
     super.initState();
   }
 
   @override
   void dispose() {
     widget.submitNotifier?.removeListener(_onSubmit);
+    widget.cancelNotifier?.removeListener(_onCancel);
     _obscureTextNotifier.dispose();
     _textController.dispose();
+    widget.isEmptyNotifier?.dispose();
     super.dispose();
   }
 
@@ -113,12 +140,13 @@ class _TextInputWidgetState extends State<TextInputWidget> {
     }
     textInputChildren.add(
       ClipRRect(
-        borderRadius: const BorderRadius.all(Radius.circular(8)),
+        borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
         child: Material(
           child: TextFormField(
             textCapitalization: widget.textCapitalization!,
             autofocus: widget.autoFocus ?? false,
             controller: _textController,
+            focusNode: widget.focusNode,
             inputFormatters: widget.maxLength != null
                 ? [LengthLimitingTextInputFormatter(50)]
                 : null,
@@ -155,9 +183,9 @@ class _TextInputWidgetState extends State<TextInputWidget> {
                     obscureTextNotifier: _obscureTextNotifier,
                     isPasswordInput: widget.isPasswordInput,
                     textController: _textController,
-                    isCancellable: widget.cancellable,
-                    shouldUnfocusOnCancelOrSubmit:
-                        widget.shouldUnfocusOnCancelOrSubmit,
+                    isClearable: widget.isClearable,
+                    shouldUnfocusOnClearOrSubmit:
+                        widget.shouldUnfocusOnClearOrSubmit,
                   ),
                 ),
               ),
@@ -224,7 +252,7 @@ class _TextInputWidgetState extends State<TextInputWidget> {
         });
       }),
     );
-    if (widget.shouldUnfocusOnCancelOrSubmit) {
+    if (widget.shouldUnfocusOnClearOrSubmit) {
       FocusScope.of(context).unfocus();
     }
     try {
@@ -303,6 +331,15 @@ class _TextInputWidgetState extends State<TextInputWidget> {
     }
   }
 
+  void _onCancel() {
+    if (widget.onCancel != null) {
+      widget.onCancel!();
+    } else {
+      _textController.clear();
+      FocusScope.of(context).unfocus();
+    }
+  }
+
   void _popNavigatorStack(BuildContext context, {Exception? e}) {
     Navigator.of(context).canPop() ? Navigator.of(context).pop(e) : null;
   }
@@ -315,8 +352,8 @@ class SuffixIconWidget extends StatelessWidget {
   final TextEditingController textController;
   final ValueNotifier? obscureTextNotifier;
   final bool isPasswordInput;
-  final bool isCancellable;
-  final bool shouldUnfocusOnCancelOrSubmit;
+  final bool isClearable;
+  final bool shouldUnfocusOnClearOrSubmit;
 
   const SuffixIconWidget({
     required this.executionState,
@@ -324,8 +361,8 @@ class SuffixIconWidget extends StatelessWidget {
     required this.textController,
     this.obscureTextNotifier,
     this.isPasswordInput = false,
-    this.isCancellable = false,
-    this.shouldUnfocusOnCancelOrSubmit = false,
+    this.isClearable = false,
+    this.shouldUnfocusOnClearOrSubmit = false,
     super.key,
   });
 
@@ -335,11 +372,11 @@ class SuffixIconWidget extends StatelessWidget {
     final colorScheme = getEnteColorScheme(context);
     if (executionState == ExecutionState.idle ||
         !shouldSurfaceExecutionStates) {
-      if (isCancellable) {
+      if (isClearable) {
         trailingWidget = GestureDetector(
           onTap: () {
             textController.clear();
-            if (shouldUnfocusOnCancelOrSubmit) {
+            if (shouldUnfocusOnClearOrSubmit) {
               FocusScope.of(context).unfocus();
             }
           },

+ 166 - 86
lib/ui/components/title_bar_widget.dart

@@ -13,6 +13,7 @@ class TitleBarWidget extends StatelessWidget {
   final bool isFlexibleSpaceDisabled;
   final bool isOnTopOfScreen;
   final Color? backgroundColor;
+  final bool isSliver;
   const TitleBarWidget({
     this.leading,
     this.title,
@@ -24,103 +25,96 @@ class TitleBarWidget extends StatelessWidget {
     this.isFlexibleSpaceDisabled = false,
     this.isOnTopOfScreen = true,
     this.backgroundColor,
+    this.isSliver = true,
     super.key,
   });
 
   @override
   Widget build(BuildContext context) {
     const toolbarHeight = 48.0;
-    final textTheme = getEnteTextTheme(context);
-    final colorTheme = getEnteColorScheme(context);
-    return SliverAppBar(
-      backgroundColor: backgroundColor,
-      primary: isOnTopOfScreen ? true : false,
-      toolbarHeight: toolbarHeight,
-      leadingWidth: 48,
-      automaticallyImplyLeading: false,
-      pinned: true,
-      expandedHeight: isFlexibleSpaceDisabled ? toolbarHeight : 102,
-      centerTitle: false,
-      titleSpacing: 4,
-      title: Padding(
-        padding: EdgeInsets.only(left: isTitleH2WithoutLeading ? 16 : 0),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          mainAxisAlignment: MainAxisAlignment.start,
-          children: [
-            title == null
-                ? const SizedBox.shrink()
-                : Text(
-                    title!,
-                    style: isTitleH2WithoutLeading
-                        ? textTheme.h2Bold
-                        : textTheme.largeBold,
-                  ),
-            caption == null || isTitleH2WithoutLeading
-                ? const SizedBox.shrink()
-                : Text(
-                    caption!,
-                    style: textTheme.mini.copyWith(color: colorTheme.textMuted),
-                  )
-          ],
+    if (isSliver) {
+      return SliverAppBar(
+        backgroundColor: backgroundColor,
+        primary: isOnTopOfScreen ? true : false,
+        toolbarHeight: toolbarHeight,
+        leadingWidth: 48,
+        automaticallyImplyLeading: false,
+        pinned: true,
+        expandedHeight: isFlexibleSpaceDisabled ? toolbarHeight : 102,
+        centerTitle: false,
+        titleSpacing: 4,
+        title: TitleWidget(
+          title: title,
+          caption: caption,
+          isTitleH2WithoutLeading: isTitleH2WithoutLeading,
         ),
-      ),
-      actions: [
-        Padding(
-          padding: const EdgeInsets.symmetric(horizontal: 4),
-          child: Row(
-            children: _actionsWithPaddingInBetween(),
+        actions: [
+          Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 4),
+            child: Row(
+              children: _actionsWithPaddingInBetween(),
+            ),
           ),
-        ),
-      ],
-      leading: isTitleH2WithoutLeading
-          ? null
-          : leading ??
-              IconButtonWidget(
-                icon: Icons.arrow_back_outlined,
-                iconButtonType: IconButtonType.primary,
-                onTap: () {
-                  Navigator.pop(context);
-                },
-              ),
-      flexibleSpace: isFlexibleSpaceDisabled
-          ? null
-          : FlexibleSpaceBar(
-              background: SafeArea(
-                child: Column(
-                  crossAxisAlignment: CrossAxisAlignment.start,
-                  mainAxisSize: MainAxisSize.min,
-                  children: <Widget>[
-                    const SizedBox(height: toolbarHeight),
-                    Padding(
-                      padding: const EdgeInsets.symmetric(
-                        vertical: 4,
-                        horizontal: 16,
-                      ),
-                      child: Column(
-                        crossAxisAlignment: CrossAxisAlignment.start,
-                        children: [
-                          flexibleSpaceTitle == null
-                              ? const SizedBox.shrink()
-                              : flexibleSpaceTitle!,
-                          flexibleSpaceCaption == null
-                              ? const SizedBox.shrink()
-                              : Text(
-                                  flexibleSpaceCaption!,
-                                  style: textTheme.small.copyWith(
-                                    color: colorTheme.textMuted,
-                                  ),
-                                  overflow: TextOverflow.ellipsis,
-                                  maxLines: 1,
-                                )
-                        ],
-                      ),
-                    ),
-                  ],
+        ],
+        leading: isTitleH2WithoutLeading
+            ? null
+            : leading ??
+                IconButtonWidget(
+                  icon: Icons.arrow_back_outlined,
+                  iconButtonType: IconButtonType.primary,
+                  onTap: () {
+                    Navigator.pop(context);
+                  },
                 ),
+        flexibleSpace: isFlexibleSpaceDisabled
+            ? null
+            : FlexibleSpaceBarWidget(
+                flexibleSpaceTitle,
+                flexibleSpaceCaption,
+                toolbarHeight,
               ),
+      );
+    } else {
+      return AppBar(
+        backgroundColor: backgroundColor,
+        primary: isOnTopOfScreen ? true : false,
+        toolbarHeight: toolbarHeight,
+        leadingWidth: 48,
+        automaticallyImplyLeading: false,
+        centerTitle: false,
+        titleSpacing: 4,
+        title: TitleWidget(
+          title: title,
+          caption: caption,
+          isTitleH2WithoutLeading: isTitleH2WithoutLeading,
+        ),
+        actions: [
+          Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 4),
+            child: Row(
+              children: _actionsWithPaddingInBetween(),
             ),
-    );
+          ),
+        ],
+        leading: isTitleH2WithoutLeading
+            ? null
+            : leading ??
+                IconButtonWidget(
+                  icon: Icons.arrow_back_outlined,
+                  iconButtonType: IconButtonType.primary,
+                  onTap: () {
+                    Navigator.pop(context);
+                  },
+                ),
+        flexibleSpace: isFlexibleSpaceDisabled
+            ? null
+            : FlexibleSpaceBarWidget(
+                flexibleSpaceTitle,
+                flexibleSpaceCaption,
+                toolbarHeight,
+              ),
+      );
+    }
   }
 
   _actionsWithPaddingInBetween() {
@@ -150,3 +144,89 @@ class TitleBarWidget extends StatelessWidget {
     return actions;
   }
 }
+
+class TitleWidget extends StatelessWidget {
+  final String? title;
+  final String? caption;
+  final bool isTitleH2WithoutLeading;
+  const TitleWidget(
+      {this.title,
+      this.caption,
+      required this.isTitleH2WithoutLeading,
+      super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final textTheme = getEnteTextTheme(context);
+    return Padding(
+      padding: EdgeInsets.only(left: isTitleH2WithoutLeading ? 16 : 0),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisAlignment: MainAxisAlignment.start,
+        children: [
+          title == null
+              ? const SizedBox.shrink()
+              : Text(
+                  title!,
+                  style: isTitleH2WithoutLeading
+                      ? textTheme.h2Bold
+                      : textTheme.largeBold,
+                ),
+          caption == null || isTitleH2WithoutLeading
+              ? const SizedBox.shrink()
+              : Text(
+                  caption!,
+                  style: textTheme.miniMuted,
+                )
+        ],
+      ),
+    );
+  }
+}
+
+class FlexibleSpaceBarWidget extends StatelessWidget {
+  final Widget? flexibleSpaceTitle;
+  final String? flexibleSpaceCaption;
+  final double toolbarHeight;
+  const FlexibleSpaceBarWidget(
+      this.flexibleSpaceTitle, this.flexibleSpaceCaption, this.toolbarHeight,
+      {super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final textTheme = getEnteTextTheme(context);
+    return FlexibleSpaceBar(
+      background: SafeArea(
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          mainAxisSize: MainAxisSize.min,
+          children: <Widget>[
+            SizedBox(height: toolbarHeight),
+            Padding(
+              padding: const EdgeInsets.symmetric(
+                vertical: 4,
+                horizontal: 16,
+              ),
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  flexibleSpaceTitle == null
+                      ? const SizedBox.shrink()
+                      : flexibleSpaceTitle!,
+                  flexibleSpaceCaption == null
+                      ? const SizedBox.shrink()
+                      : Text(
+                          flexibleSpaceCaption!,
+                          style: textTheme.smallMuted,
+                          overflow: TextOverflow.ellipsis,
+                          maxLines: 1,
+                        )
+                ],
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 1 - 1
lib/ui/home/memories_widget.dart

@@ -369,7 +369,7 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
                 color: Colors.white, //same for both themes
               ),
               onPressed: () {
-                showInfoSheet(context, file);
+                showDetailsSheet(context, file);
               },
             ),
             IconButton(

+ 6 - 0
lib/ui/huge_listview/huge_listview.dart

@@ -60,6 +60,8 @@ class HugeListView<T> extends StatefulWidget {
 
   final EdgeInsetsGeometry? thumbPadding;
 
+  final bool disableScroll;
+
   const HugeListView({
     Key? key,
     this.controller,
@@ -77,6 +79,7 @@ class HugeListView<T> extends StatefulWidget {
     this.bottomSafeArea = 120.0,
     this.isDraggableScrollbarEnabled = true,
     this.thumbPadding,
+    this.disableScroll = false,
   }) : super(key: key);
 
   @override
@@ -160,6 +163,9 @@ class HugeListViewState<T> extends State<HugeListView<T>> {
           isEnabled: widget.isDraggableScrollbarEnabled,
           padding: widget.thumbPadding,
           child: ScrollablePositionedList.builder(
+            physics: widget.disableScroll
+                ? const NeverScrollableScrollPhysics()
+                : null,
             itemScrollController: widget.controller,
             itemPositionsListener: listener,
             initialScrollIndex: widget.startIndex,

+ 121 - 79
lib/ui/huge_listview/lazy_loading_gallery.dart

@@ -32,11 +32,13 @@ class LazyLoadingGallery extends StatefulWidget {
   final Stream<FilesUpdatedEvent>? reloadEvent;
   final Set<EventType> removalEventTypes;
   final GalleryLoader asyncLoader;
-  final SelectedFiles selectedFiles;
+  final SelectedFiles? selectedFiles;
   final String tag;
   final String? logTag;
   final Stream<int> currentIndexStream;
   final int photoGirdSize;
+  final bool areFilesCollatedByDay;
+  final bool limitSelectionToOne;
   LazyLoadingGallery(
     this.files,
     this.index,
@@ -45,9 +47,11 @@ class LazyLoadingGallery extends StatefulWidget {
     this.asyncLoader,
     this.selectedFiles,
     this.tag,
-    this.currentIndexStream, {
+    this.currentIndexStream,
+    this.areFilesCollatedByDay, {
     this.logTag = "",
     this.photoGirdSize = photoGridSizeDefault,
+    this.limitSelectionToOne = false,
     Key? key,
   }) : super(key: key ?? UniqueKey());
 
@@ -62,7 +66,7 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
   late Logger _logger;
 
   late List<File> _files;
-  late StreamSubscription<FilesUpdatedEvent> _reloadEventSubscription;
+  late StreamSubscription<FilesUpdatedEvent>? _reloadEventSubscription;
   late StreamSubscription<int> _currentIndexSubscription;
   bool? _shouldRender;
   final ValueNotifier<bool> _toggleSelectAllFromDay = ValueNotifier(false);
@@ -72,7 +76,7 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
   @override
   void initState() {
     //this is for removing the 'select all from day' icon on unselecting all files with 'cancel'
-    widget.selectedFiles.addListener(_selectedFilesListener);
+    widget.selectedFiles?.addListener(_selectedFilesListener);
     super.initState();
     _init();
   }
@@ -81,7 +85,7 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
     _logger = Logger("LazyLoading_${widget.logTag}");
     _shouldRender = true;
     _files = widget.files;
-    _reloadEventSubscription = widget.reloadEvent!.listen((e) => _onReload(e));
+    _reloadEventSubscription = widget.reloadEvent?.listen((e) => _onReload(e));
 
     _currentIndexSubscription =
         widget.currentIndexStream.listen((currentIndex) {
@@ -162,9 +166,9 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
 
   @override
   void dispose() {
-    _reloadEventSubscription.cancel();
+    _reloadEventSubscription?.cancel();
     _currentIndexSubscription.cancel();
-    widget.selectedFiles.removeListener(_selectedFilesListener);
+    widget.selectedFiles?.removeListener(_selectedFilesListener);
     _toggleSelectAllFromDay.dispose();
     _showSelectAllButton.dispose();
     _areAllFromDaySelected.dispose();
@@ -175,7 +179,7 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
   void didUpdateWidget(LazyLoadingGallery oldWidget) {
     super.didUpdateWidget(oldWidget);
     if (!listEquals(_files, widget.files)) {
-      _reloadEventSubscription.cancel();
+      _reloadEventSubscription?.cancel();
       _init();
     }
   }
@@ -190,47 +194,50 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
         Row(
           mainAxisAlignment: MainAxisAlignment.spaceBetween,
           children: [
-            getDayWidget(
-              context,
-              _files[0].creationTime!,
-              widget.photoGirdSize,
-            ),
-            ValueListenableBuilder(
-              valueListenable: _showSelectAllButton,
-              builder: (context, dynamic value, _) {
-                return !value
-                    ? const SizedBox.shrink()
-                    : GestureDetector(
-                        behavior: HitTestBehavior.translucent,
-                        child: SizedBox(
-                          width: 48,
-                          height: 44,
-                          child: ValueListenableBuilder(
-                            valueListenable: _areAllFromDaySelected,
-                            builder: (context, dynamic value, _) {
-                              return value
-                                  ? const Icon(
-                                      Icons.check_circle,
-                                      size: 18,
-                                    )
-                                  : Icon(
-                                      Icons.check_circle_outlined,
-                                      color: getEnteColorScheme(context)
-                                          .strokeMuted,
-                                      size: 18,
-                                    );
-                            },
-                          ),
-                        ),
-                        onTap: () {
-                          //this value has no significance
-                          //changing only to notify the listeners
-                          _toggleSelectAllFromDay.value =
-                              !_toggleSelectAllFromDay.value;
-                        },
-                      );
-              },
-            )
+            if (widget.areFilesCollatedByDay)
+              getDayWidget(
+                context,
+                _files[0].creationTime!,
+                widget.photoGirdSize,
+              ),
+            widget.limitSelectionToOne
+                ? const SizedBox.shrink()
+                : ValueListenableBuilder(
+                    valueListenable: _showSelectAllButton,
+                    builder: (context, dynamic value, _) {
+                      return !value
+                          ? const SizedBox.shrink()
+                          : GestureDetector(
+                              behavior: HitTestBehavior.translucent,
+                              child: SizedBox(
+                                width: 48,
+                                height: 44,
+                                child: ValueListenableBuilder(
+                                  valueListenable: _areAllFromDaySelected,
+                                  builder: (context, dynamic value, _) {
+                                    return value
+                                        ? const Icon(
+                                            Icons.check_circle,
+                                            size: 18,
+                                          )
+                                        : Icon(
+                                            Icons.check_circle_outlined,
+                                            color: getEnteColorScheme(context)
+                                                .strokeMuted,
+                                            size: 18,
+                                          );
+                                  },
+                                ),
+                              ),
+                              onTap: () {
+                                //this value has no significance
+                                //changing only to notify the listeners
+                                _toggleSelectAllFromDay.value =
+                                    !_toggleSelectAllFromDay.value;
+                              },
+                            );
+                    },
+                  )
           ],
         ),
         _shouldRender!
@@ -261,6 +268,7 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
           _toggleSelectAllFromDay,
           _areAllFromDaySelected,
           widget.photoGirdSize,
+          limitSelectionToOne: widget.limitSelectionToOne,
         ),
       );
     }
@@ -271,7 +279,7 @@ class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
   }
 
   void _selectedFilesListener() {
-    if (widget.selectedFiles.files.isEmpty) {
+    if (widget.selectedFiles!.files.isEmpty) {
       _showSelectAllButton.value = false;
     } else {
       _showSelectAllButton.value = true;
@@ -283,12 +291,13 @@ class LazyLoadingGridView extends StatefulWidget {
   final String tag;
   final List<File> filesInDay;
   final GalleryLoader asyncLoader;
-  final SelectedFiles selectedFiles;
+  final SelectedFiles? selectedFiles;
   final bool shouldRender;
   final bool shouldRecycle;
   final ValueNotifier toggleSelectAllFromDay;
   final ValueNotifier areAllFilesSelected;
   final int? photoGridSize;
+  final bool limitSelectionToOne;
 
   LazyLoadingGridView(
     this.tag,
@@ -300,6 +309,7 @@ class LazyLoadingGridView extends StatefulWidget {
     this.toggleSelectAllFromDay,
     this.areAllFilesSelected,
     this.photoGridSize, {
+    this.limitSelectionToOne = false,
     Key? key,
   }) : super(key: key ?? UniqueKey());
 
@@ -316,7 +326,7 @@ class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
   void initState() {
     _shouldRender = widget.shouldRender;
     _currentUserID = Configuration.instance.getUserID();
-    widget.selectedFiles.addListener(_selectedFilesListener);
+    widget.selectedFiles?.addListener(_selectedFilesListener);
     _clearSelectionsEvent =
         Bus.instance.on<ClearSelectionsEvent>().listen((event) {
       if (mounted) {
@@ -329,7 +339,7 @@ class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
 
   @override
   void dispose() {
-    widget.selectedFiles.removeListener(_selectedFilesListener);
+    widget.selectedFiles?.removeListener(_selectedFilesListener);
     _clearSelectionsEvent.cancel();
     widget.toggleSelectAllFromDay
         .removeListener(_toggleSelectAllFromDayListener);
@@ -403,12 +413,12 @@ class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
         mainAxisSpacing: 2,
         crossAxisCount: widget.photoGridSize!,
       ),
-      padding: const EdgeInsets.all(0),
+      padding: const EdgeInsets.symmetric(vertical: (galleryGridSpacing / 2)),
     );
   }
 
   Widget _buildFile(BuildContext context, File file) {
-    final isFileSelected = widget.selectedFiles.isFileSelected(file);
+    final isFileSelected = widget.selectedFiles?.isFileSelected(file) ?? false;
     Color selectionColor = Colors.white;
     if (isFileSelected &&
         file.isUploaded &&
@@ -421,25 +431,15 @@ class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
       selectionColor = avatarColors[(randomID).remainder(avatarColors.length)];
     }
     return GestureDetector(
-      onTap: () async {
-        if (widget.selectedFiles.files.isNotEmpty) {
-          _selectFile(file);
-        } else {
-          if (AppLifecycleService.instance.mediaExtensionAction.action ==
-              IntentAction.pick) {
-            final ioFile = await getFile(file);
-            MediaExtension().setResult("file://${ioFile!.path}");
-          } else {
-            _routeToDetailPage(file, context);
-          }
-        }
+      onTap: () {
+        widget.limitSelectionToOne
+            ? _onTapWithSelectionLimit(file)
+            : _onTapNoSelectionLimit(file);
       },
       onLongPress: () {
-        if (AppLifecycleService.instance.mediaExtensionAction.action ==
-            IntentAction.main) {
-          HapticFeedback.lightImpact();
-          _selectFile(file);
-        }
+        widget.limitSelectionToOne
+            ? _onLongPressWithSelectionLimit(file)
+            : _onLongPressNoSelectionLimit(file);
       },
       child: ClipRRect(
         borderRadius: BorderRadius.circular(1),
@@ -485,8 +485,50 @@ class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
     );
   }
 
-  void _selectFile(File file) {
-    widget.selectedFiles.toggleSelection(file);
+  void _toggleFileSelection(File file) {
+    widget.selectedFiles!.toggleSelection(file);
+  }
+
+  void _onTapNoSelectionLimit(File file) async {
+    if (widget.selectedFiles?.files.isNotEmpty ?? false) {
+      _toggleFileSelection(file);
+    } else {
+      if (AppLifecycleService.instance.mediaExtensionAction.action ==
+          IntentAction.pick) {
+        final ioFile = await getFile(file);
+        MediaExtension().setResult("file://${ioFile!.path}");
+      } else {
+        _routeToDetailPage(file, context);
+      }
+    }
+  }
+
+  void _onTapWithSelectionLimit(File file) {
+    if (widget.selectedFiles!.files.isNotEmpty &&
+        widget.selectedFiles!.files.first != file) {
+      widget.selectedFiles!.clearAll();
+    }
+    _toggleFileSelection(file);
+  }
+
+  void _onLongPressNoSelectionLimit(File file) {
+    if (widget.selectedFiles!.files.isNotEmpty) {
+      _routeToDetailPage(file, context);
+    } else if (AppLifecycleService.instance.mediaExtensionAction.action ==
+        IntentAction.main) {
+      HapticFeedback.lightImpact();
+      _toggleFileSelection(file);
+    }
+  }
+
+  Future<void> _onLongPressWithSelectionLimit(File file) async {
+    if (AppLifecycleService.instance.mediaExtensionAction.action ==
+        IntentAction.pick) {
+      final ioFile = await getFile(file);
+      MediaExtension().setResult("file://${ioFile!.path}");
+    } else {
+      _routeToDetailPage(file, context);
+    }
   }
 
   void _routeToDetailPage(File file, BuildContext context) {
@@ -502,14 +544,14 @@ class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
   }
 
   void _selectedFilesListener() {
-    if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) {
+    if (widget.selectedFiles!.files.containsAll(widget.filesInDay.toSet())) {
       widget.areAllFilesSelected.value = true;
     } else {
       widget.areAllFilesSelected.value = false;
     }
     bool shouldRefresh = false;
     for (final file in widget.filesInDay) {
-      if (widget.selectedFiles.isPartOfLastSelected(file)) {
+      if (widget.selectedFiles!.isPartOfLastSelected(file)) {
         shouldRefresh = true;
       }
     }
@@ -519,12 +561,12 @@ class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
   }
 
   void _toggleSelectAllFromDayListener() {
-    if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) {
+    if (widget.selectedFiles!.files.containsAll(widget.filesInDay.toSet())) {
       setState(() {
-        widget.selectedFiles.unSelectAll(widget.filesInDay.toSet());
+        widget.selectedFiles!.unSelectAll(widget.filesInDay.toSet());
       });
     } else {
-      widget.selectedFiles.selectAll(widget.filesInDay.toSet());
+      widget.selectedFiles!.selectAll(widget.filesInDay.toSet());
     }
   }
 }

+ 5 - 2
lib/ui/tools/editor/image_editor_page.dart

@@ -13,7 +13,7 @@ import 'package:photos/core/event_bus.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
 import 'package:photos/models/file.dart' as ente;
-import 'package:photos/models/location.dart';
+import 'package:photos/models/location/location.dart';
 import 'package:photos/services/sync_service.dart';
 import 'package:photos/ui/common/loading_widget.dart';
 import 'package:photos/ui/components/action_sheet_widget.dart';
@@ -362,7 +362,10 @@ class _ImageEditorPageState extends State<ImageEditorPage> {
         final assetEntity = await widget.originalFile.getAsset;
         if (assetEntity != null) {
           final latLong = await assetEntity.latlngAsync();
-          newFile.location = Location(latLong.latitude, latLong.longitude);
+          newFile.location = Location(
+            latitude: latLong.latitude,
+            longitude: latLong.longitude,
+          );
         }
       }
       newFile.generatedID = await FilesDB.instance.insert(newFile);

+ 3 - 3
lib/ui/viewer/file/fading_bottom_bar.dart

@@ -76,7 +76,7 @@ class FadingBottomBarState extends State<FadingBottomBar> {
               color: Colors.white,
             ),
             onPressed: () async {
-              await _displayInfo(widget.file);
+              await _displayDetails(widget.file);
               safeRefresh(); //to instantly show the new caption if keypad is closed after pressing 'done' - here the caption will be updated before the bottom sheet is closed
               await Future.delayed(
                 const Duration(milliseconds: 500),
@@ -268,7 +268,7 @@ class FadingBottomBarState extends State<FadingBottomBar> {
     );
   }
 
-  Future<void> _displayInfo(File file) async {
-    await showInfoSheet(context, file);
+  Future<void> _displayDetails(File file) async {
+    await showDetailsSheet(context, file);
   }
 }

+ 3 - 3
lib/ui/viewer/file/file_caption_widget.dart

@@ -66,7 +66,7 @@ class _FileCaptionWidgetState extends State<FileCaptionWidget> {
   final _focusNode = FocusNode();
   String? editedCaption;
   String hintText = fileCaptionDefaultHint;
-  Widget? keyboardTopButtoms;
+  Widget? keyboardTopButtons;
 
   @override
   void initState() {
@@ -172,12 +172,12 @@ class _FileCaptionWidgetState extends State<FileCaptionWidget> {
       editedCaption = caption;
     }
     final bool hasFocus = _focusNode.hasFocus;
-    keyboardTopButtoms ??= KeyboardTopButton(
+    keyboardTopButtons ??= KeyboardTopButton(
       onDoneTap: onDoneTap,
       onCancelTap: onCancelTap,
     );
     if (hasFocus) {
-      KeyboardOverlay.showOverlay(context, keyboardTopButtoms!);
+      KeyboardOverlay.showOverlay(context, keyboardTopButtons!);
     } else {
       KeyboardOverlay.removeOverlay();
     }

+ 67 - 4
lib/ui/viewer/file/file_details_widget.dart

@@ -16,6 +16,7 @@ import 'package:photos/ui/viewer/file_details/backed_up_time_item_widget.dart';
 import "package:photos/ui/viewer/file_details/creation_time_item_widget.dart";
 import 'package:photos/ui/viewer/file_details/exif_item_widgets.dart';
 import "package:photos/ui/viewer/file_details/file_properties_item_widget.dart";
+import "package:photos/ui/viewer/file_details/location_tags_widget.dart";
 import "package:photos/ui/viewer/file_details/objects_item_widget.dart";
 import "package:photos/utils/exif_util.dart";
 
@@ -39,12 +40,17 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
     "takenOnDevice": null,
     "exposureTime": null,
     "ISO": null,
-    "megaPixels": null
+    "megaPixels": null,
+    "lat": null,
+    "long": null,
+    "latRef": null,
+    "longRef": null,
   };
 
   bool _isImage = false;
   late int _currentUserID;
   bool showExifListTile = false;
+  bool hasGPSData = false;
 
   @override
   void initState() {
@@ -52,6 +58,12 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
     _currentUserID = Configuration.instance.getUserID()!;
     _isImage = widget.file.fileType == FileType.image ||
         widget.file.fileType == FileType.livePhoto;
+    _exifNotifier.addListener(() {
+      if (_exifNotifier.value != null) {
+        _generateExifForLocation(_exifNotifier.value!);
+        hasGPSData = _haGPSData();
+      }
+    });
     if (_isImage) {
       _exifNotifier.addListener(() {
         if (_exifNotifier.value != null) {
@@ -63,10 +75,10 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
             _exifData["exposureTime"] != null ||
             _exifData["ISO"] != null;
       });
-      getExif(widget.file).then((exif) {
-        _exifNotifier.value = exif;
-      });
     }
+    getExif(widget.file).then((exif) {
+      _exifNotifier.value = exif;
+    });
     super.initState();
   }
 
@@ -125,6 +137,25 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
         },
       ),
     );
+    if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
+      fileDetailsTiles.addAll([
+        ValueListenableBuilder(
+          valueListenable: _exifNotifier,
+          builder: (context, _, __) {
+            return hasGPSData
+                ? Column(
+                    children: [
+                      LocationTagsWidget(
+                        widget.file.location!,
+                      ),
+                      const FileDetailsDivider(),
+                    ],
+                  )
+                : const SizedBox.shrink();
+          },
+        )
+      ]);
+    }
     if (_isImage) {
       fileDetailsTiles.addAll([
         ValueListenableBuilder(
@@ -200,6 +231,38 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
     );
   }
 
+  bool _haGPSData() {
+    final fileLocation = widget.file.location;
+    final hasLocation = (fileLocation != null &&
+            fileLocation.latitude != null &&
+            fileLocation.longitude != null) &&
+        (fileLocation.latitude != 0 || fileLocation.longitude != 0);
+    return hasLocation;
+  }
+
+  void _generateExifForLocation(Map<String, IfdTag> exif) {
+    if (exif["GPS GPSLatitude"] != null) {
+      _exifData["lat"] = exif["GPS GPSLatitude"]!
+          .values
+          .toList()
+          .map((e) => ((e as Ratio).numerator / e.denominator))
+          .toList();
+    }
+    if (exif["GPS GPSLongitude"] != null) {
+      _exifData["long"] = exif["GPS GPSLongitude"]!
+          .values
+          .toList()
+          .map((e) => ((e as Ratio).numerator / e.denominator))
+          .toList();
+    }
+    if (exif["GPS GPSLatitudeRef"] != null) {
+      _exifData["latRef"] = exif["GPS GPSLatitudeRef"].toString();
+    }
+    if (exif["GPS GPSLongitudeRef"] != null) {
+      _exifData["longRef"] = exif["GPS GPSLongitudeRef"].toString();
+    }
+  }
+
   _generateExifForDetails(Map<String, IfdTag> exif) {
     if (exif["EXIF FocalLength"] != null) {
       _exifData["focalLength"] =

+ 118 - 0
lib/ui/viewer/file_details/location_tags_widget.dart

@@ -0,0 +1,118 @@
+import "dart:async";
+
+import "package:flutter/material.dart";
+import "package:photos/core/event_bus.dart";
+import "package:photos/events/location_tag_updated_event.dart";
+import "package:photos/models/location/location.dart";
+import "package:photos/services/location_service.dart";
+import "package:photos/states/location_screen_state.dart";
+import "package:photos/ui/components/buttons/chip_button_widget.dart";
+import "package:photos/ui/components/buttons/inline_button_widget.dart";
+import "package:photos/ui/components/info_item_widget.dart";
+import 'package:photos/ui/viewer/location/add_location_sheet.dart';
+import "package:photos/ui/viewer/location/location_screen.dart";
+import "package:photos/utils/navigation_util.dart";
+
+class LocationTagsWidget extends StatefulWidget {
+  final Location centerPoint;
+  const LocationTagsWidget(this.centerPoint, {super.key});
+
+  @override
+  State<LocationTagsWidget> createState() => _LocationTagsWidgetState();
+}
+
+class _LocationTagsWidgetState extends State<LocationTagsWidget> {
+  String? title;
+  IconData? leadingIcon;
+  bool? hasChipButtons;
+  late Future<List<Widget>> locationTagChips;
+  late StreamSubscription<LocationTagUpdatedEvent> _locTagUpdateListener;
+  @override
+  void initState() {
+    locationTagChips = _getLocationTags();
+    _locTagUpdateListener =
+        Bus.instance.on<LocationTagUpdatedEvent>().listen((event) {
+      locationTagChips = _getLocationTags();
+    });
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    _locTagUpdateListener.cancel();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedSwitcher(
+      duration: const Duration(milliseconds: 500),
+      switchInCurve: Curves.easeInOutExpo,
+      switchOutCurve: Curves.easeInOutExpo,
+      child: InfoItemWidget(
+        key: ValueKey(title),
+        leadingIcon: leadingIcon ?? Icons.pin_drop_outlined,
+        title: title,
+        subtitleSection: locationTagChips,
+        hasChipButtons: hasChipButtons ?? true,
+      ),
+    );
+  }
+
+  Future<List<Widget>> _getLocationTags() async {
+    final locationTags = await LocationService.instance
+        .enclosingLocationTags(widget.centerPoint);
+    if (locationTags.isEmpty) {
+      if (mounted) {
+        setState(() {
+          title = "Add location";
+          leadingIcon = Icons.add_location_alt_outlined;
+          hasChipButtons = false;
+        });
+      }
+
+      return [
+        InlineButtonWidget(
+          "Group nearby photos",
+          () => showAddLocationSheet(
+            context,
+            widget.centerPoint,
+          ),
+        ),
+      ];
+    } else {
+      if (mounted) {
+        setState(() {
+          title = "Location";
+          leadingIcon = Icons.pin_drop_outlined;
+          hasChipButtons = true;
+        });
+      }
+    }
+
+    final result = locationTags
+        .map(
+          (locationTagEntity) => ChipButtonWidget(
+            locationTagEntity.item.name,
+            onTap: () {
+              routeToPage(
+                context,
+                LocationScreenStateProvider(
+                  locationTagEntity,
+                  const LocationScreen(),
+                ),
+              );
+            },
+          ),
+        )
+        .toList();
+    result.add(
+      ChipButtonWidget(
+        null,
+        leadingIcon: Icons.add_outlined,
+        onTap: () => showAddLocationSheet(context, widget.centerPoint),
+      ),
+    );
+    return result;
+  }
+}

+ 16 - 4
lib/ui/viewer/gallery/gallery.dart

@@ -33,18 +33,22 @@ class Gallery extends StatefulWidget {
   final Stream<FilesUpdatedEvent>? reloadEvent;
   final List<Stream<Event>>? forceReloadEvents;
   final Set<EventType> removalEventTypes;
-  final SelectedFiles selectedFiles;
+  final SelectedFiles? selectedFiles;
   final String tagPrefix;
   final Widget? header;
   final Widget? footer;
   final Widget emptyState;
   final String? albumName;
   final double scrollBottomSafeArea;
+  final bool shouldCollateFilesByDay;
+  final Widget loadingWidget;
+  final bool disableScroll;
+  final bool limitSelectionToOne;
 
   const Gallery({
     required this.asyncLoader,
-    required this.selectedFiles,
     required this.tagPrefix,
+    this.selectedFiles,
     this.initialFiles,
     this.reloadEvent,
     this.forceReloadEvents,
@@ -54,6 +58,10 @@ class Gallery extends StatefulWidget {
     this.emptyState = const EmptyState(),
     this.scrollBottomSafeArea = 120.0,
     this.albumName = '',
+    this.shouldCollateFilesByDay = true,
+    this.loadingWidget = const EnteLoadingWidget(),
+    this.disableScroll = false,
+    this.limitSelectionToOne = false,
     Key? key,
   }) : super(key: key);
 
@@ -168,7 +176,8 @@ class _GalleryState extends State<Gallery> {
 
   // Collates files and returns `true` if it resulted in a gallery reload
   bool _onFilesLoaded(List<File> files) {
-    final updatedCollatedFiles = _collateFiles(files);
+    final updatedCollatedFiles =
+        widget.shouldCollateFilesByDay ? _collateFiles(files) : [files];
     if (_collatedFiles.length != updatedCollatedFiles.length ||
         _collatedFiles.isEmpty) {
       if (mounted) {
@@ -198,7 +207,7 @@ class _GalleryState extends State<Gallery> {
   Widget build(BuildContext context) {
     _logger.finest("Building Gallery  ${widget.tagPrefix}");
     if (!_hasLoadedFiles) {
-      return const EnteLoadingWidget();
+      return widget.loadingWidget;
     }
     _photoGridSize = LocalSettings.instance.getPhotoGridSize();
     return _getListView();
@@ -211,6 +220,7 @@ class _GalleryState extends State<Gallery> {
       startIndex: 0,
       totalCount: _collatedFiles.length,
       isDraggableScrollbarEnabled: _collatedFiles.length > 10,
+      disableScroll: widget.disableScroll,
       waitBuilder: (_) {
         return const EnteLoadingWidget();
       },
@@ -246,8 +256,10 @@ class _GalleryState extends State<Gallery> {
               .on<GalleryIndexUpdatedEvent>()
               .where((event) => event.tag == widget.tagPrefix)
               .map((event) => event.index),
+          widget.shouldCollateFilesByDay,
           logTag: _logTag,
           photoGirdSize: _photoGridSize,
+          limitSelectionToOne: widget.limitSelectionToOne,
         );
         if (widget.header != null && index == 0) {
           gallery = Column(children: [widget.header!, gallery]);

+ 265 - 0
lib/ui/viewer/location/add_location_sheet.dart

@@ -0,0 +1,265 @@
+import 'package:flutter/material.dart';
+import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
+import "package:photos/core/constants.dart";
+import "package:photos/models/location/location.dart";
+import "package:photos/services/location_service.dart";
+import 'package:photos/states/location_state.dart';
+import "package:photos/theme/colors.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/common/loading_widget.dart";
+import "package:photos/ui/components/bottom_of_title_bar_widget.dart";
+import "package:photos/ui/components/buttons/button_widget.dart";
+import "package:photos/ui/components/divider_widget.dart";
+import "package:photos/ui/components/keyboard/keybiard_oveylay.dart";
+import "package:photos/ui/components/keyboard/keyboard_top_button.dart";
+import "package:photos/ui/components/models/button_type.dart";
+import "package:photos/ui/components/text_input_widget.dart";
+import "package:photos/ui/components/title_bar_title_widget.dart";
+import 'package:photos/ui/viewer/location/dynamic_location_gallery_widget.dart';
+import "package:photos/ui/viewer/location/radius_picker_widget.dart";
+
+showAddLocationSheet(
+  BuildContext context,
+  Location coordinates,
+) {
+  showBarModalBottomSheet(
+    context: context,
+    builder: (context) {
+      return LocationTagStateProvider(
+        centerPoint: coordinates,
+        const AddLocationSheet(),
+      );
+    },
+    shape: const RoundedRectangleBorder(
+      side: BorderSide(width: 0),
+      borderRadius: BorderRadius.vertical(
+        top: Radius.circular(5),
+      ),
+    ),
+    topControl: const SizedBox.shrink(),
+    backgroundColor: getEnteColorScheme(context).backgroundElevated,
+    barrierColor: backdropFaintDark,
+  );
+}
+
+class AddLocationSheet extends StatefulWidget {
+  const AddLocationSheet({super.key});
+
+  @override
+  State<AddLocationSheet> createState() => _AddLocationSheetState();
+}
+
+class _AddLocationSheetState extends State<AddLocationSheet> {
+  //The value of these notifiers has no significance.
+  //When memoriesCountNotifier is null, we show the loading widget in the
+  //memories count section which also means the gallery is loading.
+  final ValueNotifier<int?> _memoriesCountNotifier = ValueNotifier(null);
+  final ValueNotifier<bool> _submitNotifer = ValueNotifier(false);
+  final ValueNotifier<bool> _cancelNotifier = ValueNotifier(false);
+  final ValueNotifier<int> _selectedRadiusIndexNotifier =
+      ValueNotifier(defaultRadiusValueIndex);
+  final _focusNode = FocusNode();
+  final _textEditingController = TextEditingController();
+  final _isEmptyNotifier = ValueNotifier(true);
+  Widget? _keyboardTopButtons;
+
+  @override
+  void initState() {
+    _focusNode.addListener(_focusNodeListener);
+    _selectedRadiusIndexNotifier.addListener(_selectedRadiusIndexListener);
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    _focusNode.removeListener(_focusNodeListener);
+    _submitNotifer.dispose();
+    _cancelNotifier.dispose();
+    _selectedRadiusIndexNotifier.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final textTheme = getEnteTextTheme(context);
+    final colorScheme = getEnteColorScheme(context);
+    return Padding(
+      padding: const EdgeInsets.fromLTRB(0, 32, 0, 8),
+      child: Column(
+        children: [
+          const Padding(
+            padding: EdgeInsets.only(bottom: 16),
+            child: BottomOfTitleBarWidget(
+              title: TitleBarTitleWidget(title: "Add location"),
+            ),
+          ),
+          Expanded(
+            child: SingleChildScrollView(
+              physics: const BouncingScrollPhysics(
+                decelerationRate: ScrollDecelerationRate.fast,
+              ),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  Padding(
+                    padding: const EdgeInsets.symmetric(horizontal: 16),
+                    child: Column(
+                      children: [
+                        Row(
+                          children: [
+                            Expanded(
+                              child: TextInputWidget(
+                                hintText: "Location name",
+                                borderRadius: 2,
+                                focusNode: _focusNode,
+                                submitNotifier: _submitNotifer,
+                                cancelNotifier: _cancelNotifier,
+                                popNavAfterSubmission: false,
+                                shouldUnfocusOnClearOrSubmit: true,
+                                alwaysShowSuccessState: true,
+                                textEditingController: _textEditingController,
+                                isEmptyNotifier: _isEmptyNotifier,
+                              ),
+                            ),
+                            const SizedBox(width: 8),
+                            ValueListenableBuilder(
+                              valueListenable: _isEmptyNotifier,
+                              builder: (context, bool value, _) {
+                                return AnimatedSwitcher(
+                                  duration: const Duration(milliseconds: 250),
+                                  switchInCurve: Curves.easeInOut,
+                                  switchOutCurve: Curves.easeInOut,
+                                  child: ButtonWidget(
+                                    key: ValueKey(value),
+                                    buttonType: ButtonType.secondary,
+                                    buttonSize: ButtonSize.small,
+                                    labelText: "Add",
+                                    isDisabled: value,
+                                    onTap: () async {
+                                      _focusNode.unfocus();
+                                      await _addLocationTag();
+                                    },
+                                  ),
+                                );
+                              },
+                            )
+                          ],
+                        ),
+                        const SizedBox(height: 24),
+                        RadiusPickerWidget(
+                          _selectedRadiusIndexNotifier,
+                        ),
+                        const SizedBox(height: 24),
+                        Text(
+                          "A location tag groups all photos that were taken within some radius of a photo",
+                          style: textTheme.smallMuted,
+                        ),
+                      ],
+                    ),
+                  ),
+                  const DividerWidget(
+                    dividerType: DividerType.solid,
+                    padding: EdgeInsets.only(top: 24, bottom: 20),
+                  ),
+                  SizedBox(
+                    width: double.infinity,
+                    child: Padding(
+                      padding: const EdgeInsets.symmetric(horizontal: 16),
+                      child: ValueListenableBuilder(
+                        valueListenable: _memoriesCountNotifier,
+                        builder: (context, value, _) {
+                          Widget widget;
+                          if (value == null) {
+                            widget = RepaintBoundary(
+                              child: EnteLoadingWidget(
+                                size: 14,
+                                color: colorScheme.strokeMuted,
+                                alignment: Alignment.centerLeft,
+                                padding: 3,
+                              ),
+                            );
+                          } else {
+                            widget = Column(
+                              mainAxisSize: MainAxisSize.min,
+                              crossAxisAlignment: CrossAxisAlignment.start,
+                              children: [
+                                Text(
+                                  value == 1 ? "1 memory" : "$value memories",
+                                  style: textTheme.body,
+                                ),
+                                if (value as int > 1000)
+                                  Padding(
+                                    padding: const EdgeInsets.only(top: 2),
+                                    child: Text(
+                                      "Up to 1000 memories shown in gallery",
+                                      style: textTheme.miniMuted,
+                                    ),
+                                  ),
+                              ],
+                            );
+                          }
+                          return Align(
+                            alignment: Alignment.centerLeft,
+                            child: AnimatedSwitcher(
+                              duration: const Duration(milliseconds: 250),
+                              switchInCurve: Curves.easeInOutExpo,
+                              switchOutCurve: Curves.easeInOutExpo,
+                              child: widget,
+                            ),
+                          );
+                        },
+                      ),
+                    ),
+                  ),
+                  const SizedBox(height: 24),
+                  DynamicLocationGalleryWidget(
+                    _memoriesCountNotifier,
+                    "Add_location",
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Future<void> _addLocationTag() async {
+    final locationData = InheritedLocationTagData.of(context);
+    final coordinates = locationData.centerPoint;
+    final radius = radiusValues[locationData.selectedRadiusIndex];
+    await LocationService.instance.addLocation(
+      _textEditingController.text.trim(),
+      coordinates,
+      radius,
+    );
+    Navigator.pop(context);
+  }
+
+  void _focusNodeListener() {
+    final bool hasFocus = _focusNode.hasFocus;
+    _keyboardTopButtons ??= KeyboardTopButton(
+      onDoneTap: () {
+        _submitNotifer.value = !_submitNotifer.value;
+      },
+      onCancelTap: () {
+        _cancelNotifier.value = !_cancelNotifier.value;
+      },
+    );
+    if (hasFocus) {
+      KeyboardOverlay.showOverlay(context, _keyboardTopButtons!);
+    } else {
+      KeyboardOverlay.removeOverlay();
+    }
+  }
+
+  void _selectedRadiusIndexListener() {
+    InheritedLocationTagData.of(
+      context,
+    ).updateSelectedIndex(
+      _selectedRadiusIndexNotifier.value,
+    );
+    _memoriesCountNotifier.value = null;
+  }
+}

+ 143 - 0
lib/ui/viewer/location/dynamic_location_gallery_widget.dart

@@ -0,0 +1,143 @@
+import "dart:developer" as dev;
+import "dart:math";
+
+import "package:flutter/material.dart";
+import "package:photos/core/constants.dart";
+import "package:photos/db/files_db.dart";
+import "package:photos/models/file.dart";
+import "package:photos/models/file_load_result.dart";
+import "package:photos/services/collections_service.dart";
+import "package:photos/services/files_service.dart";
+import "package:photos/services/location_service.dart";
+import 'package:photos/states/location_state.dart';
+import "package:photos/ui/viewer/gallery/gallery.dart";
+import "package:photos/utils/local_settings.dart";
+
+///This gallery will get rebuilt with the updated radius when
+///InheritedLocationTagData notifies a change in radius.
+class DynamicLocationGalleryWidget extends StatefulWidget {
+  final ValueNotifier<int?> memoriesCountNotifier;
+  final String tagPrefix;
+  const DynamicLocationGalleryWidget(
+    this.memoriesCountNotifier,
+    this.tagPrefix, {
+    super.key,
+  });
+
+  @override
+  State<DynamicLocationGalleryWidget> createState() =>
+      _DynamicLocationGalleryWidgetState();
+}
+
+class _DynamicLocationGalleryWidgetState
+    extends State<DynamicLocationGalleryWidget> {
+  late final Future<FileLoadResult> fileLoadResult;
+  late Future<void> removeIgnoredFiles;
+  double heightOfGallery = 0;
+
+  @override
+  void initState() {
+    final collectionsToHide =
+        CollectionsService.instance.collectionsHiddenFromTimeline();
+    fileLoadResult = FilesDB.instance.getAllUploadedAndSharedFiles(
+      galleryLoadStartTime,
+      galleryLoadEndTime,
+      limit: null,
+      asc: false,
+      ignoredCollectionIDs: collectionsToHide,
+    );
+    removeIgnoredFiles =
+        FilesService.instance.removeIgnoredFiles(fileLoadResult);
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    const galleryFilesLimit = 1000;
+    final selectedRadius = _selectedRadius();
+    Future<FileLoadResult> filterFiles() async {
+      final FileLoadResult result = await fileLoadResult;
+      //wait for ignored files to be removed after init
+      await removeIgnoredFiles;
+      final stopWatch = Stopwatch()..start();
+      final copyOfFiles = List<File>.from(result.files);
+      copyOfFiles.removeWhere((f) {
+        return !LocationService.instance.isFileInsideLocationTag(
+          InheritedLocationTagData.of(context).centerPoint,
+          f.location!,
+          selectedRadius,
+        );
+      });
+      dev.log(
+        "Time taken to get all files in a location tag: ${stopWatch.elapsedMilliseconds} ms",
+      );
+      stopWatch.stop();
+      widget.memoriesCountNotifier.value = copyOfFiles.length;
+      final limitedResults = copyOfFiles.take(galleryFilesLimit).toList();
+
+      return Future.value(
+        FileLoadResult(
+          limitedResults,
+          result.hasMore,
+        ),
+      );
+    }
+
+    return FutureBuilder(
+      //Only rebuild Gallery if the center point or radius changes
+      key: ValueKey(
+        "${InheritedLocationTagData.of(context).centerPoint}$selectedRadius",
+      ),
+      builder: (context, snapshot) {
+        if (snapshot.hasData) {
+          return SizedBox(
+            height: _galleryHeight(
+              min(
+                (widget.memoriesCountNotifier.value ?? 0),
+                galleryFilesLimit,
+              ),
+            ),
+            child: Gallery(
+              loadingWidget: const SizedBox.shrink(),
+              disableScroll: true,
+              asyncLoader: (
+                creationStartTime,
+                creationEndTime, {
+                limit,
+                asc,
+              }) async {
+                return snapshot.data as FileLoadResult;
+              },
+              tagPrefix: widget.tagPrefix,
+              shouldCollateFilesByDay: false,
+            ),
+          );
+        } else {
+          return const SizedBox.shrink();
+        }
+      },
+      future: filterFiles(),
+    );
+  }
+
+  int _selectedRadius() {
+    return radiusValues[
+        InheritedLocationTagData.of(context).selectedRadiusIndex];
+  }
+
+  double _galleryHeight(int fileCount) {
+    final photoGridSize = LocalSettings.instance.getPhotoGridSize();
+    final totalWhiteSpaceBetweenPhotos =
+        galleryGridSpacing * (photoGridSize - 1);
+
+    final thumbnailHeight =
+        ((MediaQuery.of(context).size.width - totalWhiteSpaceBetweenPhotos) /
+            photoGridSize);
+
+    final numberOfRows = (fileCount / photoGridSize).ceil();
+
+    final galleryHeight = (thumbnailHeight * numberOfRows) +
+        (galleryGridSpacing * (numberOfRows - 1));
+    return galleryHeight + 120;
+  }
+}

+ 69 - 0
lib/ui/viewer/location/edit_center_point_tile_widget.dart

@@ -0,0 +1,69 @@
+import "package:flutter/material.dart";
+import "package:photos/models/file.dart";
+import "package:photos/services/location_service.dart";
+import "package:photos/states/location_state.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/viewer/location/pick_center_point_widget.dart";
+
+class EditCenterPointTileWidget extends StatelessWidget {
+  const EditCenterPointTileWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final textTheme = getEnteTextTheme(context);
+    final colorScheme = getEnteColorScheme(context);
+    return Row(
+      children: [
+        Container(
+          width: 48,
+          height: 48,
+          color: colorScheme.fillFaint,
+          child: Icon(
+            Icons.location_on_outlined,
+            color: colorScheme.strokeFaint,
+          ),
+        ),
+        Expanded(
+          child: Padding(
+            padding: const EdgeInsets.fromLTRB(12, 4.5, 16, 4.5),
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              mainAxisAlignment: MainAxisAlignment.start,
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(
+                  "Center point",
+                  style: textTheme.body,
+                ),
+                const SizedBox(height: 4),
+                Text(
+                  LocationService.instance.convertLocationToDMS(
+                    InheritedLocationTagData.of(context)
+                        .locationTagEntity!
+                        .item
+                        .centerPoint,
+                  ),
+                  style: textTheme.miniMuted,
+                ),
+              ],
+            ),
+          ),
+        ),
+        IconButton(
+          onPressed: () async {
+            final File? centerPointFile = await showPickCenterPointSheet(
+              context,
+              InheritedLocationTagData.of(context).locationTagEntity!,
+            );
+            if (centerPointFile != null) {
+              InheritedLocationTagData.of(context)
+                  .updateCenterPoint(centerPointFile.location!);
+            }
+          },
+          icon: const Icon(Icons.edit),
+          color: getEnteColorScheme(context).strokeMuted,
+        ),
+      ],
+    );
+  }
+}

+ 274 - 0
lib/ui/viewer/location/edit_location_sheet.dart

@@ -0,0 +1,274 @@
+import 'package:flutter/material.dart';
+import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
+import "package:photos/core/constants.dart";
+import "package:photos/models/local_entity_data.dart";
+import "package:photos/models/location_tag/location_tag.dart";
+import "package:photos/services/location_service.dart";
+import "package:photos/states/location_state.dart";
+import "package:photos/theme/colors.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/common/loading_widget.dart";
+import "package:photos/ui/components/bottom_of_title_bar_widget.dart";
+import "package:photos/ui/components/buttons/button_widget.dart";
+import "package:photos/ui/components/divider_widget.dart";
+import "package:photos/ui/components/keyboard/keybiard_oveylay.dart";
+import "package:photos/ui/components/keyboard/keyboard_top_button.dart";
+import "package:photos/ui/components/models/button_type.dart";
+import "package:photos/ui/components/text_input_widget.dart";
+import "package:photos/ui/components/title_bar_title_widget.dart";
+import 'package:photos/ui/viewer/location/dynamic_location_gallery_widget.dart';
+import "package:photos/ui/viewer/location/edit_center_point_tile_widget.dart";
+import "package:photos/ui/viewer/location/radius_picker_widget.dart";
+
+showEditLocationSheet(
+  BuildContext context,
+  LocalEntity<LocationTag> locationTagEntity,
+) {
+  showBarModalBottomSheet(
+    context: context,
+    builder: (context) {
+      return LocationTagStateProvider(
+        locationTagEntity: locationTagEntity,
+        const EditLocationSheet(),
+      );
+    },
+    shape: const RoundedRectangleBorder(
+      side: BorderSide(width: 0),
+      borderRadius: BorderRadius.vertical(
+        top: Radius.circular(5),
+      ),
+    ),
+    topControl: const SizedBox.shrink(),
+    backgroundColor: getEnteColorScheme(context).backgroundElevated,
+    barrierColor: backdropFaintDark,
+  );
+}
+
+class EditLocationSheet extends StatefulWidget {
+  const EditLocationSheet({
+    super.key,
+  });
+
+  @override
+  State<EditLocationSheet> createState() => _EditLocationSheetState();
+}
+
+class _EditLocationSheetState extends State<EditLocationSheet> {
+  //The value of these notifiers has no significance.
+  //When memoriesCountNotifier is null, we show the loading widget in the
+  //memories count section which also means the gallery is loading.
+  final ValueNotifier<int?> _memoriesCountNotifier = ValueNotifier(null);
+  final ValueNotifier<bool> _submitNotifer = ValueNotifier(false);
+  final ValueNotifier<bool> _cancelNotifier = ValueNotifier(false);
+  final ValueNotifier<int> _selectedRadiusIndexNotifier =
+      ValueNotifier(defaultRadiusValueIndex);
+  final _focusNode = FocusNode();
+  final _textEditingController = TextEditingController();
+  final _isEmptyNotifier = ValueNotifier(false);
+  Widget? _keyboardTopButtons;
+
+  @override
+  void initState() {
+    _focusNode.addListener(_focusNodeListener);
+    _selectedRadiusIndexNotifier.addListener(_selectedRadiusIndexListener);
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    _focusNode.removeListener(_focusNodeListener);
+    _submitNotifer.dispose();
+    _cancelNotifier.dispose();
+    _selectedRadiusIndexNotifier.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final textTheme = getEnteTextTheme(context);
+    final colorScheme = getEnteColorScheme(context);
+    final locationName =
+        InheritedLocationTagData.of(context).locationTagEntity!.item.name;
+    return Padding(
+      padding: const EdgeInsets.fromLTRB(0, 32, 0, 8),
+      child: Column(
+        children: [
+          const Padding(
+            padding: EdgeInsets.only(bottom: 16),
+            child: BottomOfTitleBarWidget(
+              title: TitleBarTitleWidget(title: "Edit location"),
+            ),
+          ),
+          Expanded(
+            child: SingleChildScrollView(
+              physics: const BouncingScrollPhysics(
+                decelerationRate: ScrollDecelerationRate.fast,
+              ),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  Padding(
+                    padding: const EdgeInsets.symmetric(horizontal: 16),
+                    child: Column(
+                      children: [
+                        Row(
+                          children: [
+                            Expanded(
+                              child: TextInputWidget(
+                                hintText: "Location name",
+                                borderRadius: 2,
+                                focusNode: _focusNode,
+                                submitNotifier: _submitNotifer,
+                                cancelNotifier: _cancelNotifier,
+                                popNavAfterSubmission: false,
+                                shouldUnfocusOnClearOrSubmit: true,
+                                alwaysShowSuccessState: true,
+                                initialValue: locationName,
+                                onCancel: () {
+                                  _focusNode.unfocus();
+                                  _textEditingController.value =
+                                      TextEditingValue(text: locationName);
+                                },
+                                textEditingController: _textEditingController,
+                                isEmptyNotifier: _isEmptyNotifier,
+                              ),
+                            ),
+                            const SizedBox(width: 8),
+                            ValueListenableBuilder(
+                              valueListenable: _isEmptyNotifier,
+                              builder: (context, bool value, _) {
+                                return AnimatedSwitcher(
+                                  duration: const Duration(milliseconds: 250),
+                                  switchInCurve: Curves.easeInOut,
+                                  switchOutCurve: Curves.easeInOut,
+                                  child: ButtonWidget(
+                                    key: ValueKey(value),
+                                    buttonType: ButtonType.secondary,
+                                    buttonSize: ButtonSize.small,
+                                    labelText: "Save",
+                                    isDisabled: value,
+                                    onTap: () async {
+                                      _focusNode.unfocus();
+                                      await _editLocation();
+                                    },
+                                  ),
+                                );
+                              },
+                            ),
+                          ],
+                        ),
+                        const SizedBox(height: 20),
+                        const EditCenterPointTileWidget(),
+                        const SizedBox(height: 20),
+                        RadiusPickerWidget(
+                          _selectedRadiusIndexNotifier,
+                        ),
+                        const SizedBox(height: 24),
+                      ],
+                    ),
+                  ),
+                  const DividerWidget(
+                    dividerType: DividerType.solid,
+                    padding: EdgeInsets.only(top: 24, bottom: 20),
+                  ),
+                  SizedBox(
+                    width: double.infinity,
+                    child: Padding(
+                      padding: const EdgeInsets.symmetric(horizontal: 16),
+                      child: ValueListenableBuilder(
+                        valueListenable: _memoriesCountNotifier,
+                        builder: (context, value, _) {
+                          Widget widget;
+                          if (value == null) {
+                            widget = RepaintBoundary(
+                              child: EnteLoadingWidget(
+                                size: 14,
+                                color: colorScheme.strokeMuted,
+                                alignment: Alignment.centerLeft,
+                                padding: 3,
+                              ),
+                            );
+                          } else {
+                            widget = Column(
+                              mainAxisSize: MainAxisSize.min,
+                              crossAxisAlignment: CrossAxisAlignment.start,
+                              children: [
+                                Text(
+                                  value == 1 ? "1 memory" : "$value memories",
+                                  style: textTheme.body,
+                                ),
+                                if (value as int > 1000)
+                                  Padding(
+                                    padding: const EdgeInsets.only(top: 2),
+                                    child: Text(
+                                      "Up to 1000 memories shown in gallery",
+                                      style: textTheme.miniMuted,
+                                    ),
+                                  ),
+                              ],
+                            );
+                          }
+                          return Align(
+                            alignment: Alignment.centerLeft,
+                            child: AnimatedSwitcher(
+                              duration: const Duration(milliseconds: 250),
+                              switchInCurve: Curves.easeInOutExpo,
+                              switchOutCurve: Curves.easeInOutExpo,
+                              child: widget,
+                            ),
+                          );
+                        },
+                      ),
+                    ),
+                  ),
+                  const SizedBox(height: 24),
+                  DynamicLocationGalleryWidget(
+                    _memoriesCountNotifier,
+                    "Edit_location",
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Future<void> _editLocation() async {
+    final locationTagState = InheritedLocationTagData.of(context);
+    await LocationService.instance.updateLocationTag(
+      locationTagEntity: locationTagState.locationTagEntity!,
+      newRadius: radiusValues[locationTagState.selectedRadiusIndex],
+      newName: _textEditingController.text.trim(),
+      newCenterPoint: InheritedLocationTagData.of(context).centerPoint,
+    );
+    Navigator.of(context).pop();
+  }
+
+  void _focusNodeListener() {
+    final bool hasFocus = _focusNode.hasFocus;
+    _keyboardTopButtons ??= KeyboardTopButton(
+      onDoneTap: () {
+        _submitNotifer.value = !_submitNotifer.value;
+      },
+      onCancelTap: () {
+        _cancelNotifier.value = !_cancelNotifier.value;
+      },
+    );
+    if (hasFocus) {
+      KeyboardOverlay.showOverlay(context, _keyboardTopButtons!);
+    } else {
+      KeyboardOverlay.removeOverlay();
+    }
+  }
+
+  void _selectedRadiusIndexListener() {
+    InheritedLocationTagData.of(
+      context,
+    ).updateSelectedIndex(
+      _selectedRadiusIndexNotifier.value,
+    );
+    _memoriesCountNotifier.value = null;
+  }
+}

+ 300 - 0
lib/ui/viewer/location/location_screen.dart

@@ -0,0 +1,300 @@
+import 'dart:developer' as dev;
+import "package:flutter/material.dart";
+import "package:photos/core/constants.dart";
+import "package:photos/core/event_bus.dart";
+import "package:photos/db/files_db.dart";
+import "package:photos/events/files_updated_event.dart";
+import "package:photos/events/local_photos_updated_event.dart";
+import "package:photos/models/file.dart";
+import "package:photos/models/file_load_result.dart";
+import "package:photos/models/gallery_type.dart";
+import "package:photos/models/selected_files.dart";
+import "package:photos/services/collections_service.dart";
+import "package:photos/services/files_service.dart";
+import "package:photos/services/location_service.dart";
+import "package:photos/states/location_screen_state.dart";
+import "package:photos/theme/colors.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/common/loading_widget.dart";
+import "package:photos/ui/components/buttons/icon_button_widget.dart";
+import "package:photos/ui/components/title_bar_title_widget.dart";
+import "package:photos/ui/components/title_bar_widget.dart";
+import "package:photos/ui/viewer/actions/file_selection_overlay_bar.dart";
+import "package:photos/ui/viewer/gallery/gallery.dart";
+import "package:photos/ui/viewer/location/edit_location_sheet.dart";
+import "package:photos/utils/dialog_util.dart";
+
+class LocationScreen extends StatelessWidget {
+  const LocationScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: const PreferredSize(
+        preferredSize: Size(double.infinity, 48),
+        child: TitleBarWidget(
+          isSliver: false,
+          isFlexibleSpaceDisabled: true,
+          actionIcons: [LocationScreenPopUpMenu()],
+        ),
+      ),
+      body: Column(
+        children: <Widget>[
+          SizedBox(
+            height: MediaQuery.of(context).size.height - 102,
+            width: double.infinity,
+            child: const LocationGalleryWidget(),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class LocationScreenPopUpMenu extends StatelessWidget {
+  const LocationScreenPopUpMenu({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final textTheme = getEnteTextTheme(context);
+    final colorScheme = getEnteColorScheme(context);
+    return Padding(
+      padding: const EdgeInsets.only(right: 4),
+      child: Theme(
+        data: Theme.of(context).copyWith(
+          highlightColor: Colors.transparent,
+          splashColor: Colors.transparent,
+        ),
+        child: PopupMenuButton(
+          elevation: 2,
+          offset: const Offset(10, 50),
+          shape: RoundedRectangleBorder(
+            borderRadius: BorderRadius.circular(8),
+          ),
+          color: colorScheme.backgroundElevated2,
+          child: const IconButtonWidget(
+            icon: Icons.more_horiz,
+            iconButtonType: IconButtonType.primary,
+            disableGestureDetector: true,
+          ),
+          itemBuilder: (context) {
+            return [
+              PopupMenuItem(
+                value: "edit",
+                child: Text(
+                  "Edit",
+                  style: textTheme.bodyBold,
+                ),
+              ),
+              PopupMenuItem(
+                onTap: () {},
+                value: "delete",
+                child: Text(
+                  "Delete Location",
+                  style: textTheme.bodyBold.copyWith(color: warning500),
+                ),
+              ),
+            ];
+          },
+          onSelected: (value) async {
+            if (value == "edit") {
+              showEditLocationSheet(
+                context,
+                InheritedLocationScreenState.of(context).locationTagEntity,
+              );
+            } else if (value == "delete") {
+              try {
+                await LocationService.instance.deleteLocationTag(
+                  InheritedLocationScreenState.of(context).locationTagEntity.id,
+                );
+                Navigator.of(context).pop();
+              } catch (e) {
+                showGenericErrorDialog(context: context);
+              }
+            }
+          },
+        ),
+      ),
+    );
+  }
+}
+
+class LocationGalleryWidget extends StatefulWidget {
+  const LocationGalleryWidget({super.key});
+
+  @override
+  State<LocationGalleryWidget> createState() => _LocationGalleryWidgetState();
+}
+
+class _LocationGalleryWidgetState extends State<LocationGalleryWidget> {
+  late final Future<FileLoadResult> fileLoadResult;
+  late Future<void> removeIgnoredFiles;
+  late Widget galleryHeaderWidget;
+  final _selectedFiles = SelectedFiles();
+  @override
+  void initState() {
+    final collectionsToHide =
+        CollectionsService.instance.collectionsHiddenFromTimeline();
+    fileLoadResult = FilesDB.instance.getAllUploadedAndSharedFiles(
+      galleryLoadStartTime,
+      galleryLoadEndTime,
+      limit: null,
+      asc: false,
+      ignoredCollectionIDs: collectionsToHide,
+    );
+    removeIgnoredFiles =
+        FilesService.instance.removeIgnoredFiles(fileLoadResult);
+    galleryHeaderWidget = const GalleryHeaderWidget();
+    super.initState();
+  }
+
+  @override
+  void dispose() {
+    InheritedLocationScreenState.memoryCountNotifier.value = null;
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final selectedRadius =
+        InheritedLocationScreenState.of(context).locationTagEntity.item.radius;
+    final centerPoint = InheritedLocationScreenState.of(context)
+        .locationTagEntity
+        .item
+        .centerPoint;
+    Future<FileLoadResult> filterFiles() async {
+      final FileLoadResult result = await fileLoadResult;
+      //wait for ignored files to be removed after init
+      await removeIgnoredFiles;
+      final stopWatch = Stopwatch()..start();
+      final copyOfFiles = List<File>.from(result.files);
+      copyOfFiles.removeWhere((f) {
+        return !LocationService.instance.isFileInsideLocationTag(
+          centerPoint,
+          f.location!,
+          selectedRadius,
+        );
+      });
+      dev.log(
+        "Time taken to get all files in a location tag: ${stopWatch.elapsedMilliseconds} ms",
+      );
+      stopWatch.stop();
+      InheritedLocationScreenState.memoryCountNotifier.value =
+          copyOfFiles.length;
+
+      return Future.value(
+        FileLoadResult(
+          copyOfFiles,
+          result.hasMore,
+        ),
+      );
+    }
+
+    return FutureBuilder(
+      //rebuild gallery only when there is change in radius or center point
+      key: ValueKey("$centerPoint$selectedRadius"),
+      builder: (context, snapshot) {
+        if (snapshot.hasData) {
+          return Stack(
+            children: [
+              Gallery(
+                loadingWidget: Column(
+                  children: [
+                    galleryHeaderWidget,
+                    EnteLoadingWidget(
+                      color: getEnteColorScheme(context).strokeMuted,
+                    ),
+                  ],
+                ),
+                header: galleryHeaderWidget,
+                asyncLoader: (
+                  creationStartTime,
+                  creationEndTime, {
+                  limit,
+                  asc,
+                }) async {
+                  return snapshot.data as FileLoadResult;
+                },
+                reloadEvent: Bus.instance.on<LocalPhotosUpdatedEvent>(),
+                removalEventTypes: const {
+                  EventType.deletedFromRemote,
+                  EventType.deletedFromEverywhere,
+                },
+                selectedFiles: _selectedFiles,
+                tagPrefix: "location_gallery",
+              ),
+              FileSelectionOverlayBar(
+                GalleryType.locationTag,
+                _selectedFiles,
+              )
+            ],
+          );
+        } else {
+          return Column(
+            children: [
+              galleryHeaderWidget,
+              const Expanded(
+                child: EnteLoadingWidget(),
+              ),
+            ],
+          );
+        }
+      },
+      future: filterFiles(),
+    );
+  }
+}
+
+class GalleryHeaderWidget extends StatefulWidget {
+  const GalleryHeaderWidget({super.key});
+
+  @override
+  State<GalleryHeaderWidget> createState() => _GalleryHeaderWidgetState();
+}
+
+class _GalleryHeaderWidgetState extends State<GalleryHeaderWidget> {
+  @override
+  Widget build(BuildContext context) {
+    final locationName =
+        InheritedLocationScreenState.of(context).locationTagEntity.item.name;
+    return Padding(
+      padding: const EdgeInsets.only(bottom: 20),
+      child: Padding(
+        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            SizedBox(
+              key: ValueKey(locationName),
+              width: double.infinity,
+              child: TitleBarTitleWidget(
+                title: locationName,
+              ),
+            ),
+            ValueListenableBuilder(
+              valueListenable: InheritedLocationScreenState.memoryCountNotifier,
+              builder: (context, value, _) {
+                if (value == null) {
+                  return RepaintBoundary(
+                    child: EnteLoadingWidget(
+                      size: 12,
+                      color: getEnteColorScheme(context).strokeMuted,
+                      alignment: Alignment.centerLeft,
+                      padding: 2.5,
+                    ),
+                  );
+                } else {
+                  return Text(
+                    value == 1 ? "1 memory" : "$value memories",
+                    style: getEnteTextTheme(context).smallMuted,
+                  );
+                }
+              },
+            )
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 196 - 0
lib/ui/viewer/location/pick_center_point_widget.dart

@@ -0,0 +1,196 @@
+import "dart:math";
+
+import "package:flutter/material.dart";
+import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
+import "package:photos/core/configuration.dart";
+import "package:photos/db/files_db.dart";
+import "package:photos/models/file.dart";
+import "package:photos/models/file_load_result.dart";
+import "package:photos/models/local_entity_data.dart";
+import "package:photos/models/location_tag/location_tag.dart";
+import "package:photos/models/selected_files.dart";
+import "package:photos/services/collections_service.dart";
+import "package:photos/services/ignored_files_service.dart";
+import "package:photos/theme/colors.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/components/bottom_of_title_bar_widget.dart";
+import "package:photos/ui/components/buttons/button_widget.dart";
+import "package:photos/ui/components/models/button_type.dart";
+import "package:photos/ui/components/title_bar_title_widget.dart";
+import "package:photos/ui/viewer/gallery/gallery.dart";
+
+Future<File?> showPickCenterPointSheet(
+  BuildContext context,
+  LocalEntity<LocationTag> locationTagEntity,
+) async {
+  return await showBarModalBottomSheet(
+    context: context,
+    builder: (context) {
+      return PickCenterPointWidget(locationTagEntity);
+    },
+    shape: const RoundedRectangleBorder(
+      side: BorderSide(width: 0),
+      borderRadius: BorderRadius.vertical(
+        top: Radius.circular(5),
+      ),
+    ),
+    topControl: const SizedBox.shrink(),
+    backgroundColor: getEnteColorScheme(context).backgroundElevated,
+    barrierColor: backdropFaintDark,
+    enableDrag: false,
+  );
+}
+
+class PickCenterPointWidget extends StatelessWidget {
+  final LocalEntity<LocationTag> locationTagEntity;
+
+  const PickCenterPointWidget(
+    this.locationTagEntity, {
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final ValueNotifier<bool> isFileSelected = ValueNotifier(false);
+    final selectedFiles = SelectedFiles();
+    selectedFiles.addListener(() {
+      isFileSelected.value = selectedFiles.files.isNotEmpty;
+    });
+
+    return Padding(
+      padding: const EdgeInsets.all(0),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          ConstrainedBox(
+            constraints: BoxConstraints(
+              maxWidth: min(428, MediaQuery.of(context).size.width),
+            ),
+            child: Padding(
+              padding: const EdgeInsets.fromLTRB(0, 32, 0, 8),
+              child: Column(
+                mainAxisSize: MainAxisSize.max,
+                children: [
+                  Expanded(
+                    child: Column(
+                      children: [
+                        BottomOfTitleBarWidget(
+                          title: const TitleBarTitleWidget(
+                            title: "Pick center point",
+                          ),
+                          caption: locationTagEntity.item.name,
+                        ),
+                        Expanded(
+                          child: Gallery(
+                            asyncLoader: (
+                              creationStartTime,
+                              creationEndTime, {
+                              limit,
+                              asc,
+                            }) async {
+                              final ownerID =
+                                  Configuration.instance.getUserID();
+                              final hasSelectedAllForBackup = Configuration
+                                  .instance
+                                  .hasSelectedAllFoldersForBackup();
+                              final collectionsToHide = CollectionsService
+                                  .instance
+                                  .collectionsHiddenFromTimeline();
+                              FileLoadResult result;
+                              if (hasSelectedAllForBackup) {
+                                result = await FilesDB.instance
+                                    .getAllLocalAndUploadedFiles(
+                                  creationStartTime,
+                                  creationEndTime,
+                                  ownerID!,
+                                  limit: limit,
+                                  asc: asc,
+                                  ignoredCollectionIDs: collectionsToHide,
+                                );
+                              } else {
+                                result = await FilesDB.instance
+                                    .getAllPendingOrUploadedFiles(
+                                  creationStartTime,
+                                  creationEndTime,
+                                  ownerID!,
+                                  limit: limit,
+                                  asc: asc,
+                                  ignoredCollectionIDs: collectionsToHide,
+                                );
+                              }
+
+                              // hide ignored files from home page UI
+                              final ignoredIDs =
+                                  await IgnoredFilesService.instance.ignoredIDs;
+                              result.files.removeWhere(
+                                (f) =>
+                                    f.uploadedFileID == null &&
+                                    IgnoredFilesService.instance
+                                        .shouldSkipUpload(ignoredIDs, f),
+                              );
+                              return result;
+                            },
+                            tagPrefix: "pick_center_point_gallery",
+                            selectedFiles: selectedFiles,
+                            limitSelectionToOne: true,
+                          ),
+                        ),
+                      ],
+                    ),
+                  ),
+                  SafeArea(
+                    child: Container(
+                      //inner stroke of 1pt + 15 pts of top padding = 16 pts
+                      padding: const EdgeInsets.fromLTRB(16, 15, 16, 8),
+                      decoration: BoxDecoration(
+                        border: Border(
+                          top: BorderSide(
+                            color: getEnteColorScheme(context).strokeFaint,
+                          ),
+                        ),
+                      ),
+                      child: Column(
+                        children: [
+                          ValueListenableBuilder(
+                            valueListenable: isFileSelected,
+                            builder: (context, bool value, _) {
+                              return AnimatedSwitcher(
+                                duration: const Duration(milliseconds: 300),
+                                switchInCurve: Curves.easeInOutExpo,
+                                switchOutCurve: Curves.easeInOutExpo,
+                                child: ButtonWidget(
+                                  key: ValueKey(value),
+                                  isDisabled: !value,
+                                  buttonType: ButtonType.neutral,
+                                  labelText: "Use selected photo",
+                                  onTap: () async {
+                                    final selectedFile =
+                                        selectedFiles.files.first;
+                                    Navigator.pop(context, selectedFile);
+                                  },
+                                ),
+                              );
+                            },
+                          ),
+                          const SizedBox(height: 8),
+                          ButtonWidget(
+                            buttonType: ButtonType.secondary,
+                            buttonAction: ButtonAction.cancel,
+                            labelText: "Cancel",
+                            onTap: () async {
+                              Navigator.of(context).pop();
+                            },
+                          ),
+                        ],
+                      ),
+                    ),
+                  )
+                ],
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 142 - 0
lib/ui/viewer/location/radius_picker_widget.dart

@@ -0,0 +1,142 @@
+import "package:flutter/material.dart";
+import "package:photos/core/constants.dart";
+import "package:photos/states/location_state.dart";
+import "package:photos/theme/colors.dart";
+import "package:photos/theme/ente_theme.dart";
+
+class CustomTrackShape extends RoundedRectSliderTrackShape {
+  @override
+  Rect getPreferredRect({
+    required RenderBox parentBox,
+    Offset offset = Offset.zero,
+    required SliderThemeData sliderTheme,
+    bool isEnabled = false,
+    bool isDiscrete = false,
+  }) {
+    const trackHeight = 2.0;
+    final trackWidth = parentBox.size.width;
+    return Rect.fromLTWH(0, 0, trackWidth, trackHeight);
+  }
+}
+
+class RadiusPickerWidget extends StatefulWidget {
+  ///This notifier can be listened to get the selected radius index from
+  ///a parent widget.
+  final ValueNotifier<int> selectedRadiusIndexNotifier;
+  const RadiusPickerWidget(
+    this.selectedRadiusIndexNotifier, {
+    super.key,
+  });
+
+  @override
+  State<RadiusPickerWidget> createState() => _RadiusPickerWidgetState();
+}
+
+class _RadiusPickerWidgetState extends State<RadiusPickerWidget> {
+  @override
+  void initState() {
+    super.initState();
+  }
+
+  @override
+  void didChangeDependencies() {
+    widget.selectedRadiusIndexNotifier.value =
+        InheritedLocationTagData.of(context).selectedRadiusIndex;
+    super.didChangeDependencies();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final selectedRadiusIndex = widget.selectedRadiusIndexNotifier.value;
+    final radiusValue = radiusValues[selectedRadiusIndex];
+    final textTheme = getEnteTextTheme(context);
+    final colorScheme = getEnteColorScheme(context);
+    return Row(
+      children: [
+        Container(
+          height: 48,
+          width: 48,
+          decoration: BoxDecoration(
+            color: colorScheme.fillFaint,
+            borderRadius: const BorderRadius.all(Radius.circular(2)),
+          ),
+          padding: const EdgeInsets.all(4),
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            mainAxisAlignment: MainAxisAlignment.center,
+            crossAxisAlignment: CrossAxisAlignment.center,
+            children: [
+              Expanded(
+                flex: 6,
+                child: Text(
+                  radiusValue.toString(),
+                  style: radiusValue != 1200
+                      ? textTheme.largeBold
+                      : textTheme.bodyBold,
+                  textAlign: TextAlign.center,
+                ),
+              ),
+              Expanded(
+                flex: 5,
+                child: Text(
+                  "km",
+                  style: textTheme.miniMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(width: 4),
+        Expanded(
+          child: Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 8),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                Text("Radius", style: textTheme.body),
+                const SizedBox(height: 10),
+                SizedBox(
+                  height: 12,
+                  child: SliderTheme(
+                    data: SliderThemeData(
+                      overlayColor: Colors.transparent,
+                      thumbColor: strokeSolidMutedLight,
+                      activeTrackColor: strokeSolidMutedLight,
+                      inactiveTrackColor: colorScheme.strokeFaint,
+                      activeTickMarkColor: colorScheme.strokeMuted,
+                      inactiveTickMarkColor: strokeSolidMutedLight,
+                      trackShape: CustomTrackShape(),
+                      thumbShape: const RoundSliderThumbShape(
+                        enabledThumbRadius: 6,
+                        pressedElevation: 0,
+                        elevation: 0,
+                      ),
+                      tickMarkShape: const RoundSliderTickMarkShape(
+                        tickMarkRadius: 1,
+                      ),
+                    ),
+                    child: RepaintBoundary(
+                      child: Slider(
+                        value: selectedRadiusIndex.toDouble(),
+                        onChanged: (value) {
+                          setState(() {
+                            widget.selectedRadiusIndexNotifier.value =
+                                value.toInt();
+                          });
+                        },
+                        min: 0,
+                        max: radiusValues.length - 1,
+                        divisions: radiusValues.length - 1,
+                      ),
+                    ),
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 9 - 7
lib/ui/viewer/search/search_widget.dart

@@ -5,7 +5,6 @@ import 'package:logging/logging.dart';
 import 'package:photos/ente_theme_data.dart';
 import "package:photos/generated/l10n.dart";
 import 'package:photos/models/search/search_result.dart';
-import 'package:photos/services/feature_flag_service.dart';
 import 'package:photos/services/search_service.dart';
 import 'package:photos/ui/components/buttons/icon_button_widget.dart';
 import 'package:photos/ui/viewer/search/result/no_result_widget.dart';
@@ -215,16 +214,19 @@ class _SearchWidgetState extends State<SearchWidget> {
           await _searchService.getFileExtensionResults(query);
       allResults.addAll(fileExtnResult);
 
+      final locationResult = await _searchService.getLocationResults(query);
+      allResults.addAll(locationResult);
+
       final collectionResults =
           await _searchService.getCollectionSearchResults(query);
       allResults.addAll(collectionResults);
 
-      if (FeatureFlagService.instance.isInternalUserOrDebugBuild() &&
-          query.startsWith("l:")) {
-        final locationResults = await _searchService
-            .getLocationSearchResults(query.replaceAll("l:", ""));
-        allResults.addAll(locationResults);
-      }
+      // if (FeatureFlagService.instance.isInternalUserOrDebugBuild() &&
+      //     query.startsWith("l:")) {
+      //   final locationResults = await _searchService
+      //       .getLocationSearchResults(query.replaceAll("l:", ""));
+      //   allResults.addAll(locationResults);
+      // }
 
       final monthResults = await _searchService.getMonthSearchResults(query);
       allResults.addAll(monthResults);

+ 3 - 2
lib/utils/file_uploader_util.dart

@@ -13,7 +13,7 @@ import 'package:photos/core/constants.dart';
 import 'package:photos/core/errors.dart';
 import 'package:photos/models/file.dart' as ente;
 import 'package:photos/models/file_type.dart';
-import 'package:photos/models/location.dart';
+import 'package:photos/models/location/location.dart';
 import 'package:photos/utils/crypto_util.dart';
 import 'package:photos/utils/file_util.dart';
 import 'package:video_thumbnail/video_thumbnail.dart';
@@ -155,7 +155,8 @@ Future<void> _decorateEnteFileData(ente.File file, AssetEntity asset) async {
   if (file.location == null ||
       (file.location!.latitude == 0 && file.location!.longitude == 0)) {
     final latLong = await asset.latlngAsync();
-    file.location = Location(latLong.latitude, latLong.longitude);
+    file.location =
+        Location(latitude: latLong.latitude, longitude: latLong.longitude);
   }
 
   if (file.title == null || file.title!.isEmpty) {

+ 14 - 0
lib/utils/lat_lon_util.dart

@@ -0,0 +1,14 @@
+String convertLatLng(double decimal, bool isLat) {
+  final degree = "${decimal.toString().split(".")[0]}°";
+  final minutesBeforeConversion =
+      double.parse("0.${decimal.toString().split(".")[1]}");
+  final minutes = "${(minutesBeforeConversion * 60).toString().split('.')[0]}'";
+  final secondsBeforeConversion = double.parse(
+    "0.${(minutesBeforeConversion * 60).toString().split('.')[1]}",
+  );
+  final seconds =
+      '${double.parse((secondsBeforeConversion * 60).toString()).toStringAsFixed(0)}" ';
+  final dmsOutput =
+      "$degree$minutes$seconds${isLat ? decimal > 0 ? 'N' : 'S' : decimal > 0 ? 'E' : 'W'}";
+  return dmsOutput;
+}

+ 161 - 1
pubspec.lock

@@ -97,6 +97,70 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.1"
+  build:
+    dependency: transitive
+    description:
+      name: build
+      sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.1"
+  build_config:
+    dependency: transitive
+    description:
+      name: build_config
+      sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.1"
+  build_daemon:
+    dependency: transitive
+    description:
+      name: build_daemon
+      sha256: "757153e5d9cd88253cb13f28c2fb55a537dc31fefd98137549895b5beb7c6169"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.1"
+  build_resolvers:
+    dependency: transitive
+    description:
+      name: build_resolvers
+      sha256: db49b8609ef8c81cca2b310618c3017c00f03a92af44c04d310b907b2d692d95
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.0"
+  build_runner:
+    dependency: "direct dev"
+    description:
+      name: build_runner
+      sha256: b0a8a7b8a76c493e85f1b84bffa0588859a06197863dba8c9036b15581fd9727
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.3"
+  build_runner_core:
+    dependency: transitive
+    description:
+      name: build_runner_core
+      sha256: "14febe0f5bac5ae474117a36099b4de6f1dbc52df6c5e55534b3da9591bf4292"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.2.7"
+  built_collection:
+    dependency: transitive
+    description:
+      name: built_collection
+      sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
+      url: "https://pub.dev"
+    source: hosted
+    version: "5.1.1"
+  built_value:
+    dependency: transitive
+    description:
+      name: built_value
+      sha256: "31b7c748fd4b9adf8d25d72a4c4a59ef119f12876cf414f94f8af5131d5fa2b0"
+      url: "https://pub.dev"
+    source: hosted
+    version: "8.4.4"
   cached_network_image:
     dependency: "direct main"
     description:
@@ -169,6 +233,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.2.1"
+  checked_yaml:
+    dependency: transitive
+    description:
+      name: checked_yaml
+      sha256: "3d1505d91afa809d177efd4eed5bb0eb65805097a1463abdd2add076effae311"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.2"
   chewie:
     dependency: "direct main"
     description:
@@ -184,6 +256,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.1.1"
+  code_builder:
+    dependency: transitive
+    description:
+      name: code_builder
+      sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.4.0"
   collection:
     dependency: "direct main"
     description:
@@ -272,6 +352,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.5"
+  dart_style:
+    dependency: transitive
+    description:
+      name: dart_style
+      sha256: "5be16bf1707658e4c03078d4a9b90208ded217fb02c163e207d334082412f2fb"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.5"
   dbus:
     dependency: transitive
     description:
@@ -472,6 +560,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "3.2.12"
+  fixnum:
+    dependency: transitive
+    description:
+      name: fixnum
+      sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
   fk_user_agent:
     dependency: "direct main"
     description:
@@ -756,6 +852,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "8.1.3"
+  freezed:
+    dependency: "direct dev"
+    description:
+      name: freezed
+      sha256: e819441678f1679b719008ff2ff0ef045d66eed9f9ec81166ca0d9b02a187454
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  freezed_annotation:
+    dependency: "direct main"
+    description:
+      name: freezed_annotation
+      sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.0"
   frontend_server_client:
     dependency: transitive
     description:
@@ -780,6 +892,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "5.0.6"
+  graphs:
+    dependency: transitive
+    description:
+      name: graphs
+      sha256: f9e130f3259f52d26f0cfc0e964513796dafed572fa52e45d2f8d6ca14db39b2
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.0"
   hex:
     dependency: transitive
     description:
@@ -917,13 +1037,21 @@ packages:
     source: hosted
     version: "0.6.5"
   json_annotation:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: json_annotation
       sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317
       url: "https://pub.dev"
     source: hosted
     version: "4.8.0"
+  json_serializable:
+    dependency: "direct dev"
+    description:
+      name: json_serializable
+      sha256: dadc08bd61f72559f938dd08ec20dbfec6c709bba83515085ea943d2078d187a
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.6.1"
   like_button:
     dependency: "direct main"
     description:
@@ -1349,6 +1477,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.3"
+  pubspec_parse:
+    dependency: transitive
+    description:
+      name: pubspec_parse
+      sha256: ec85d7d55339d85f44ec2b682a82fea340071e8978257e5a43e69f79e98ef50c
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.2"
   quiver:
     dependency: "direct main"
     description:
@@ -1538,6 +1674,22 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.99"
+  source_gen:
+    dependency: transitive
+    description:
+      name: source_gen
+      sha256: c2bea18c95cfa0276a366270afaa2850b09b4a76db95d546f3d003dcc7011298
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.7"
+  source_helper:
+    dependency: transitive
+    description:
+      name: source_helper
+      sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.3"
   source_map_stack_trace:
     dependency: transitive
     description:
@@ -1723,6 +1875,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.8.0"
+  timing:
+    dependency: transitive
+    description:
+      name: timing
+      sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.1"
   tuple:
     dependency: "direct main"
     description:

+ 5 - 0
pubspec.yaml

@@ -68,12 +68,14 @@ dependencies:
   flutter_sodium: ^0.2.0
   flutter_typeahead: ^4.0.0
   fluttertoast: ^8.0.6
+  freezed_annotation: ^2.2.0
   google_nav_bar: ^5.0.5
   http: ^0.13.4
   image: ^3.0.2
   image_editor: ^1.3.0
   in_app_purchase: ^3.0.7
   intl: ^0.17.0
+  json_annotation: ^4.8.0
   like_button: ^2.0.2
   loading_animations: ^2.1.0
   local_auth: ^2.1.5
@@ -134,9 +136,12 @@ dependency_overrides:
   wakelock: ^0.6.1+2
 
 dev_dependencies:
+  build_runner: ^2.3.3
   flutter_lints: ^2.0.1
   flutter_test:
     sdk: flutter
+  freezed: ^2.3.2
+  json_serializable: ^6.6.1
   test:
 
 flutter_icons: