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 d5298f3ff..fd097b124 100644 --- a/lib/db/files_db.dart +++ b/lib/db/files_db.dart @@ -9,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'; @@ -80,6 +80,7 @@ class FilesDB { ...createOnDeviceFilesAndPathCollection(), ...addFileSizeColumn(), ...updateIndexes(), + ...createEntityDataTable(), ]; final dbConfig = MigrationConfig( @@ -332,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 [ ''' @@ -529,7 +544,8 @@ class FilesDB { filesTable, where: onlyFilesWithLocation ? '$columnLatitude IS NOT NULL AND $columnLongitude IS NOT NULL AND ($columnLatitude IS NOT 0 OR $columnLongitude IS NOT 0)' - '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)' + ' 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))', @@ -1390,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) { @@ -1489,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 28c01dace..3b2cc6972 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'package:photos/ente_theme_data.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'; @@ -154,7 +155,9 @@ Future _init(bool isBackground, {String via = ''}) async { await NetworkClient.instance.init(); await Configuration.instance.init(); await UserService.instance.init(); - await LocationService.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 6035d1daa..8c0861ecc 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/typedefs.dart b/lib/models/typedefs.dart index f2930ee59..bd53d57ff 100644 --- a/lib/models/typedefs.dart +++ b/lib/models/typedefs.dart @@ -1,8 +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 index 4fb2a356e..f9e44ec1c 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -1,51 +1,64 @@ -import "dart:collection"; 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 { - SharedPreferences? prefs; + late SharedPreferences prefs; + final Logger _logger = Logger((LocationService).toString()); + LocationService._privateConstructor(); static final LocationService instance = LocationService._privateConstructor(); - Future init() async { - prefs ??= await SharedPreferences.getInstance(); + void init(SharedPreferences preferences) { + prefs = preferences; } - List getAllLocationTags() { - var list = prefs!.getStringList('locations'); - list ??= []; - return list; + 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, - double lat, - double long, + Location centerPoint, int radius, ) async { - final list = getAllLocationTags(); //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(lat)) / kilometersPerDegree; + + final a = + (radius * _scaleFactor(centerPoint.latitude!)) / kilometersPerDegree; final b = radius / kilometersPerDegree; - final center = [lat, long]; - final data = { - "name": location, - "radius": radius, - "aSquare": a * a, - "bSquare": b * b, - "center": center, - }; - final encodedMap = json.encode(data); - list.add(encodedMap); - await prefs!.setStringList('locations', list); + 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 @@ -56,79 +69,127 @@ class LocationService { return 1 / cos(lat * (pi / 180)); } - List enclosingLocationTags(List coordinates) { - final result = List.of([]); - final allLocationTags = getAllLocationTags(); - for (var locationTag in allLocationTags) { - final locationJson = json.decode(locationTag); - final aSquare = locationJson["aSquare"]; - final bSquare = locationJson["bSquare"]; - final center = locationJson["center"]; - final x = coordinates[0] - center[0]; - final y = coordinates[1] - center[1]; - if ((x * x) / (aSquare) + (y * y) / (bSquare) <= 1) { - result.add(locationJson["name"]); + 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; } - return result; } bool isFileInsideLocationTag( - List center, - List fileCoordinates, + Location centerPoint, + Location fileCoordinates, int radius, ) { - final a = (radius * _scaleFactor(center[0])) / kilometersPerDegree; + final a = + (radius * _scaleFactor(centerPoint.latitude!)) / kilometersPerDegree; final b = radius / kilometersPerDegree; - final x = center[0] - fileCoordinates[0]; - final y = center[1] - fileCoordinates[1]; + 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; } - Future addFileToLocation(int locationId, int fileId) async { - final list = getFilesByLocation(locationId.toString()); - list.add(fileId.toString()); - await prefs!.setStringList("location_$locationId", list); + 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 getFilesByLocation(String locationId) { - var fileList = prefs!.getStringList("location_$locationId"); - fileList ??= []; - return fileList; + 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]; } - List getLocationsByFileID(int fileId) { - final locationList = getAllLocationTags(); - final locations = List.of([]); - for (String locationString in locationList) { - final locationJson = json.decode(locationString); - locations.add(locationJson); - } - final res = List.of([]); - for (dynamic location in locations) { - final list = getFilesByLocation(location["id"].toString()); - if (list.contains(fileId.toString())) { - res.add(location["name"]); + ///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; } - return res; } - Map> clusterFilesByLocation() { - final map = HashMap>(); - var locations = prefs!.getStringList('locations'); - locations ??= []; - for (String locationData in locations) { - final locationJson = json.decode(locationData); - map.putIfAbsent( - locationData, - () => getFilesByLocation(locationJson['id'].toString()), + 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; } - return map; } } @@ -140,12 +201,12 @@ class GPSData { GPSData(this.latRef, this.lat, this.longRef, this.long); - List toSignedDecimalDegreeCoordinates() { + Location toLocationObj() { final latSign = latRef == "N" ? 1 : -1; final longSign = longRef == "E" ? 1 : -1; - return [ - latSign * lat[0] + lat[1] / 60 + lat[2] / 3600, - longSign * long[0] + long[1] / 60 + long[2] / 3600 - ]; + 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 edf085831..96c048173 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -1,5 +1,3 @@ -import "dart:convert"; - import 'package:logging/logging.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/data/holidays.dart'; @@ -11,10 +9,9 @@ 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"; @@ -56,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( @@ -269,27 +226,41 @@ class SearchService { Future> getLocationResults( String query, ) async { + final locations = + (await LocationService.instance.getLocationTags()).map((e) => e.item); + final Map> result = {}; + final List searchResults = []; - final locations = LocationService.instance.getAllLocationTags(); - for (String location in locations) { - final locationJson = json.decode(location); - final locationName = locationJson["name"].toString(); - _logger.info(locationName); - if (locationName.toLowerCase().contains(query.toLowerCase())) { - _logger.info("TRUEEE"); - final fileIDs = LocationService.instance - .getFilesByLocation(locationJson["id"].toString()); - final files = List.empty(growable: true); - for (String fileID in fileIDs) { - final id = int.parse(fileID); - final file = await FilesDB.instance.getFile(id); - files.add(file!); + + 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, - locationName, - files, + entry.key.name, + entry.value, ), ); } @@ -397,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/add_location_state.dart b/lib/states/add_location_state.dart deleted file mode 100644 index 13358174b..000000000 --- a/lib/states/add_location_state.dart +++ /dev/null @@ -1,71 +0,0 @@ -import "package:flutter/material.dart"; -import "package:photos/core/constants.dart"; -import "package:photos/models/typedefs.dart"; -import "package:photos/utils/debouncer.dart"; - -class LocationTagDataStateProvider extends StatefulWidget { - final List coordinates; - final Widget child; - const LocationTagDataStateProvider(this.coordinates, this.child, {super.key}); - - @override - State createState() => - _LocationTagDataStateProviderState(); -} - -class _LocationTagDataStateProviderState - extends State { - int selectedRaduisIndex = defaultRadiusValueIndex; - late List coordinates; - final Debouncer _selectedRadiusDebouncer = - Debouncer(const Duration(milliseconds: 300)); - @override - void initState() { - coordinates = widget.coordinates; - super.initState(); - } - - void _updateSelectedIndex(int index) { - _selectedRadiusDebouncer.cancelDebounce(); - _selectedRadiusDebouncer.run(() async { - if (mounted) { - setState(() { - selectedRaduisIndex = index; - }); - } - }); - } - - @override - Widget build(BuildContext context) { - return InheritedLocationTagData( - selectedRaduisIndex, - coordinates, - _updateSelectedIndex, - child: widget.child, - ); - } -} - -class InheritedLocationTagData extends InheritedWidget { - final int selectedRadiusIndex; - final List coordinates; - final VoidCallbackParamInt updateSelectedIndex; - const InheritedLocationTagData( - this.selectedRadiusIndex, - this.coordinates, - this.updateSelectedIndex, { - required super.child, - super.key, - }); - - static InheritedLocationTagData of(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType()!; - } - - @override - bool updateShouldNotify(InheritedLocationTagData oldWidget) { - return oldWidget.selectedRadiusIndex != selectedRadiusIndex; - } -} 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/ui/components/text_input_widget.dart b/lib/ui/components/text_input_widget.dart index b39cd9bb7..1f1b0d5c3 100644 --- a/lib/ui/components/text_input_widget.dart +++ b/lib/ui/components/text_input_widget.dart @@ -38,6 +38,9 @@ class TextInputWidget extends StatefulWidget { final bool isClearable; final bool shouldUnfocusOnClearOrSubmit; final FocusNode? focusNode; + final VoidCallback? onCancel; + final TextEditingController? textEditingController; + final ValueNotifier? isEmptyNotifier; const TextInputWidget({ this.onSubmit, this.onChange, @@ -61,6 +64,9 @@ class TextInputWidget extends StatefulWidget { this.shouldUnfocusOnClearOrSubmit = false, this.borderRadius = 8, this.focusNode, + this.onCancel, + this.textEditingController, + this.isEmptyNotifier, super.key, }); @@ -70,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; @@ -82,6 +88,7 @@ class _TextInputWidgetState extends State { void initState() { widget.submitNotifier?.addListener(_onSubmit); widget.cancelNotifier?.addListener(_onCancel); + _textController = widget.textEditingController ?? TextEditingController(); if (widget.initialValue != null) { _textController.value = TextEditingValue( @@ -96,6 +103,12 @@ 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(); } @@ -105,6 +118,7 @@ class _TextInputWidgetState extends State { widget.cancelNotifier?.removeListener(_onCancel); _obscureTextNotifier.dispose(); _textController.dispose(); + widget.isEmptyNotifier?.dispose(); super.dispose(); } @@ -318,8 +332,12 @@ class _TextInputWidgetState extends State { } void _onCancel() { - _textController.clear(); - FocusScope.of(context).unfocus(); + if (widget.onCancel != null) { + widget.onCancel!(); + } else { + _textController.clear(); + FocusScope.of(context).unfocus(); + } } void _popNavigatorStack(BuildContext context, {Exception? e}) { 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/huge_listview/lazy_loading_gallery.dart b/lib/ui/huge_listview/lazy_loading_gallery.dart index d05617304..a3a2edd89 100644 --- a/lib/ui/huge_listview/lazy_loading_gallery.dart +++ b/lib/ui/huge_listview/lazy_loading_gallery.dart @@ -38,6 +38,7 @@ class LazyLoadingGallery extends StatefulWidget { final Stream currentIndexStream; final int photoGirdSize; final bool areFilesCollatedByDay; + final bool limitSelectionToOne; LazyLoadingGallery( this.files, this.index, @@ -50,6 +51,7 @@ class LazyLoadingGallery extends StatefulWidget { this.areFilesCollatedByDay, { this.logTag = "", this.photoGirdSize = photoGridSizeDefault, + this.limitSelectionToOne = false, Key? key, }) : super(key: key ?? UniqueKey()); @@ -198,42 +200,44 @@ class _LazyLoadingGalleryState extends State { _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; - }, - ); - }, - ) + 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! @@ -264,6 +268,7 @@ class _LazyLoadingGalleryState extends State { _toggleSelectAllFromDay, _areAllFromDaySelected, widget.photoGirdSize, + limitSelectionToOne: widget.limitSelectionToOne, ), ); } @@ -292,6 +297,7 @@ class LazyLoadingGridView extends StatefulWidget { final ValueNotifier toggleSelectAllFromDay; final ValueNotifier areAllFilesSelected; final int? photoGridSize; + final bool limitSelectionToOne; LazyLoadingGridView( this.tag, @@ -303,6 +309,7 @@ class LazyLoadingGridView extends StatefulWidget { this.toggleSelectAllFromDay, this.areAllFilesSelected, this.photoGridSize, { + this.limitSelectionToOne = false, Key? key, }) : super(key: key ?? UniqueKey()); @@ -424,28 +431,16 @@ class _LazyLoadingGridViewState extends State { selectionColor = avatarColors[(randomID).remainder(avatarColors.length)]; } return GestureDetector( - onTap: () async { - if (widget.selectedFiles?.files.isNotEmpty ?? false) { - _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: () { + widget.limitSelectionToOne + ? _onLongPressWithSelectionLimit(file) + : _onLongPressNoSelectionLimit(file); }, - onLongPress: widget.selectedFiles != null - ? () { - if (AppLifecycleService.instance.mediaExtensionAction.action == - IntentAction.main) { - HapticFeedback.lightImpact(); - _selectFile(file); - } - } - : null, child: ClipRRect( borderRadius: BorderRadius.circular(1), child: Stack( @@ -490,10 +485,52 @@ class _LazyLoadingGridViewState extends State { ); } - void _selectFile(File 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) { final page = DetailPage( DetailPageConfiguration( diff --git a/lib/ui/tools/editor/image_editor_page.dart b/lib/ui/tools/editor/image_editor_page.dart index 8f2624a79..32e9c85b0 100644 --- a/lib/ui/tools/editor/image_editor_page.dart +++ b/lib/ui/tools/editor/image_editor_page.dart @@ -12,7 +12,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'; @@ -359,7 +359,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/file_details_widget.dart b/lib/ui/viewer/file/file_details_widget.dart index ecd6dba4d..d7d168102 100644 --- a/lib/ui/viewer/file/file_details_widget.dart +++ b/lib/ui/viewer/file/file_details_widget.dart @@ -5,7 +5,6 @@ import "package:photos/core/configuration.dart"; import "package:photos/models/file.dart"; import "package:photos/models/file_type.dart"; import "package:photos/services/feature_flag_service.dart"; -import "package:photos/services/location_service.dart"; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/buttons/icon_button_widget.dart'; import "package:photos/ui/components/divider_widget.dart"; @@ -147,12 +146,7 @@ class _FileDetailsWidgetState extends State { ? Column( children: [ LocationTagsWidget( - GPSData( - _exifData["latRef"], - _exifData["lat"], - _exifData["longRef"], - _exifData["long"], - ).toSignedDecimalDegreeCoordinates(), + widget.file.location!, ), const FileDetailsDivider(), ], @@ -238,10 +232,12 @@ class _FileDetailsWidgetState extends State { } bool _haGPSData() { - return _exifData["lat"] != null && - _exifData["long"] != null && - _exifData["latRef"] != null && - _exifData["longRef"] != null; + 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) { diff --git a/lib/ui/viewer/file/location_chip.dart b/lib/ui/viewer/file/location_chip.dart deleted file mode 100644 index 4c056d06b..000000000 --- a/lib/ui/viewer/file/location_chip.dart +++ /dev/null @@ -1,37 +0,0 @@ -import "package:flutter/material.dart"; -import "package:photos/services/location_service.dart"; - -Widget locationChipList(int id, BuildContext context) { - final locationService = LocationService.instance; - final list = locationService.getLocationsByFileID(id); - return Wrap( - spacing: 6.0, - runSpacing: 6.0, - children: [ - ...list.map((e) => _buildChip(e, context)).toList(), - _addLocation(context) - ], - ); -} - -Widget _buildChip(String label, BuildContext context) { - return Chip( - labelPadding: const EdgeInsets.all(2.0), - label: Text( - label, - style: TextStyle( - color: Theme.of(context).hintColor, - ), - ), - backgroundColor: Theme.of(context).cardColor, - elevation: 6.0, - padding: const EdgeInsets.all(8.0), - ); -} - -Widget _addLocation(BuildContext context) { - return IconButton( - onPressed: () {}, - icon: const Icon(Icons.add), - ); -} diff --git a/lib/ui/viewer/file/location_detail.dart b/lib/ui/viewer/file/location_detail.dart deleted file mode 100644 index 3324f4eb5..000000000 --- a/lib/ui/viewer/file/location_detail.dart +++ /dev/null @@ -1,268 +0,0 @@ -import "package:flutter/material.dart"; -import "package:photos/ente_theme_data.dart"; -import "package:photos/services/location_service.dart"; -import "package:photos/ui/common/gradient_button.dart"; -import "package:photos/utils/lat_lon_util.dart"; - -class CreateLocation extends StatefulWidget { - const CreateLocation({super.key}); - - @override - State createState() { - return CreateLocationState(); - } -} - -class CreateLocationState extends State { - TextEditingController locationController = TextEditingController(); - List centerPointController = List.from( - [TextEditingController(text: "0.0"), TextEditingController(text: "0.0")], - ); - List centerPoint = List.of([0, 0]); - final List values = [2, 10, 20, 40, 80, 200, 400, 1200]; - int slider = 0; - - Dialog selectCenterPoint(BuildContext context) => Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), //this right here - child: SizedBox( - height: 300.0, - width: 300.0, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(15), - child: TextField( - controller: centerPointController[0], - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Longitude', - hintText: 'Enter Longitude', - ), - keyboardType: TextInputType.number, - ), - ), - Padding( - padding: const EdgeInsets.all(15), - child: TextField( - controller: centerPointController[1], - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Latitude', - hintText: 'Enter Latitude', - ), - keyboardType: TextInputType.number, - ), - ), - const Padding(padding: EdgeInsets.only(top: 50.0)), - TextButton( - onPressed: () { - setState(() { - centerPoint = List.of([ - double.parse(centerPointController[0].text), - double.parse(centerPointController[1].text) - ]); - }); - Navigator.of(context).pop(); - }, - child: Text( - 'Select', - style: TextStyle( - color: Theme.of(context).colorScheme.iconColor, - fontSize: 18.0, - ), - ), - ) - ], - ), - ), - ); - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 12), - Container( - margin: const EdgeInsets.fromLTRB(16, 12, 0, 0), - child: const Align( - alignment: Alignment.centerLeft, - child: Text.rich( - TextSpan(text: "Add Location"), - textAlign: TextAlign.start, - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800), - ), - ), - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.all(15), - child: TextField( - controller: locationController, - decoration: InputDecoration( - filled: true, - fillColor: Theme.of(context).colorScheme.subTextColor, - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - border: const OutlineInputBorder(), - hintText: 'Enter Your Location', - ), - ), - ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 8), - padding: const EdgeInsets.all(7), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: ListTile( - horizontalTitleGap: 2, - leading: const Icon(Icons.location_on_rounded), - title: const Text( - "Center Point", - ), - subtitle: Text( - "${convertLatLng(centerPoint[0], true)}, ${convertLatLng(centerPoint[1], false)}", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context) - .colorScheme - .defaultTextColor - .withOpacity(0.5), - ), - ), - trailing: IconButton( - onPressed: () async { - showDialog( - context: context, - builder: (BuildContext context) => - selectCenterPoint(context), - ); - }, - icon: const Icon(Icons.edit), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Container( - height: 65, - width: 70, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of(context).focusColor, - borderRadius: const BorderRadius.all(Radius.circular(5)), - ), - child: Column( - children: [ - Text( - values[slider].round().toString(), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - const Text( - "Km", - style: TextStyle( - fontWeight: FontWeight.w200, - ), - ) - ], - ), - ), - Expanded( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(left: 20), - child: Text( - "Radius", - style: TextStyle( - fontWeight: FontWeight.w500, - ), - ), - ), - Row( - children: [ - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 5, - activeTrackColor: Theme.of(context) - .colorScheme - .inverseBackgroundColor, - inactiveTrackColor: Theme.of(context) - .colorScheme - .subTextColor, - thumbColor: Theme.of(context) - .colorScheme - .inverseBackgroundColor, - ), - child: SizedBox( - width: double.infinity, - child: Slider( - value: slider.toDouble(), - min: 0, - max: values.length - 1, - divisions: values.length - 1, - label: values[slider].toString(), - onChanged: (double value) { - setState(() { - slider = value.toInt(); - }); - }, - ), - ), - ), - ), - ], - ) - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 200), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 100), - child: GradientButton( - onTap: () async { - await LocationService.instance.addLocation( - locationController.text, - centerPoint[0], - centerPoint[1], - values[slider].toInt(), - ); - Navigator.pop(context); - }, - text: "Add Location", - iconData: Icons.location_on, - ), - ), - ) - ], - ), - ), - ); - } -} diff --git a/lib/ui/viewer/file/locations_list.dart b/lib/ui/viewer/file/locations_list.dart deleted file mode 100644 index c8562eb9e..000000000 --- a/lib/ui/viewer/file/locations_list.dart +++ /dev/null @@ -1,214 +0,0 @@ -import "dart:async"; -import "dart:convert"; - -import "package:flutter/material.dart"; -import "package:photos/db/files_db.dart"; -import "package:photos/ente_theme_data.dart"; -import "package:photos/services/location_service.dart"; -import "package:photos/ui/viewer/file/location_detail.dart"; - -//state = 0; normal mode -//state = 1; selection mode -class LocationsList extends StatelessWidget { - final int state; - final int? fileId; - LocationsList({super.key, this.state = 0, this.fileId}); - - final clusteredLocationList = - LocationService.instance.clusterFilesByLocation(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 12), - Container( - margin: const EdgeInsets.fromLTRB(16, 12, 0, 0), - child: const Align( - alignment: Alignment.centerLeft, - child: Text.rich( - TextSpan(text: "Locations"), - textAlign: TextAlign.start, - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800), - ), - ), - ), - Container( - margin: const EdgeInsets.fromLTRB(16, 12, 0, 0), - child: Align( - alignment: Alignment.centerLeft, - child: Text.rich( - TextSpan(text: clusteredLocationList.length.toString()), - textAlign: TextAlign.start, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w100, - ), - ), - ), - ), - const SizedBox(height: 12), - ...clusteredLocationList.entries.map((entry) { - final location = json.decode(entry.key); - return InkWell( - onTap: () async { - if (state == 1) { - await LocationService.instance - .addFileToLocation(location["id"], fileId!); - Navigator.pop(context); - } - }, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 10, - ), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border.all(color: Theme.of(context).dividerColor), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: ListTile( - horizontalTitleGap: 2, - title: Text( - location["name"], - ), - subtitle: Text( - "${entry.value.length} memories", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context) - .colorScheme - .defaultTextColor - .withOpacity(0.5), - ), - ), - trailing: state == 0 - ? IconButton( - onPressed: () async {}, - icon: const Icon(Icons.arrow_forward_ios), - ) - : null, - ), - ), - ); - }).toList(), - InkWell( - onTap: () { - unawaited( - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return const CreateLocation(); - }, - ), - ), - ); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 8), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: const ListTile( - horizontalTitleGap: 2, - leading: Icon(Icons.add_location_alt_rounded), - title: Text( - "Add New", - ), - ), - ), - ) - ], - ), - ), - ); - } -} - -class LocationFilesList extends StatelessWidget { - final List fileIDs; - const LocationFilesList({super.key, required this.fileIDs}); - - Future generateFiles() async { - final files = List.empty(growable: true); - for (String fileID in fileIDs) { - final file = await (FilesDB.instance.getFile(int.parse(fileID))); - files.add(file!); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(), - body: SingleChildScrollView( - child: Column( - children: [ - ...LocationService.instance - .clusterFilesByLocation() - .entries - .map( - (entry) => Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border.all(color: Theme.of(context).dividerColor), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: ListTile( - horizontalTitleGap: 2, - title: Text( - entry.key, - ), - subtitle: Text( - "${entry.value.length} memories", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context) - .colorScheme - .defaultTextColor - .withOpacity(0.5), - ), - ), - trailing: IconButton( - onPressed: () async {}, - icon: const Icon(Icons.arrow_forward_ios), - ), - ), - ), - ) - .toList(), - Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: const ListTile( - horizontalTitleGap: 2, - leading: Padding( - padding: EdgeInsets.only(top: 8), - child: Icon(Icons.add_location_alt_rounded), - ), - title: Text( - "Add Location", - ), - ), - ) - ], - ), - ), - ); - } -} diff --git a/lib/ui/viewer/file_details/location_tags_widget.dart b/lib/ui/viewer/file_details/location_tags_widget.dart index b30fd2c46..d3d8adb89 100644 --- a/lib/ui/viewer/file_details/location_tags_widget.dart +++ b/lib/ui/viewer/file_details/location_tags_widget.dart @@ -1,31 +1,48 @@ 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 List coordinates; - const LocationTagsWidget(this.coordinates, {super.key}); + final Location centerPoint; + const LocationTagsWidget(this.centerPoint, {super.key}); @override State createState() => _LocationTagsWidgetState(); } class _LocationTagsWidgetState extends State { - String title = "Add location"; - IconData leadingIcon = Icons.add_location_alt_outlined; - bool hasChipButtons = false; + 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( @@ -34,52 +51,66 @@ class _LocationTagsWidgetState extends State { switchOutCurve: Curves.easeInOutExpo, child: InfoItemWidget( key: ValueKey(title), - leadingIcon: Icons.add_location_alt_outlined, + leadingIcon: leadingIcon ?? Icons.pin_drop_outlined, title: title, subtitleSection: locationTagChips, - hasChipButtons: hasChipButtons, + hasChipButtons: hasChipButtons ?? true, ), ); } Future> _getLocationTags() async { - final locationTags = - LocationService.instance.enclosingLocationTags(widget.coordinates); + 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.coordinates, - //This callback is for reloading the locationTagsWidget after adding a new location tag - //so that it updates in file details. - () { - locationTagChips = _getLocationTags(); - }, + widget.centerPoint, ), ), ]; + } else { + if (mounted) { + setState(() { + title = "Location"; + leadingIcon = Icons.pin_drop_outlined; + hasChipButtons = true; + }); + } } - setState(() { - title = "Location"; - leadingIcon = Icons.pin_drop_outlined; - hasChipButtons = true; - }); - final result = locationTags.map((e) => ChipButtonWidget(e)).toList(); + + 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.coordinates, - //This callback is for reloading the locationTagsWidget after adding a new location tag - //so that it updates in file details. - () { - locationTagChips = _getLocationTags(); - }, - ), + onTap: () => showAddLocationSheet(context, widget.centerPoint), ), ); return result; diff --git a/lib/ui/viewer/gallery/gallery.dart b/lib/ui/viewer/gallery/gallery.dart index 1314267ff..40e8206d4 100644 --- a/lib/ui/viewer/gallery/gallery.dart +++ b/lib/ui/viewer/gallery/gallery.dart @@ -43,6 +43,7 @@ class Gallery extends StatefulWidget { final bool shouldCollateFilesByDay; final Widget loadingWidget; final bool disableScroll; + final bool limitSelectionToOne; const Gallery({ required this.asyncLoader, @@ -60,6 +61,7 @@ class Gallery extends StatefulWidget { this.shouldCollateFilesByDay = true, this.loadingWidget = const EnteLoadingWidget(), this.disableScroll = false, + this.limitSelectionToOne = false, Key? key, }) : super(key: key); @@ -257,6 +259,7 @@ class _GalleryState extends State { 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 index fcd2b8df7..906dd66b2 100644 --- a/lib/ui/viewer/location/add_location_sheet.dart +++ b/lib/ui/viewer/location/add_location_sheet.dart @@ -1,31 +1,33 @@ 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/add_location_state.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/add_location_gallery_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, - List coordinates, - VoidCallback onLocationAdded, + Location coordinates, ) { showBarModalBottomSheet( context: context, builder: (context) { - return LocationTagDataStateProvider( - coordinates, - AddLocationSheet(onLocationAdded), + return LocationTagStateProvider( + centerPoint: coordinates, + const AddLocationSheet(), ); }, shape: const RoundedRectangleBorder( @@ -37,13 +39,11 @@ showAddLocationSheet( topControl: const SizedBox.shrink(), backgroundColor: getEnteColorScheme(context).backgroundElevated, barrierColor: backdropFaintDark, - enableDrag: false, ); } class AddLocationSheet extends StatefulWidget { - final VoidCallback onLocationAdded; - const AddLocationSheet(this.onLocationAdded, {super.key}); + const AddLocationSheet({super.key}); @override State createState() => _AddLocationSheetState(); @@ -56,12 +56,17 @@ class _AddLocationSheetState extends State { 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(); } @@ -70,6 +75,7 @@ class _AddLocationSheetState extends State { _focusNode.removeListener(_focusNodeListener); _submitNotifer.dispose(); _cancelNotifier.dispose(); + _selectedRadiusIndexNotifier.dispose(); super.dispose(); } @@ -89,6 +95,9 @@ class _AddLocationSheetState extends State { ), Expanded( child: SingleChildScrollView( + physics: const BouncingScrollPhysics( + decelerationRate: ScrollDecelerationRate.fast, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -96,21 +105,50 @@ class _AddLocationSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ - TextInputWidget( - hintText: "Location name", - borderRadius: 2, - focusNode: _focusNode, - submitNotifier: _submitNotifer, - cancelNotifier: _cancelNotifier, - popNavAfterSubmission: true, - onSubmit: (locationName) async { - await _addLocationTag(locationName); - }, - shouldUnfocusOnClearOrSubmit: true, - alwaysShowSuccessState: true, + 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(_memoriesCountNotifier), + RadiusPickerWidget( + _selectedRadiusIndexNotifier, + ), const SizedBox(height: 24), Text( "A location tag groups all photos that were taken within some radius of a photo", @@ -174,7 +212,10 @@ class _AddLocationSheetState extends State { ), ), const SizedBox(height: 24), - AddLocationGalleryWidget(_memoriesCountNotifier), + DynamicLocationGalleryWidget( + _memoriesCountNotifier, + "Add_location", + ), ], ), ), @@ -184,17 +225,16 @@ class _AddLocationSheetState extends State { ); } - Future _addLocationTag(String locationName) async { + Future _addLocationTag() async { final locationData = InheritedLocationTagData.of(context); - final coordinates = locationData.coordinates; + final coordinates = locationData.centerPoint; final radius = radiusValues[locationData.selectedRadiusIndex]; await LocationService.instance.addLocation( - locationName, - coordinates.first, - coordinates.last, + _textEditingController.text.trim(), + coordinates, radius, ); - widget.onLocationAdded.call(); + Navigator.pop(context); } void _focusNodeListener() { @@ -213,4 +253,13 @@ class _AddLocationSheetState extends State { KeyboardOverlay.removeOverlay(); } } + + void _selectedRadiusIndexListener() { + InheritedLocationTagData.of( + context, + ).updateSelectedIndex( + _selectedRadiusIndexNotifier.value, + ); + _memoriesCountNotifier.value = null; + } } diff --git a/lib/ui/viewer/location/add_location_gallery_widget.dart b/lib/ui/viewer/location/dynamic_location_gallery_widget.dart similarity index 61% rename from lib/ui/viewer/location/add_location_gallery_widget.dart rename to lib/ui/viewer/location/dynamic_location_gallery_widget.dart index 11016e94c..d230f04ed 100644 --- a/lib/ui/viewer/location/add_location_gallery_widget.dart +++ b/lib/ui/viewer/location/dynamic_location_gallery_widget.dart @@ -2,36 +2,52 @@ import "dart:developer" as dev; import "dart:math"; import "package:flutter/material.dart"; -import "package:photos/core/configuration.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/ignored_files_service.dart"; +import "package:photos/services/files_service.dart"; import "package:photos/services/location_service.dart"; -import "package:photos/states/add_location_state.dart"; +import 'package:photos/states/location_state.dart'; import "package:photos/ui/viewer/gallery/gallery.dart"; import "package:photos/utils/local_settings.dart"; -class AddLocationGalleryWidget extends StatefulWidget { +///This gallery will get rebuilt with the updated radius when +///InheritedLocationTagData notifies a change in radius. +class DynamicLocationGalleryWidget extends StatefulWidget { final ValueNotifier memoriesCountNotifier; - const AddLocationGalleryWidget(this.memoriesCountNotifier, {super.key}); + final String tagPrefix; + const DynamicLocationGalleryWidget( + this.memoriesCountNotifier, + this.tagPrefix, { + super.key, + }); @override - State createState() => - _AddLocationGalleryWidgetState(); + State createState() => + _DynamicLocationGalleryWidgetState(); } -class _AddLocationGalleryWidgetState extends State { +class _DynamicLocationGalleryWidgetState + extends State { late final Future fileLoadResult; late Future removeIgnoredFiles; double heightOfGallery = 0; @override void initState() { - fileLoadResult = _fetchAllFilesWithLocationData(); - removeIgnoredFiles = _removeIgnoredFiles(fileLoadResult); + final collectionsToHide = + CollectionsService.instance.collectionsHiddenFromTimeline(); + fileLoadResult = FilesDB.instance.getAllUploadedAndSharedFiles( + galleryLoadStartTime, + galleryLoadEndTime, + limit: null, + asc: false, + ignoredCollectionIDs: collectionsToHide, + ); + removeIgnoredFiles = + FilesService.instance.removeIgnoredFiles(fileLoadResult); super.initState(); } @@ -46,14 +62,9 @@ class _AddLocationGalleryWidgetState extends State { final stopWatch = Stopwatch()..start(); final copyOfFiles = List.from(result.files); copyOfFiles.removeWhere((f) { - assert( - f.location != null && - f.location!.latitude != null && - f.location!.longitude != null, - ); return !LocationService.instance.isFileInsideLocationTag( - InheritedLocationTagData.of(context).coordinates, - [f.location!.latitude!, f.location!.longitude!], + InheritedLocationTagData.of(context).centerPoint, + f.location!, selectedRadius, ); }); @@ -73,7 +84,10 @@ class _AddLocationGalleryWidgetState extends State { } return FutureBuilder( - key: ValueKey(selectedRadius), + //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( @@ -84,7 +98,6 @@ class _AddLocationGalleryWidgetState extends State { ), ), child: Gallery( - key: ValueKey(selectedRadius), loadingWidget: const SizedBox.shrink(), disableScroll: true, asyncLoader: ( @@ -95,7 +108,7 @@ class _AddLocationGalleryWidgetState extends State { }) async { return snapshot.data as FileLoadResult; }, - tagPrefix: "Add location", + tagPrefix: widget.tagPrefix, shouldCollateFilesByDay: false, ), ); @@ -112,15 +125,6 @@ class _AddLocationGalleryWidgetState extends State { InheritedLocationTagData.of(context).selectedRadiusIndex]; } - Future _removeIgnoredFiles(Future result) async { - final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs; - (await result).files.removeWhere( - (f) => - f.uploadedFileID == null && - IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f), - ); - } - double _galleryHeight(int fileCount) { final photoGridSize = LocalSettings.instance.getPhotoGridSize(); final totalWhiteSpaceBetweenPhotos = @@ -136,33 +140,4 @@ class _AddLocationGalleryWidgetState extends State { (galleryGridSpacing * (numberOfRows - 1)); return galleryHeight + 120; } - - Future _fetchAllFilesWithLocationData() { - final ownerID = Configuration.instance.getUserID(); - final hasSelectedAllForBackup = - Configuration.instance.hasSelectedAllFoldersForBackup(); - final collectionsToHide = - CollectionsService.instance.collectionsHiddenFromTimeline(); - if (hasSelectedAllForBackup) { - return FilesDB.instance.getAllLocalAndUploadedFiles( - galleryLoadStartTime, - galleryLoadEndTime, - ownerID!, - limit: null, - asc: true, - ignoredCollectionIDs: collectionsToHide, - onlyFilesWithLocation: true, - ); - } else { - return FilesDB.instance.getAllPendingOrUploadedFiles( - galleryLoadStartTime, - galleryLoadEndTime, - ownerID!, - limit: null, - asc: true, - ignoredCollectionIDs: collectionsToHide, - onlyFilesWithLocation: true, - ); - } - } } 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 index ea141d4eb..89bd621b7 100644 --- a/lib/ui/viewer/location/radius_picker_widget.dart +++ b/lib/ui/viewer/location/radius_picker_widget.dart @@ -1,6 +1,6 @@ import "package:flutter/material.dart"; import "package:photos/core/constants.dart"; -import "package:photos/states/add_location_state.dart"; +import "package:photos/states/location_state.dart"; import "package:photos/theme/colors.dart"; import "package:photos/theme/ente_theme.dart"; @@ -20,20 +20,34 @@ class CustomTrackShape extends RoundedRectSliderTrackShape { } class RadiusPickerWidget extends StatefulWidget { - final ValueNotifier memoriesCountNotifier; - const RadiusPickerWidget(this.memoriesCountNotifier, {super.key}); + ///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 { - //Will maintain the state of the slider using this varialbe. Can't use - //InheritedLocationData.selectedRadiusIndex as the state in the inheritedWidget - //only changes after debounce time and the slider will not reflect the change immediately. - int selectedRadiusIndex = defaultRadiusValueIndex; + @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); @@ -107,15 +121,9 @@ class _RadiusPickerWidgetState extends State { value: selectedRadiusIndex.toDouble(), onChanged: (value) { setState(() { - selectedRadiusIndex = value.toInt(); + widget.selectedRadiusIndexNotifier.value = + value.toInt(); }); - - InheritedLocationTagData.of( - context, - ).updateSelectedIndex( - value.toInt(), - ); - widget.memoriesCountNotifier.value = null; }, min: 0, max: radiusValues.length - 1, diff --git a/lib/utils/file_uploader_util.dart b/lib/utils/file_uploader_util.dart index 64b3f77d3..e7c0a87ea 100644 --- a/lib/utils/file_uploader_util.dart +++ b/lib/utils/file_uploader_util.dart @@ -13,7 +13,7 @@ import 'package:photos/core/constants.dart'; import 'package:photos/core/errors.dart'; import 'package:photos/models/file.dart' as ente; import 'package:photos/models/file_type.dart'; -import 'package:photos/models/location.dart'; +import 'package:photos/models/location/location.dart'; import 'package:photos/utils/crypto_util.dart'; import 'package:photos/utils/file_util.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; @@ -155,7 +155,8 @@ Future _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/pubspec.lock b/pubspec.lock index 32aeb5e61..419c5bb32 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: @@ -716,6 +812,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: @@ -740,6 +852,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: @@ -877,13 +997,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: @@ -1309,6 +1437,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: @@ -1498,6 +1634,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: @@ -1675,6 +1827,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 37414178d..930a4fa73 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.0.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 @@ -130,9 +132,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: