Location tag (#971)

This commit is contained in:
Ashil 2023-04-10 13:30:46 +05:30 committed by GitHub
commit 59b43393cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 3031 additions and 1062 deletions

65
lib/db/entities_db.dart Normal file
View file

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

View file

@ -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<String> createEntityDataTable() {
return [
'''
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY NOT NULL,
type TEXT NOT NULL,
ownerID INTEGER NOT NULL,
data TEXT NOT NULL DEFAULT '{}',
updatedAt INTEGER NOT NULL
);
'''
];
}
static List<String> addFileSizeColumn() {
return [
'''
@ -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<FileLoadResult> getAllUploadedAndSharedFiles(
int startTime,
int endTime, {
int? limit,
bool? asc,
Set<int>? ignoredCollectionIDs,
}) async {
final db = await instance.database;
final order = (asc ?? false ? 'ASC' : 'DESC');
final results = await db.query(
filesTable,
where:
'$columnLatitude IS NOT NULL AND $columnLongitude IS NOT NULL AND ($columnLatitude IS NOT 0 OR $columnLongitude IS NOT 0)'
' AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND '
'($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)'
' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))',
whereArgs: [startTime, endTime, visibilityVisible],
orderBy:
'$columnCreationTime ' + order + ', $columnModificationTime ' + order,
limit: limit,
);
final files = convertToFiles(results);
final List<File> deduplicatedFiles =
_deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs);
return FileLoadResult(deduplicatedFiles, files.length == limit);
}
Map<String, dynamic> _getRowForFile(File file) {
final row = <String, dynamic>{};
if (file.generatedID != null) {
@ -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];

View file

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

114
lib/gateways/entity_gw.dart Normal file
View file

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

View file

@ -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<void> _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();

View file

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

View file

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

View file

@ -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";
}
}
}

View file

@ -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;

View file

@ -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:

View file

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

View file

@ -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)';
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void> init() async {
prefs ??= await SharedPreferences.getInstance();
void init(SharedPreferences preferences) {
prefs = preferences;
}
List<String> getAllLocationTags() {
var list = prefs!.getStringList('locations');
list ??= [];
return list;
Future<Iterable<LocalEntity<LocationTag>>> _getStoredLocationTags() async {
final data = await EntityService.instance.getEntities(EntityType.location);
return data.map(
(e) => LocalEntity(LocationTag.fromJson(json.decode(e.data)), e.id),
);
}
Future<Iterable<LocalEntity<LocationTag>>> getLocationTags() {
return _getStoredLocationTags();
}
Future<void> addLocation(
String location,
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<String> enclosingLocationTags(List<double> coordinates) {
final result = List<String>.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<List<LocalEntity<LocationTag>>> enclosingLocationTags(
Location fileCoordinates,
) async {
try {
final result = List<LocalEntity<LocationTag>>.of([]);
final locationTagEntities = await getLocationTags();
for (LocalEntity<LocationTag> locationTagEntity in locationTagEntities) {
final locationTag = locationTagEntity.item;
final x = fileCoordinates.latitude! - locationTag.centerPoint.latitude!;
final y =
fileCoordinates.longitude! - locationTag.centerPoint.longitude!;
if ((x * x) / (locationTag.aSquare) + (y * y) / (locationTag.bSquare) <=
1) {
result.add(
locationTagEntity,
);
}
}
return result;
} catch (e, s) {
_logger.severe("Failed to get enclosing location tags", e, s);
rethrow;
}
return result;
}
bool isFileInsideLocationTag(
List<double> center,
List<double> 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<void> 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<String> getFilesByLocation(String locationId) {
var fileList = prefs!.getStringList("location_$locationId");
fileList ??= [];
return fileList;
List<int> convertCoordinateToDMS(double coordinate) {
final degrees = coordinate.floor();
final minutes = ((coordinate - degrees) * 60).floor();
final seconds = ((coordinate - degrees - minutes / 60) * 3600).floor();
return [degrees, minutes, seconds];
}
List<String> getLocationsByFileID(int fileId) {
final locationList = getAllLocationTags();
final locations = List<dynamic>.of([]);
for (String locationString in locationList) {
final locationJson = json.decode(locationString);
locations.add(locationJson);
}
final res = List<String>.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<void> updateLocationTag({
required LocalEntity<LocationTag> locationTagEntity,
int? newRadius,
Location? newCenterPoint,
String? newName,
}) async {
try {
final radius = newRadius ?? locationTagEntity.item.radius;
final centerPoint = newCenterPoint ?? locationTagEntity.item.centerPoint;
final name = newName ?? locationTagEntity.item.name;
final locationTag = locationTagEntity.item;
//Exit if there is no change in locationTag's properties
if (radius == locationTag.radius &&
centerPoint == locationTag.centerPoint &&
name == locationTag.name) {
return;
}
final a =
(radius * _scaleFactor(centerPoint.latitude!)) / kilometersPerDegree;
final b = radius / kilometersPerDegree;
final updatedLoationTag = locationTagEntity.item.copyWith(
centerPoint: centerPoint,
aSquare: a * a,
bSquare: b * b,
radius: radius,
name: name,
);
await EntityService.instance.addOrUpdate(
EntityType.location,
json.encode(updatedLoationTag.toJson()),
id: locationTagEntity.id,
);
Bus.instance.fire(
LocationTagUpdatedEvent(
LocTagEventType.update,
updatedLocTagEntities: [
LocalEntity(updatedLoationTag, locationTagEntity.id)
],
),
);
} catch (e, s) {
_logger.severe("Failed to update location tag", e, s);
rethrow;
}
return res;
}
Map<String, List<String>> clusterFilesByLocation() {
final map = HashMap<String, List<String>>();
var locations = prefs!.getStringList('locations');
locations ??= [];
for (String locationData in locations) {
final locationJson = json.decode(locationData);
map.putIfAbsent(
locationData,
() => getFilesByLocation(locationJson['id'].toString()),
Future<void> deleteLocationTag(String locTagEntityId) async {
try {
await EntityService.instance.deleteEntry(
locTagEntityId,
);
Bus.instance.fire(
LocationTagUpdatedEvent(
LocTagEventType.delete,
),
);
} catch (e, s) {
_logger.severe("Failed to delete location tag", e, s);
rethrow;
}
return map;
}
}
@ -140,12 +201,12 @@ class GPSData {
GPSData(this.latRef, this.lat, this.longRef, this.long);
List<double> 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,
);
}
}

View file

@ -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<List<GenericSearchResult>> getLocationSearchResults(
// String query,
// ) async {
// final List<GenericSearchResult> searchResults = [];
// try {
// final List<File> allFiles = await _getAllFiles();
// // This code used an deprecated API earlier. We've retained the
// // scaffolding for when we implement a client side location search, and
// // meanwhile have replaced the API response.data with an empty map here.
// final matchedLocationSearchResults = LocationApiResponse.fromMap({});
// for (var locationData in matchedLocationSearchResults.results) {
// final List<File> filesInLocation = [];
// for (var file in allFiles) {
// if (_isValidLocation(file.location) &&
// _isLocationWithinBounds(file.location!, locationData)) {
// filesInLocation.add(file);
// }
// }
// filesInLocation.sort(
// (first, second) =>
// second.creationTime!.compareTo(first.creationTime!),
// );
// if (filesInLocation.isNotEmpty) {
// searchResults.add(
// GenericSearchResult(
// ResultType.location,
// locationData.place,
// filesInLocation,
// ),
// );
// }
// }
// } catch (e) {
// _logger.severe(e);
// }
// return searchResults;
// }
// getFilteredCollectionsWithThumbnail removes deleted or archived or
// collections which don't have a file from search result
Future<List<AlbumSearchResult>> getCollectionSearchResults(
@ -269,27 +226,41 @@ class SearchService {
Future<List<GenericSearchResult>> getLocationResults(
String query,
) async {
final locations =
(await LocationService.instance.getLocationTags()).map((e) => e.item);
final Map<LocationTag, List<File>> result = {};
final List<GenericSearchResult> searchResults = [];
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<File>.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<LocationTag, List<File>> 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<Tuple3<int, MonthData, int?>> _getPossibleEventDate(String query) {
final List<Tuple3<int, MonthData, int?>> possibleEvents = [];
if (query.trim().isEmpty) {

View file

@ -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<double> coordinates;
final Widget child;
const LocationTagDataStateProvider(this.coordinates, this.child, {super.key});
@override
State<LocationTagDataStateProvider> createState() =>
_LocationTagDataStateProviderState();
}
class _LocationTagDataStateProviderState
extends State<LocationTagDataStateProvider> {
int selectedRaduisIndex = defaultRadiusValueIndex;
late List<double> 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<double> 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<InheritedLocationTagData>()!;
}
@override
bool updateShouldNotify(InheritedLocationTagData oldWidget) {
return oldWidget.selectedRadiusIndex != selectedRadiusIndex;
}
}

View file

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

View file

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

View file

@ -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<TextInputWidget> {
ExecutionState executionState = ExecutionState.idle;
final _textController = TextEditingController();
late final TextEditingController _textController;
final _debouncer = Debouncer(const Duration(milliseconds: 300));
late final ValueNotifier<bool> _obscureTextNotifier;
@ -82,6 +88,7 @@ class _TextInputWidgetState extends State<TextInputWidget> {
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<TextInputWidget> {
}
_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<TextInputWidget> {
widget.cancelNotifier?.removeListener(_onCancel);
_obscureTextNotifier.dispose();
_textController.dispose();
widget.isEmptyNotifier?.dispose();
super.dispose();
}
@ -318,8 +332,12 @@ class _TextInputWidgetState extends State<TextInputWidget> {
}
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}) {

View file

@ -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: <Widget>[
const SizedBox(height: toolbarHeight),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
flexibleSpaceTitle == null
? const SizedBox.shrink()
: flexibleSpaceTitle!,
flexibleSpaceCaption == null
? const SizedBox.shrink()
: Text(
flexibleSpaceCaption!,
style: textTheme.small.copyWith(
color: colorTheme.textMuted,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
)
],
),
),
],
),
),
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: <Widget>[
SizedBox(height: toolbarHeight),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
flexibleSpaceTitle == null
? const SizedBox.shrink()
: flexibleSpaceTitle!,
flexibleSpaceCaption == null
? const SizedBox.shrink()
: Text(
flexibleSpaceCaption!,
style: textTheme.smallMuted,
overflow: TextOverflow.ellipsis,
maxLines: 1,
)
],
),
),
],
),
),
);
}
}

View file

@ -38,6 +38,7 @@ class LazyLoadingGallery extends StatefulWidget {
final Stream<int> 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<LazyLoadingGallery> {
_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<LazyLoadingGallery> {
_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<LazyLoadingGridView> {
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<LazyLoadingGridView> {
);
}
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<void> _onLongPressWithSelectionLimit(File file) async {
if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.pick) {
final ioFile = await getFile(file);
MediaExtension().setResult("file://${ioFile!.path}");
} else {
_routeToDetailPage(file, context);
}
}
void _routeToDetailPage(File file, BuildContext context) {
final page = DetailPage(
DetailPageConfiguration(

View file

@ -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<ImageEditorPage> {
final assetEntity = await widget.originalFile.getAsset;
if (assetEntity != null) {
final latLong = await assetEntity.latlngAsync();
newFile.location = Location(latLong.latitude, latLong.longitude);
newFile.location = Location(
latitude: latLong.latitude,
longitude: latLong.longitude,
);
}
}
newFile.generatedID = await FilesDB.instance.insert(newFile);

View file

@ -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<FileDetailsWidget> {
? 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<FileDetailsWidget> {
}
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<String, IfdTag> exif) {

View file

@ -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),
);
}

View file

@ -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<StatefulWidget> createState() {
return CreateLocationState();
}
}
class CreateLocationState extends State<CreateLocation> {
TextEditingController locationController = TextEditingController();
List<TextEditingController> centerPointController = List.from(
[TextEditingController(text: "0.0"), TextEditingController(text: "0.0")],
);
List<double> centerPoint = List.of([0, 0]);
final List<double> 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: <Widget>[
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,
),
),
)
],
),
),
);
}
}

View file

@ -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<String> fileIDs;
const LocationFilesList({super.key, required this.fileIDs});
Future<void> 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",
),
),
)
],
),
),
);
}
}

View file

@ -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<double> coordinates;
const LocationTagsWidget(this.coordinates, {super.key});
final Location centerPoint;
const LocationTagsWidget(this.centerPoint, {super.key});
@override
State<LocationTagsWidget> createState() => _LocationTagsWidgetState();
}
class _LocationTagsWidgetState extends State<LocationTagsWidget> {
String title = "Add location";
IconData leadingIcon = Icons.add_location_alt_outlined;
bool hasChipButtons = false;
String? title;
IconData? leadingIcon;
bool? hasChipButtons;
late Future<List<Widget>> locationTagChips;
late StreamSubscription<LocationTagUpdatedEvent> _locTagUpdateListener;
@override
void initState() {
locationTagChips = _getLocationTags();
_locTagUpdateListener =
Bus.instance.on<LocationTagUpdatedEvent>().listen((event) {
locationTagChips = _getLocationTags();
});
super.initState();
}
@override
void dispose() {
_locTagUpdateListener.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
@ -34,52 +51,66 @@ class _LocationTagsWidgetState extends State<LocationTagsWidget> {
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<List<Widget>> _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;

View file

@ -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<Gallery> {
widget.shouldCollateFilesByDay,
logTag: _logTag,
photoGirdSize: _photoGridSize,
limitSelectionToOne: widget.limitSelectionToOne,
);
if (widget.header != null && index == 0) {
gallery = Column(children: [widget.header!, gallery]);

View file

@ -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<double> 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<AddLocationSheet> createState() => _AddLocationSheetState();
@ -56,12 +56,17 @@ class _AddLocationSheetState extends State<AddLocationSheet> {
final ValueNotifier<int?> _memoriesCountNotifier = ValueNotifier(null);
final ValueNotifier<bool> _submitNotifer = ValueNotifier(false);
final ValueNotifier<bool> _cancelNotifier = ValueNotifier(false);
final ValueNotifier<int> _selectedRadiusIndexNotifier =
ValueNotifier(defaultRadiusValueIndex);
final _focusNode = FocusNode();
final _textEditingController = TextEditingController();
final _isEmptyNotifier = ValueNotifier(true);
Widget? _keyboardTopButtons;
@override
void initState() {
_focusNode.addListener(_focusNodeListener);
_selectedRadiusIndexNotifier.addListener(_selectedRadiusIndexListener);
super.initState();
}
@ -70,6 +75,7 @@ class _AddLocationSheetState extends State<AddLocationSheet> {
_focusNode.removeListener(_focusNodeListener);
_submitNotifer.dispose();
_cancelNotifier.dispose();
_selectedRadiusIndexNotifier.dispose();
super.dispose();
}
@ -89,6 +95,9 @@ class _AddLocationSheetState extends State<AddLocationSheet> {
),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(
decelerationRate: ScrollDecelerationRate.fast,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -96,21 +105,50 @@ class _AddLocationSheetState extends State<AddLocationSheet> {
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<AddLocationSheet> {
),
),
const SizedBox(height: 24),
AddLocationGalleryWidget(_memoriesCountNotifier),
DynamicLocationGalleryWidget(
_memoriesCountNotifier,
"Add_location",
),
],
),
),
@ -184,17 +225,16 @@ class _AddLocationSheetState extends State<AddLocationSheet> {
);
}
Future<void> _addLocationTag(String locationName) async {
Future<void> _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<AddLocationSheet> {
KeyboardOverlay.removeOverlay();
}
}
void _selectedRadiusIndexListener() {
InheritedLocationTagData.of(
context,
).updateSelectedIndex(
_selectedRadiusIndexNotifier.value,
);
_memoriesCountNotifier.value = null;
}
}

View file

@ -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<int?> memoriesCountNotifier;
const AddLocationGalleryWidget(this.memoriesCountNotifier, {super.key});
final String tagPrefix;
const DynamicLocationGalleryWidget(
this.memoriesCountNotifier,
this.tagPrefix, {
super.key,
});
@override
State<AddLocationGalleryWidget> createState() =>
_AddLocationGalleryWidgetState();
State<DynamicLocationGalleryWidget> createState() =>
_DynamicLocationGalleryWidgetState();
}
class _AddLocationGalleryWidgetState extends State<AddLocationGalleryWidget> {
class _DynamicLocationGalleryWidgetState
extends State<DynamicLocationGalleryWidget> {
late final Future<FileLoadResult> fileLoadResult;
late Future<void> 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<AddLocationGalleryWidget> {
final stopWatch = Stopwatch()..start();
final copyOfFiles = List<File>.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<AddLocationGalleryWidget> {
}
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<AddLocationGalleryWidget> {
),
),
child: Gallery(
key: ValueKey(selectedRadius),
loadingWidget: const SizedBox.shrink(),
disableScroll: true,
asyncLoader: (
@ -95,7 +108,7 @@ class _AddLocationGalleryWidgetState extends State<AddLocationGalleryWidget> {
}) async {
return snapshot.data as FileLoadResult;
},
tagPrefix: "Add location",
tagPrefix: widget.tagPrefix,
shouldCollateFilesByDay: false,
),
);
@ -112,15 +125,6 @@ class _AddLocationGalleryWidgetState extends State<AddLocationGalleryWidget> {
InheritedLocationTagData.of(context).selectedRadiusIndex];
}
Future<void> _removeIgnoredFiles(Future<FileLoadResult> result) async {
final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
(await result).files.removeWhere(
(f) =>
f.uploadedFileID == null &&
IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f),
);
}
double _galleryHeight(int fileCount) {
final photoGridSize = LocalSettings.instance.getPhotoGridSize();
final totalWhiteSpaceBetweenPhotos =
@ -136,33 +140,4 @@ class _AddLocationGalleryWidgetState extends State<AddLocationGalleryWidget> {
(galleryGridSpacing * (numberOfRows - 1));
return galleryHeight + 120;
}
Future<FileLoadResult> _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,
);
}
}
}

View file

@ -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,
),
],
);
}
}

View file

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

View file

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

View file

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

View file

@ -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<int?> memoriesCountNotifier;
const RadiusPickerWidget(this.memoriesCountNotifier, {super.key});
///This notifier can be listened to get the selected radius index from
///a parent widget.
final ValueNotifier<int> selectedRadiusIndexNotifier;
const RadiusPickerWidget(
this.selectedRadiusIndexNotifier, {
super.key,
});
@override
State<RadiusPickerWidget> createState() => _RadiusPickerWidgetState();
}
class _RadiusPickerWidgetState extends State<RadiusPickerWidget> {
//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<RadiusPickerWidget> {
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,

View file

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

View file

@ -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:

View file

@ -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: