Location info item in file details (#942)
This commit is contained in:
commit
ef4bbaa994
7 changed files with 204 additions and 21 deletions
|
@ -57,3 +57,5 @@ const double restrictedMaxWidth = 430;
|
|||
const double mobileSmallThreshold = 336;
|
||||
|
||||
const publicLinkDeviceLimits = [50, 25, 10, 5, 2, 1];
|
||||
|
||||
const kilometersPerDegree = 111.16;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import "dart:collection";
|
||||
import "dart:convert";
|
||||
import "dart:math";
|
||||
|
||||
import "package:photos/core/constants.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
class LocationService {
|
||||
|
@ -13,7 +15,7 @@ class LocationService {
|
|||
prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
List<String> getLocations() {
|
||||
List<String> getAllLocationTags() {
|
||||
var list = prefs!.getStringList('locations');
|
||||
list ??= [];
|
||||
return list;
|
||||
|
@ -22,22 +24,55 @@ class LocationService {
|
|||
Future<void> addLocation(
|
||||
String location,
|
||||
double lat,
|
||||
double lon,
|
||||
double long,
|
||||
int radius,
|
||||
) async {
|
||||
final list = getLocations();
|
||||
final list = getAllLocationTags();
|
||||
//The area enclosed by the location tag will be a circle on a 3D spherical
|
||||
//globe and an ellipse on a 2D Mercator projection (2D map)
|
||||
//a & b are the semi-major and semi-minor axes of the ellipse
|
||||
//Converting the unit from kilometers to degrees for a and b as that is
|
||||
//the unit on the caritesian plane
|
||||
final a = (radius * _scaleFactor(lat)) / kilometersPerDegree;
|
||||
final b = radius / kilometersPerDegree;
|
||||
final center = [lat, long];
|
||||
final data = {
|
||||
"id": list.length,
|
||||
"name": location,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius": radius,
|
||||
"aSquare": a * a,
|
||||
"bSquare": b * b,
|
||||
"center": center,
|
||||
};
|
||||
final encodedMap = json.encode(data);
|
||||
list.add(encodedMap);
|
||||
await prefs!.setStringList('locations', list);
|
||||
}
|
||||
|
||||
///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));
|
||||
}
|
||||
|
||||
List<String> enclosingLocationTags(List<double> coordinates) {
|
||||
final result = List<String>.of([]);
|
||||
final allLocationTags = getAllLocationTags();
|
||||
for (var locationTag in allLocationTags) {
|
||||
final locationJson = json.decode(locationTag);
|
||||
final aSquare = locationJson["aSquare"];
|
||||
final bSquare = locationJson["bSquare"];
|
||||
final center = locationJson["center"];
|
||||
final x = coordinates[0] - center[0];
|
||||
final y = coordinates[1] - center[1];
|
||||
if ((x * x) / (aSquare) + (y * y) / (bSquare) <= 1) {
|
||||
result.add(locationJson["name"]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> addFileToLocation(int locationId, int fileId) async {
|
||||
final list = getFilesByLocation(locationId.toString());
|
||||
list.add(fileId.toString());
|
||||
|
@ -51,7 +86,7 @@ class LocationService {
|
|||
}
|
||||
|
||||
List<String> getLocationsByFileID(int fileId) {
|
||||
final locationList = getLocations();
|
||||
final locationList = getAllLocationTags();
|
||||
final locations = List<dynamic>.of([]);
|
||||
for (String locationString in locationList) {
|
||||
final locationJson = json.decode(locationString);
|
||||
|
@ -81,3 +116,21 @@ class LocationService {
|
|||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
List<double> toSignedDecimalDegreeCoordinates() {
|
||||
final latSign = latRef == "N" ? 1 : -1;
|
||||
final longSign = longRef == "E" ? 1 : -1;
|
||||
return [
|
||||
latSign * lat[0] + lat[1] / 60 + lat[2] / 3600,
|
||||
longSign * long[0] + long[1] / 60 + long[2] / 3600
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -270,7 +270,7 @@ class SearchService {
|
|||
String query,
|
||||
) async {
|
||||
final List<GenericSearchResult> searchResults = [];
|
||||
final locations = LocationService.instance.getLocations();
|
||||
final locations = LocationService.instance.getAllLocationTags();
|
||||
for (String location in locations) {
|
||||
final locationJson = json.decode(location);
|
||||
final locationName = locationJson["name"].toString();
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -5,6 +5,7 @@ import "package:photos/core/configuration.dart";
|
|||
import "package:photos/models/file.dart";
|
||||
import "package:photos/models/file_type.dart";
|
||||
import "package:photos/services/feature_flag_service.dart";
|
||||
import "package:photos/services/location_service.dart";
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import "package:photos/ui/components/divider_widget.dart";
|
||||
|
@ -16,6 +17,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 +41,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 +59,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 +76,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 +138,30 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
|
|||
},
|
||||
),
|
||||
);
|
||||
if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
|
||||
fileDetailsTiles.addAll([
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _exifNotifier,
|
||||
builder: (context, _, __) {
|
||||
return hasGPSData
|
||||
? Column(
|
||||
children: [
|
||||
LocationTagsWidget(
|
||||
GPSData(
|
||||
_exifData["latRef"],
|
||||
_exifData["lat"],
|
||||
_exifData["longRef"],
|
||||
_exifData["long"],
|
||||
).toSignedDecimalDegreeCoordinates(),
|
||||
),
|
||||
const FileDetailsDivider(),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
)
|
||||
]);
|
||||
}
|
||||
if (_isImage) {
|
||||
fileDetailsTiles.addAll([
|
||||
ValueListenableBuilder(
|
||||
|
@ -200,6 +237,36 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
|
|||
);
|
||||
}
|
||||
|
||||
bool _haGPSData() {
|
||||
return _exifData["lat"] != null &&
|
||||
_exifData["long"] != null &&
|
||||
_exifData["latRef"] != null &&
|
||||
_exifData["longRef"] != null;
|
||||
}
|
||||
|
||||
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"] =
|
||||
|
|
59
lib/ui/viewer/file_details/location_tags_widget.dart
Normal file
59
lib/ui/viewer/file_details/location_tags_widget.dart
Normal file
|
@ -0,0 +1,59 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:photos/services/location_service.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";
|
||||
|
||||
class LocationTagsWidget extends StatefulWidget {
|
||||
final List<double> coordinates;
|
||||
const LocationTagsWidget(this.coordinates, {super.key});
|
||||
|
||||
@override
|
||||
State<LocationTagsWidget> createState() => _LocationTagsWidgetState();
|
||||
}
|
||||
|
||||
class _LocationTagsWidgetState extends State<LocationTagsWidget> {
|
||||
String title = "Add location";
|
||||
IconData leadingIcon = Icons.add_location_alt_outlined;
|
||||
bool hasChipButtons = false;
|
||||
late final Future<List<Widget>> locationTagChips;
|
||||
@override
|
||||
void initState() {
|
||||
locationTagChips = _getLocationTags();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
switchInCurve: Curves.easeInOutExpo,
|
||||
switchOutCurve: Curves.easeInOutExpo,
|
||||
child: InfoItemWidget(
|
||||
key: ValueKey(title),
|
||||
leadingIcon: Icons.add_location_alt_outlined,
|
||||
title: title,
|
||||
subtitleSection: locationTagChips,
|
||||
hasChipButtons: hasChipButtons,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Widget>> _getLocationTags() async {
|
||||
final locationTags =
|
||||
LocationService.instance.enclosingLocationTags(widget.coordinates);
|
||||
if (locationTags.isEmpty) {
|
||||
return [
|
||||
InlineButtonWidget("Group nearby photos", () {}),
|
||||
];
|
||||
}
|
||||
setState(() {
|
||||
title = "Location";
|
||||
leadingIcon = Icons.pin_drop_outlined;
|
||||
hasChipButtons = true;
|
||||
});
|
||||
return locationTags.map((e) => ChipButtonWidget(e)).toList();
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ description: ente photos application
|
|||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
|
||||
version: 0.7.37+437
|
||||
version: 0.7.38+438
|
||||
|
||||
environment:
|
||||
sdk: '>=2.17.0 <3.0.0'
|
||||
|
|
Loading…
Add table
Reference in a new issue