Merge branch 'main' into aspectRatio

This commit is contained in:
Neeraj Gupta 2023-04-12 13:44:17 +05:30
commit 85889d086d
No known key found for this signature in database
GPG key ID: 3C5A1684DC1729E1
57 changed files with 3833 additions and 315 deletions

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"workbench.colorCustomizations": {
"activityBar.background": "#0C227B",
"titleBar.activeBackground": "#1130AC",
"titleBar.activeForeground": "#FCFDFF"
}
}

View file

@ -66,8 +66,9 @@ You can alternatively install the build from PlayStore or F-Droid.
3. Pull in all submodules with `git submodule update --init --recursive`
4. Enable repo git hooks `git config core.hooksPath hooks`
5. Setup TensorFlowLite by executing `setup.sh`
6. For Android, [setup your keystore](https://docs.flutter.dev/deployment/android#create-an-upload-keystore) and run `flutter build apk --release --flavor independent`
7. For iOS, run `flutter build ios`
6. If using Visual Studio Code, add the [Flutter Intl](https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl) extension
7. For Android, [setup your keystore](https://docs.flutter.dev/deployment/android#create-an-upload-keystore) and run `flutter build apk --release --flavor independent`
8. For iOS, run `flutter build ios`
<br/>
## 🙋 Help

View file

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

65
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

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

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

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

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

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -15,7 +15,7 @@ import 'package:photos/core/constants.dart';
import 'package:photos/core/errors.dart';
import 'package:photos/models/file.dart' as ente;
import 'package:photos/models/file_type.dart';
import 'package:photos/models/location.dart';
import "package:photos/models/location/location.dart";
import "package:photos/models/magic_metadata.dart";
import "package:photos/services/file_magic_service.dart";
import 'package:photos/utils/crypto_util.dart';
@ -169,7 +169,8 @@ Future<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

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

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:
@ -756,6 +852,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.1.3"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: e819441678f1679b719008ff2ff0ef045d66eed9f9ec81166ca0d9b02a187454
url: "https://pub.dev"
source: hosted
version: "2.3.2"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338
url: "https://pub.dev"
source: hosted
version: "2.2.0"
frontend_server_client:
dependency: transitive
description:
@ -780,6 +892,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.6"
graphs:
dependency: transitive
description:
name: graphs
sha256: f9e130f3259f52d26f0cfc0e964513796dafed572fa52e45d2f8d6ca14db39b2
url: "https://pub.dev"
source: hosted
version: "2.2.0"
hex:
dependency: transitive
description:
@ -917,13 +1037,21 @@ packages:
source: hosted
version: "0.6.5"
json_annotation:
dependency: transitive
dependency: "direct main"
description:
name: json_annotation
sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317
url: "https://pub.dev"
source: hosted
version: "4.8.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: dadc08bd61f72559f938dd08ec20dbfec6c709bba83515085ea943d2078d187a
url: "https://pub.dev"
source: hosted
version: "6.6.1"
like_button:
dependency: "direct main"
description:
@ -1349,6 +1477,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: ec85d7d55339d85f44ec2b682a82fea340071e8978257e5a43e69f79e98ef50c
url: "https://pub.dev"
source: hosted
version: "1.2.2"
quiver:
dependency: "direct main"
description:
@ -1538,6 +1674,22 @@ packages:
description: flutter
source: sdk
version: "0.0.99"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: c2bea18c95cfa0276a366270afaa2850b09b4a76db95d546f3d003dcc7011298
url: "https://pub.dev"
source: hosted
version: "1.2.7"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
source_map_stack_trace:
dependency: transitive
description:
@ -1723,6 +1875,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.8.0"
timing:
dependency: transitive
description:
name: timing
sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
tuple:
dependency: "direct main"
description:

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.3.0
in_app_purchase: ^3.0.7
intl: ^0.17.0
json_annotation: ^4.8.0
like_button: ^2.0.2
loading_animations: ^2.1.0
local_auth: ^2.1.5
@ -134,9 +136,12 @@ dependency_overrides:
wakelock: ^0.6.1+2
dev_dependencies:
build_runner: ^2.3.3
flutter_lints: ^2.0.1
flutter_test:
sdk: flutter
freezed: ^2.3.2
json_serializable: ^6.6.1
test:
flutter_icons: