diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..9033b7b16 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "workbench.colorCustomizations": { + "activityBar.background": "#0C227B", + "titleBar.activeBackground": "#1130AC", + "titleBar.activeForeground": "#FCFDFF" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 571d732c7..ebcd1164a 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ You can alternatively install the build from PlayStore or F-Droid. 3. Pull in all submodules with `git submodule update --init --recursive` 4. Enable repo git hooks `git config core.hooksPath hooks` 5. Setup TensorFlowLite by executing `setup.sh` -6. For Android, [setup your keystore](https://docs.flutter.dev/deployment/android#create-an-upload-keystore) and run `flutter build apk --release --flavor independent` -7. For iOS, run `flutter build ios` +6. If using Visual Studio Code, add the [Flutter Intl](https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl) extension +7. For Android, [setup your keystore](https://docs.flutter.dev/deployment/android#create-an-upload-keystore) and run `flutter build apk --release --flavor independent` +8. For iOS, run `flutter build ios`
## 🙋 Help diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 236419b4d..63f62ed39 100644 --- a/lib/core/constants.dart +++ b/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 = [2, 10, 20, 40, 80, 200, 400, 1200]; + +const defaultRadiusValueIndex = 4; + +const galleryGridSpacing = 2.0; diff --git a/lib/db/entities_db.dart b/lib/db/entities_db.dart new file mode 100644 index 000000000..b8b48fbe4 --- /dev/null +++ b/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 upsertEntities( + List 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 deleteEntities( + List 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> getEntities(EntityType type) async { + final db = await database; + final List> maps = await db.query( + "entities", + where: "type = ?", + whereArgs: [type.typeToString()], + ); + return List.generate(maps.length, (i) { + return LocalEntityData.fromJson(maps[i]); + }); + } +} diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index 67f09df9e..fd097b124 100644 --- a/lib/db/files_db.dart +++ b/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 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 addFileSizeColumn() { return [ ''' @@ -485,14 +501,20 @@ class FilesDB { bool? asc, int visibility = visibilityVisible, Set? 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 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? 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 getAllUploadedAndSharedFiles( + int startTime, + int endTime, { + int? limit, + bool? asc, + Set? 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 deduplicatedFiles = + _deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs); + return FileLoadResult(deduplicatedFiles, files.length == limit); + } + Map _getRowForFile(File file) { final row = {}; 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]; diff --git a/lib/events/location_tag_updated_event.dart b/lib/events/location_tag_updated_event.dart new file mode 100644 index 000000000..ae8db761c --- /dev/null +++ b/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>? updatedLocTagEntities; + final LocTagEventType type; + + LocationTagUpdatedEvent(this.type, {this.updatedLocTagEntities}); +} + +enum LocTagEventType { + add, + update, + delete, +} diff --git a/lib/gateways/entity_gw.dart b/lib/gateways/entity_gw.dart new file mode 100644 index 000000000..a88d31eeb --- /dev/null +++ b/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 createKey( + EntityType entityType, + String encKey, + String header, + ) async { + await _enteDio.post( + "/user-entity/key", + data: { + "type": entityType.typeToString(), + "encryptedKey": encKey, + "header": header, + }, + ); + } + + Future 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 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 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 deleteEntity( + String id, + ) async { + await _enteDio.delete( + "/user-entity/entity", + queryParameters: { + "id": id, + }, + ); + } + + Future> 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 authEntities = []; + 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 {} diff --git a/lib/main.dart b/lib/main.dart index 48a4bb42d..f7ec32e06 100644 --- a/lib/main.dart +++ b/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 _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(); diff --git a/lib/models/api/entity/data.dart b/lib/models/api/entity/data.dart new file mode 100644 index 000000000..b46b3bdb7 --- /dev/null +++ b/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 toMap() { + return { + 'id': id, + 'userID': userID, + 'encryptedData': encryptedData, + 'header': header, + 'isDeleted': isDeleted, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; + } + + factory EntityData.fromMap(Map 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)); +} diff --git a/lib/models/api/entity/key.dart b/lib/models/api/entity/key.dart new file mode 100644 index 000000000..58e53e042 --- /dev/null +++ b/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 toMap() { + return { + 'userID': userID, + 'type': type.typeToString(), + 'encryptedKey': encryptedKey, + 'header': header, + 'createdAt': createdAt, + }; + } + + factory EntityKey.fromMap(Map 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)); +} diff --git a/lib/models/api/entity/type.dart b/lib/models/api/entity/type.dart new file mode 100644 index 000000000..3631792de --- /dev/null +++ b/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"; + } + } +} diff --git a/lib/models/file.dart b/lib/models/file.dart index d13897e70..19a98a00d 100644 --- a/lib/models/file.dart +++ b/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; diff --git a/lib/models/gallery_type.dart b/lib/models/gallery_type.dart index d93e42faf..ad29bf7c6 100644 --- a/lib/models/gallery_type.dart +++ b/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: diff --git a/lib/models/local_entity_data.dart b/lib/models/local_entity_data.dart new file mode 100644 index 000000000..9066e16fd --- /dev/null +++ b/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 toJson() { + return { + "id": id, + "type": type.typeToString(), + "data": data, + "ownerID": ownerID, + "updatedAt": updatedAt, + }; + } + + factory LocalEntityData.fromJson(Map 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 extends Equatable { + final T item; + final String id; + + const LocalEntity(this.item, this.id); + + @override + List get props => [item, id]; +} diff --git a/lib/models/location.dart b/lib/models/location.dart deleted file mode 100644 index e81964322..000000000 --- a/lib/models/location.dart +++ /dev/null @@ -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)'; -} diff --git a/lib/models/location/location.dart b/lib/models/location/location.dart new file mode 100644 index 000000000..1349aba44 --- /dev/null +++ b/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 json) => + _$LocationFromJson(json); +} diff --git a/lib/models/location/location.freezed.dart b/lib/models/location/location.freezed.dart new file mode 100644 index 000000000..e3cc1a19d --- /dev/null +++ b/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 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 json) { + return _Location.fromJson(json); +} + +/// @nodoc +mixin _$Location { + double? get latitude => throw _privateConstructorUsedError; + double? get longitude => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $LocationCopyWith 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 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 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 json) = _$_Location.fromJson; + + @override + double? get latitude; + @override + double? get longitude; + @override + @JsonKey(ignore: true) + _$$_LocationCopyWith<_$_Location> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/location/location.g.dart b/lib/models/location/location.g.dart new file mode 100644 index 000000000..fe91798f9 --- /dev/null +++ b/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 json) => _$_Location( + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), + ); + +Map _$$_LocationToJson(_$_Location instance) => + { + 'latitude': instance.latitude, + 'longitude': instance.longitude, + }; diff --git a/lib/models/location_tag/location_tag.dart b/lib/models/location_tag/location_tag.dart new file mode 100644 index 000000000..1901013d9 --- /dev/null +++ b/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 json) => + _$LocationTagFromJson(json); + + int get radiusIndex { + return radiusValues.indexOf(radius); + } +} diff --git a/lib/models/location_tag/location_tag.freezed.dart b/lib/models/location_tag/location_tag.freezed.dart new file mode 100644 index 000000000..88a88b483 --- /dev/null +++ b/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 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 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 toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $LocationTagCopyWith 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 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 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 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; +} diff --git a/lib/models/location_tag/location_tag.g.dart b/lib/models/location_tag/location_tag.g.dart new file mode 100644 index 000000000..29bc59b35 --- /dev/null +++ b/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 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), + ); + +Map _$$_LocationTagToJson(_$_LocationTag instance) => + { + 'name': instance.name, + 'radius': instance.radius, + 'aSquare': instance.aSquare, + 'bSquare': instance.bSquare, + 'centerPoint': instance.centerPoint, + }; diff --git a/lib/models/search/search_result.dart b/lib/models/search/search_result.dart index 559ad7d24..94063b0d2 100644 --- a/lib/models/search/search_result.dart +++ b/lib/models/search/search_result.dart @@ -23,5 +23,5 @@ enum ResultType { fileType, fileExtension, fileCaption, - event + event, } diff --git a/lib/models/typedefs.dart b/lib/models/typedefs.dart index 7a42a1fe5..bd53d57ff 100644 --- a/lib/models/typedefs.dart +++ b/lib/models/typedefs.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import "package:photos/models/location/location.dart"; + typedef FutureVoidCallback = Future Function(); typedef BoolCallBack = bool Function(); typedef FutureVoidCallbackParamStr = Future Function(String); typedef VoidCallbackParamStr = void Function(String); typedef FutureOrVoidCallback = FutureOr Function(); +typedef VoidCallbackParamInt = void Function(int); +typedef VoidCallbackParamLocation = void Function(Location); diff --git a/lib/services/entity_service.dart b/lib/services/entity_service.dart new file mode 100644 index 000000000..5797a7591 --- /dev/null +++ b/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 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> getEntities(EntityType type) async { + return await _db.getEntities(type); + } + + Future 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 deleteEntry(String id) async { + await _gateway.deleteEntity(id); + await _db.deleteEntities([id]); + } + + Future syncEntities() async { + try { + await _remoteToLocalSync(EntityType.location); + } catch (e) { + _logger.severe("Failed to sync entities", e); + } + } + + Future _remoteToLocalSync(EntityType type) async { + final int lastSyncTime = + _prefs.getInt(_getEntityLastSyncTimePrefix(type)) ?? 0; + final List 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 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 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 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; + } + } +} diff --git a/lib/services/files_service.dart b/lib/services/files_service.dart index 136e3e7c2..41544594a 100644 --- a/lib/services/files_service.dart +++ b/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 removeIgnoredFiles(Future result) async { + final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs; + (await result).files.removeWhere( + (f) => + f.uploadedFileID == null && + IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f), + ); + } } enum EditTimeSource { diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart new file mode 100644 index 000000000..f9e44ec1c --- /dev/null +++ b/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>> _getStoredLocationTags() async { + final data = await EntityService.instance.getEntities(EntityType.location); + return data.map( + (e) => LocalEntity(LocationTag.fromJson(json.decode(e.data)), e.id), + ); + } + + Future>> getLocationTags() { + return _getStoredLocationTags(); + } + + Future 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>> enclosingLocationTags( + Location fileCoordinates, + ) async { + try { + final result = List>.of([]); + final locationTagEntities = await getLocationTags(); + for (LocalEntity 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 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 updateLocationTag({ + required LocalEntity 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 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 lat; + final String longRef; + final List 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, + ); + } +} diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 8ace0cc33..96c048173 100644 --- a/lib/services/search_service.dart +++ b/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> getLocationSearchResults( - String query, - ) async { - final List searchResults = []; - try { - final List 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 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> getCollectionSearchResults( @@ -263,6 +223,51 @@ class SearchService { return searchResults; } + Future> getLocationResults( + String query, + ) async { + final locations = + (await LocationService.instance.getLocationTags()).map((e) => e.item); + final Map> result = {}; + + final List 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> entry in result.entries) { + if (entry.value.isNotEmpty) { + searchResults.add( + GenericSearchResult( + ResultType.location, + entry.key.name, + entry.value, + ), + ); + } + } + return searchResults; + } + Future> getMonthSearchResults(String query) async { final List 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> _getPossibleEventDate(String query) { final List> possibleEvents = []; if (query.trim().isEmpty) { diff --git a/lib/states/location_screen_state.dart b/lib/states/location_screen_state.dart new file mode 100644 index 000000000..fe9e284ae --- /dev/null +++ b/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 locationTagEntity; + final Widget child; + const LocationScreenStateProvider( + this.locationTagEntity, + this.child, { + super.key, + }); + + @override + State createState() => + _LocationScreenStateProviderState(); +} + +class _LocationScreenStateProviderState + extends State { + late LocalEntity _locationTagEntity; + late final StreamSubscription _locTagUpdateListener; + @override + void initState() { + _locationTagEntity = widget.locationTagEntity; + _locTagUpdateListener = + Bus.instance.on().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 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(null); + + static InheritedLocationScreenState of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType()!; + } + + @override + bool updateShouldNotify(covariant InheritedLocationScreenState oldWidget) { + return oldWidget.locationTagEntity != locationTagEntity; + } +} diff --git a/lib/states/location_state.dart b/lib/states/location_state.dart new file mode 100644 index 000000000..c2c525c7a --- /dev/null +++ b/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? locationTagEntity; + final Location? centerPoint; + final Widget child; + const LocationTagStateProvider( + this.child, { + this.centerPoint, + this.locationTagEntity, + super.key, + }); + + @override + State createState() => + _LocationTagStateProviderState(); +} + +class _LocationTagStateProviderState extends State { + int _selectedRaduisIndex = defaultRadiusValueIndex; + late Location? _centerPoint; + late LocalEntity? _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().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? 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()!; + } + + @override + bool updateShouldNotify(InheritedLocationTagData oldWidget) { + return oldWidget.selectedRadiusIndex != selectedRadiusIndex || + oldWidget.centerPoint != centerPoint || + oldWidget.locationTagEntity != locationTagEntity; + } +} diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index fcf2e9f62..1cc2d151b 100644 --- a/lib/theme/colors.dart +++ b/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); diff --git a/lib/ui/actions/file/file_actions.dart b/lib/ui/actions/file/file_actions.dart index d5f14ed3d..e88c917fd 100644 --- a/lib/ui/actions/file/file_actions.dart +++ b/lib/ui/actions/file/file_actions.dart @@ -131,7 +131,7 @@ Future showSingleFileDeleteSheet( } } -Future showInfoSheet(BuildContext context, File file) async { +Future showDetailsSheet(BuildContext context, File file) async { final colorScheme = getEnteColorScheme(context); return showBarModalBottomSheet( topControl: const SizedBox.shrink(), diff --git a/lib/ui/collection_action_sheet.dart b/lib/ui/collection_action_sheet.dart index 9e0d044d7..cae6b2a7a 100644 --- a/lib/ui/collection_action_sheet.dart +++ b/lib/ui/collection_action_sheet.dart @@ -159,8 +159,9 @@ class _CollectionActionSheetState extends State { _searchQuery = value; }); }, - cancellable: true, - shouldUnfocusOnCancelOrSubmit: true, + isClearable: true, + shouldUnfocusOnClearOrSubmit: true, + borderRadius: 2, ), ), _getCollectionItems(filesCount), diff --git a/lib/ui/components/buttons/chip_button_widget.dart b/lib/ui/components/buttons/chip_button_widget.dart index 20c492737..27d9d2b19 100644 --- a/lib/ui/components/buttons/chip_button_widget.dart +++ b/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, + ), + ) ], ), ), diff --git a/lib/ui/components/divider_widget.dart b/lib/ui/components/divider_widget.dart index de30ea04f..1e8b3a882 100644 --- a/lib/ui/components/divider_widget.dart +++ b/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, + ), ); } diff --git a/lib/ui/components/text_input_widget.dart b/lib/ui/components/text_input_widget.dart index 53ae63521..1f1b0d5c3 100644 --- a/lib/ui/components/text_input_widget.dart +++ b/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 { ExecutionState executionState = ExecutionState.idle; - final _textController = TextEditingController(); + late final TextEditingController _textController; final _debouncer = Debouncer(const Duration(milliseconds: 300)); late final ValueNotifier _obscureTextNotifier; @@ -70,6 +87,8 @@ class _TextInputWidgetState extends State { @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 { } _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 { } 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 { 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 { }); }), ); - if (widget.shouldUnfocusOnCancelOrSubmit) { + if (widget.shouldUnfocusOnClearOrSubmit) { FocusScope.of(context).unfocus(); } try { @@ -303,6 +331,15 @@ class _TextInputWidgetState extends State { } } + 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(); } }, diff --git a/lib/ui/components/title_bar_widget.dart b/lib/ui/components/title_bar_widget.dart index 25a2cf2d6..46fbd228d 100644 --- a/lib/ui/components/title_bar_widget.dart +++ b/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(), - ), - ), - ], - 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: [ - 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, - ) - ], - ), - ), - ], - ), - ), + 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, + ), + ); + } 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: [ + 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, + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/home/memories_widget.dart b/lib/ui/home/memories_widget.dart index 5e23e3ea0..a06e5bcab 100644 --- a/lib/ui/home/memories_widget.dart +++ b/lib/ui/home/memories_widget.dart @@ -369,7 +369,7 @@ class _FullScreenMemoryState extends State { color: Colors.white, //same for both themes ), onPressed: () { - showInfoSheet(context, file); + showDetailsSheet(context, file); }, ), IconButton( diff --git a/lib/ui/huge_listview/huge_listview.dart b/lib/ui/huge_listview/huge_listview.dart index ce651d297..056039e1c 100644 --- a/lib/ui/huge_listview/huge_listview.dart +++ b/lib/ui/huge_listview/huge_listview.dart @@ -60,6 +60,8 @@ class HugeListView extends StatefulWidget { final EdgeInsetsGeometry? thumbPadding; + final bool disableScroll; + const HugeListView({ Key? key, this.controller, @@ -77,6 +79,7 @@ class HugeListView extends StatefulWidget { this.bottomSafeArea = 120.0, this.isDraggableScrollbarEnabled = true, this.thumbPadding, + this.disableScroll = false, }) : super(key: key); @override @@ -160,6 +163,9 @@ class HugeListViewState extends State> { isEnabled: widget.isDraggableScrollbarEnabled, padding: widget.thumbPadding, child: ScrollablePositionedList.builder( + physics: widget.disableScroll + ? const NeverScrollableScrollPhysics() + : null, itemScrollController: widget.controller, itemPositionsListener: listener, initialScrollIndex: widget.startIndex, diff --git a/lib/ui/huge_listview/lazy_loading_gallery.dart b/lib/ui/huge_listview/lazy_loading_gallery.dart index d1e012f8d..a3a2edd89 100644 --- a/lib/ui/huge_listview/lazy_loading_gallery.dart +++ b/lib/ui/huge_listview/lazy_loading_gallery.dart @@ -32,11 +32,13 @@ class LazyLoadingGallery extends StatefulWidget { final Stream? reloadEvent; final Set removalEventTypes; final GalleryLoader asyncLoader; - final SelectedFiles selectedFiles; + final SelectedFiles? selectedFiles; final String tag; final String? logTag; final Stream 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 { late Logger _logger; late List _files; - late StreamSubscription _reloadEventSubscription; + late StreamSubscription? _reloadEventSubscription; late StreamSubscription _currentIndexSubscription; bool? _shouldRender; final ValueNotifier _toggleSelectAllFromDay = ValueNotifier(false); @@ -72,7 +76,7 @@ class _LazyLoadingGalleryState extends State { @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 { _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 { @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 { 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 { 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 { _toggleSelectAllFromDay, _areAllFromDaySelected, widget.photoGirdSize, + limitSelectionToOne: widget.limitSelectionToOne, ), ); } @@ -271,7 +279,7 @@ class _LazyLoadingGalleryState extends State { } 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 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 { void initState() { _shouldRender = widget.shouldRender; _currentUserID = Configuration.instance.getUserID(); - widget.selectedFiles.addListener(_selectedFilesListener); + widget.selectedFiles?.addListener(_selectedFilesListener); _clearSelectionsEvent = Bus.instance.on().listen((event) { if (mounted) { @@ -329,7 +339,7 @@ class _LazyLoadingGridViewState extends State { @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 { 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 { 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 { ); } - 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 _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 { } 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 { } 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()); } } } diff --git a/lib/ui/tools/editor/image_editor_page.dart b/lib/ui/tools/editor/image_editor_page.dart index 3a888177a..493f691c1 100644 --- a/lib/ui/tools/editor/image_editor_page.dart +++ b/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 { 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); diff --git a/lib/ui/viewer/file/fading_bottom_bar.dart b/lib/ui/viewer/file/fading_bottom_bar.dart index 3bf0febd0..27f338e8f 100644 --- a/lib/ui/viewer/file/fading_bottom_bar.dart +++ b/lib/ui/viewer/file/fading_bottom_bar.dart @@ -76,7 +76,7 @@ class FadingBottomBarState extends State { 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 { ); } - Future _displayInfo(File file) async { - await showInfoSheet(context, file); + Future _displayDetails(File file) async { + await showDetailsSheet(context, file); } } diff --git a/lib/ui/viewer/file/file_caption_widget.dart b/lib/ui/viewer/file/file_caption_widget.dart index 6f6fbd093..7841a92c1 100644 --- a/lib/ui/viewer/file/file_caption_widget.dart +++ b/lib/ui/viewer/file/file_caption_widget.dart @@ -66,7 +66,7 @@ class _FileCaptionWidgetState extends State { final _focusNode = FocusNode(); String? editedCaption; String hintText = fileCaptionDefaultHint; - Widget? keyboardTopButtoms; + Widget? keyboardTopButtons; @override void initState() { @@ -172,12 +172,12 @@ class _FileCaptionWidgetState extends State { 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(); } diff --git a/lib/ui/viewer/file/file_details_widget.dart b/lib/ui/viewer/file/file_details_widget.dart index 12c9a4b58..d7d168102 100644 --- a/lib/ui/viewer/file/file_details_widget.dart +++ b/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 { "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 { _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 { _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 { }, ), ); + 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 { ); } + 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 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 exif) { if (exif["EXIF FocalLength"] != null) { _exifData["focalLength"] = diff --git a/lib/ui/viewer/file_details/location_tags_widget.dart b/lib/ui/viewer/file_details/location_tags_widget.dart new file mode 100644 index 000000000..d3d8adb89 --- /dev/null +++ b/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 createState() => _LocationTagsWidgetState(); +} + +class _LocationTagsWidgetState extends State { + String? title; + IconData? leadingIcon; + bool? hasChipButtons; + late Future> locationTagChips; + late StreamSubscription _locTagUpdateListener; + @override + void initState() { + locationTagChips = _getLocationTags(); + _locTagUpdateListener = + Bus.instance.on().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> _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; + } +} diff --git a/lib/ui/viewer/gallery/gallery.dart b/lib/ui/viewer/gallery/gallery.dart index 9fe53a590..40e8206d4 100644 --- a/lib/ui/viewer/gallery/gallery.dart +++ b/lib/ui/viewer/gallery/gallery.dart @@ -33,18 +33,22 @@ class Gallery extends StatefulWidget { final Stream? reloadEvent; final List>? forceReloadEvents; final Set 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 { // Collates files and returns `true` if it resulted in a gallery reload bool _onFilesLoaded(List 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 { 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 { startIndex: 0, totalCount: _collatedFiles.length, isDraggableScrollbarEnabled: _collatedFiles.length > 10, + disableScroll: widget.disableScroll, waitBuilder: (_) { return const EnteLoadingWidget(); }, @@ -246,8 +256,10 @@ class _GalleryState extends State { .on() .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]); diff --git a/lib/ui/viewer/location/add_location_sheet.dart b/lib/ui/viewer/location/add_location_sheet.dart new file mode 100644 index 000000000..906dd66b2 --- /dev/null +++ b/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 createState() => _AddLocationSheetState(); +} + +class _AddLocationSheetState extends State { + //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 _memoriesCountNotifier = ValueNotifier(null); + final ValueNotifier _submitNotifer = ValueNotifier(false); + final ValueNotifier _cancelNotifier = ValueNotifier(false); + final ValueNotifier _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 _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; + } +} diff --git a/lib/ui/viewer/location/dynamic_location_gallery_widget.dart b/lib/ui/viewer/location/dynamic_location_gallery_widget.dart new file mode 100644 index 000000000..d230f04ed --- /dev/null +++ b/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 memoriesCountNotifier; + final String tagPrefix; + const DynamicLocationGalleryWidget( + this.memoriesCountNotifier, + this.tagPrefix, { + super.key, + }); + + @override + State createState() => + _DynamicLocationGalleryWidgetState(); +} + +class _DynamicLocationGalleryWidgetState + extends State { + late final Future fileLoadResult; + late Future 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 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.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; + } +} diff --git a/lib/ui/viewer/location/edit_center_point_tile_widget.dart b/lib/ui/viewer/location/edit_center_point_tile_widget.dart new file mode 100644 index 000000000..a4e3e02d4 --- /dev/null +++ b/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, + ), + ], + ); + } +} diff --git a/lib/ui/viewer/location/edit_location_sheet.dart b/lib/ui/viewer/location/edit_location_sheet.dart new file mode 100644 index 000000000..2506bb8be --- /dev/null +++ b/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 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 createState() => _EditLocationSheetState(); +} + +class _EditLocationSheetState extends State { + //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 _memoriesCountNotifier = ValueNotifier(null); + final ValueNotifier _submitNotifer = ValueNotifier(false); + final ValueNotifier _cancelNotifier = ValueNotifier(false); + final ValueNotifier _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 _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; + } +} diff --git a/lib/ui/viewer/location/location_screen.dart b/lib/ui/viewer/location/location_screen.dart new file mode 100644 index 000000000..3365952c7 --- /dev/null +++ b/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: [ + 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 createState() => _LocationGalleryWidgetState(); +} + +class _LocationGalleryWidgetState extends State { + late final Future fileLoadResult; + late Future 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 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.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(), + 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 createState() => _GalleryHeaderWidgetState(); +} + +class _GalleryHeaderWidgetState extends State { + @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, + ); + } + }, + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/viewer/location/pick_center_point_widget.dart b/lib/ui/viewer/location/pick_center_point_widget.dart new file mode 100644 index 000000000..d046fbfea --- /dev/null +++ b/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 showPickCenterPointSheet( + BuildContext context, + LocalEntity 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 locationTagEntity; + + const PickCenterPointWidget( + this.locationTagEntity, { + super.key, + }); + + @override + Widget build(BuildContext context) { + final ValueNotifier 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(); + }, + ), + ], + ), + ), + ) + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/viewer/location/radius_picker_widget.dart b/lib/ui/viewer/location/radius_picker_widget.dart new file mode 100644 index 000000000..89bd621b7 --- /dev/null +++ b/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 selectedRadiusIndexNotifier; + const RadiusPickerWidget( + this.selectedRadiusIndexNotifier, { + super.key, + }); + + @override + State createState() => _RadiusPickerWidgetState(); +} + +class _RadiusPickerWidgetState extends State { + @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, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/viewer/search/search_widget.dart b/lib/ui/viewer/search/search_widget.dart index c9b37e052..ed206f486 100644 --- a/lib/ui/viewer/search/search_widget.dart +++ b/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 { 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); diff --git a/lib/utils/file_uploader_util.dart b/lib/utils/file_uploader_util.dart index 5340d5c4a..ddf3251de 100644 --- a/lib/utils/file_uploader_util.dart +++ b/lib/utils/file_uploader_util.dart @@ -15,7 +15,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/models/magic_metadata.dart"; import "package:photos/services/file_magic_service.dart"; import 'package:photos/utils/crypto_util.dart'; @@ -169,7 +169,8 @@ Future _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) { diff --git a/lib/utils/lat_lon_util.dart b/lib/utils/lat_lon_util.dart new file mode 100644 index 000000000..a270e3902 --- /dev/null +++ b/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; +} diff --git a/pubspec.lock b/pubspec.lock index 083bfd1a7..71935b381 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index 5b823747b..90e1d8264 100644 --- a/pubspec.yaml +++ b/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: