mirror of
https://github.com/immich-app/immich.git
synced 2025-01-12 15:32:36 +02:00
feat(mobile): edit date time & location (#5461)
* chore: text correction * fix: update activities stat only when the widget is mounted * feat(mobile): edit date time * feat(mobile): edit location * chore(build): update gradle wrapper - 7.6.3 * style: dropdownmenu styling * style: wrap locationpicker in singlechildscrollview * test: add unit test for getTZAdjustedTimeAndOffset * pr changes --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
84c5b08c25
commit
086a957a2b
@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
|
||||||
distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2
|
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80
|
@ -144,6 +144,8 @@
|
|||||||
"control_bottom_app_bar_stack": "Stack",
|
"control_bottom_app_bar_stack": "Stack",
|
||||||
"control_bottom_app_bar_unarchive": "Unarchive",
|
"control_bottom_app_bar_unarchive": "Unarchive",
|
||||||
"control_bottom_app_bar_upload": "Upload",
|
"control_bottom_app_bar_upload": "Upload",
|
||||||
|
"control_bottom_app_bar_edit_time": "Edit Date & Time",
|
||||||
|
"control_bottom_app_bar_edit_location": "Edit Location",
|
||||||
"create_album_page_untitled": "Untitled",
|
"create_album_page_untitled": "Untitled",
|
||||||
"create_shared_album_page_create": "Create",
|
"create_shared_album_page_create": "Create",
|
||||||
"create_shared_album_page_share": "Share",
|
"create_shared_album_page_share": "Share",
|
||||||
@ -165,6 +167,7 @@
|
|||||||
"exif_bottom_sheet_description": "Add Description...",
|
"exif_bottom_sheet_description": "Add Description...",
|
||||||
"exif_bottom_sheet_details": "DETAILS",
|
"exif_bottom_sheet_details": "DETAILS",
|
||||||
"exif_bottom_sheet_location": "LOCATION",
|
"exif_bottom_sheet_location": "LOCATION",
|
||||||
|
"exif_bottom_sheet_location_add": "Add a location",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
||||||
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
||||||
"experimental_settings_subtitle": "Use at your own risk!",
|
"experimental_settings_subtitle": "Use at your own risk!",
|
||||||
@ -461,5 +464,18 @@
|
|||||||
"viewer_remove_from_stack": "Remove from Stack",
|
"viewer_remove_from_stack": "Remove from Stack",
|
||||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||||
"viewer_unstack": "Un-Stack",
|
"viewer_unstack": "Un-Stack",
|
||||||
"scaffold_body_error_occured": "Error occured"
|
"scaffold_body_error_occurred": "Error occurred",
|
||||||
|
"edit_date_time_dialog_date_time": "Date and Time",
|
||||||
|
"edit_date_time_dialog_timezone": "Timezone",
|
||||||
|
"action_common_cancel": "Cancel",
|
||||||
|
"action_common_update": "Update",
|
||||||
|
"edit_location_dialog_title": "Location",
|
||||||
|
"map_location_picker_page_use_location": "Use this location",
|
||||||
|
"location_picker_choose_on_map": "Choose on map",
|
||||||
|
"location_picker_latitude": "Latitude",
|
||||||
|
"location_picker_latitude_hint": "Enter your latitude here",
|
||||||
|
"location_picker_latitude_error": "Enter a valid latitude",
|
||||||
|
"location_picker_longitude": "Longitude",
|
||||||
|
"location_picker_longitude_hint": "Enter your longitude here",
|
||||||
|
"location_picker_longitude_error": "Enter a valid longitude"
|
||||||
}
|
}
|
||||||
|
36
mobile/lib/extensions/asset_extensions.dart
Normal file
36
mobile/lib/extensions/asset_extensions.dart
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:timezone/timezone.dart';
|
||||||
|
|
||||||
|
extension TZExtension on Asset {
|
||||||
|
/// Returns the created time of the asset from the exif info (if available) or from
|
||||||
|
/// the fileCreatedAt field, adjusted to the timezone value from the exif info along with
|
||||||
|
/// the timezone offset in [Duration]
|
||||||
|
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
|
||||||
|
DateTime dt = fileCreatedAt.toLocal();
|
||||||
|
if (exifInfo?.dateTimeOriginal != null) {
|
||||||
|
dt = exifInfo!.dateTimeOriginal!;
|
||||||
|
if (exifInfo?.timeZone != null) {
|
||||||
|
dt = dt.toUtc();
|
||||||
|
try {
|
||||||
|
final location = getLocation(exifInfo!.timeZone!);
|
||||||
|
dt = TZDateTime.from(dt, location);
|
||||||
|
} on LocationNotFoundException {
|
||||||
|
RegExp re = RegExp(
|
||||||
|
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final m = re.firstMatch(exifInfo!.timeZone!);
|
||||||
|
if (m != null) {
|
||||||
|
final duration = Duration(
|
||||||
|
hours: int.parse(m.group(1) ?? '0'),
|
||||||
|
minutes: int.parse(m.group(2) ?? '0'),
|
||||||
|
);
|
||||||
|
dt = dt.add(duration);
|
||||||
|
return (dt, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (dt, dt.timeZoneOffset);
|
||||||
|
}
|
||||||
|
}
|
4
mobile/lib/extensions/duration_extensions.dart
Normal file
4
mobile/lib/extensions/duration_extensions.dart
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
extension TZOffsetExtension on Duration {
|
||||||
|
String formatAsOffset() =>
|
||||||
|
"${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
|
||||||
|
}
|
@ -95,7 +95,11 @@ class ActivityStatisticsNotifier extends StateNotifier<int> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchStatistics() async {
|
Future<void> fetchStatistics() async {
|
||||||
state = await _activityService.getStatistics(albumId, assetId: assetId);
|
final count =
|
||||||
|
await _activityService.getStatistics(albumId, assetId: assetId);
|
||||||
|
if (mounted) {
|
||||||
|
state = count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addActivity() async {
|
Future<void> addActivity() async {
|
||||||
|
@ -4,14 +4,15 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:timezone/timezone.dart';
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
|
||||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||||
|
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@ -21,53 +22,30 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
|
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
|
||||||
|
|
||||||
bool hasCoordinates(ExifInfo? exifInfo) =>
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final assetWithExif = ref.watch(assetDetailProvider(asset));
|
||||||
|
final exifInfo = (assetWithExif.value ?? asset).exifInfo;
|
||||||
|
var textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||||
|
|
||||||
|
bool hasCoordinates() =>
|
||||||
exifInfo != null &&
|
exifInfo != null &&
|
||||||
exifInfo.latitude != null &&
|
exifInfo.latitude != null &&
|
||||||
exifInfo.longitude != null &&
|
exifInfo.longitude != null &&
|
||||||
exifInfo.latitude != 0 &&
|
exifInfo.latitude != 0 &&
|
||||||
exifInfo.longitude != 0;
|
exifInfo.longitude != 0;
|
||||||
|
|
||||||
String formatTimeZone(Duration d) =>
|
String formattedDateTime() {
|
||||||
"GMT${d.isNegative ? '-' : '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
|
final (dt, timeZone) =
|
||||||
|
(assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset();
|
||||||
String get formattedDateTime {
|
|
||||||
DateTime dt = asset.fileCreatedAt.toLocal();
|
|
||||||
String? timeZone;
|
|
||||||
if (asset.exifInfo?.dateTimeOriginal != null) {
|
|
||||||
dt = asset.exifInfo!.dateTimeOriginal!;
|
|
||||||
if (asset.exifInfo?.timeZone != null) {
|
|
||||||
dt = dt.toUtc();
|
|
||||||
try {
|
|
||||||
final location = getLocation(asset.exifInfo!.timeZone!);
|
|
||||||
dt = TZDateTime.from(dt, location);
|
|
||||||
} on LocationNotFoundException {
|
|
||||||
RegExp re = RegExp(
|
|
||||||
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
final m = re.firstMatch(asset.exifInfo!.timeZone!);
|
|
||||||
if (m != null) {
|
|
||||||
final duration = Duration(
|
|
||||||
hours: int.parse(m.group(1) ?? '0'),
|
|
||||||
minutes: int.parse(m.group(2) ?? '0'),
|
|
||||||
);
|
|
||||||
dt = dt.add(duration);
|
|
||||||
timeZone = formatTimeZone(duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final date = DateFormat.yMMMEd().format(dt);
|
final date = DateFormat.yMMMEd().format(dt);
|
||||||
final time = DateFormat.jm().format(dt);
|
final time = DateFormat.jm().format(dt);
|
||||||
timeZone ??= formatTimeZone(dt.timeZoneOffset);
|
|
||||||
|
|
||||||
return '$date • $time $timeZone';
|
return '$date • $time GMT${timeZone.formatAsOffset()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {
|
Future<Uri?> createCoordinatesUri() async {
|
||||||
if (!hasCoordinates(exifInfo)) {
|
if (!hasCoordinates()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,24 +86,20 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final assetWithExif = ref.watch(assetDetailProvider(asset));
|
|
||||||
final exifInfo = (assetWithExif.value ?? asset).exifInfo;
|
|
||||||
var textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
|
||||||
|
|
||||||
buildMap() {
|
buildMap() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return MapThumbnail(
|
return MapThumbnail(
|
||||||
|
showAttribution: false,
|
||||||
coords: LatLng(
|
coords: LatLng(
|
||||||
exifInfo?.latitude ?? 0,
|
exifInfo?.latitude ?? 0,
|
||||||
exifInfo?.longitude ?? 0,
|
exifInfo?.longitude ?? 0,
|
||||||
),
|
),
|
||||||
height: 150,
|
height: 150,
|
||||||
zoom: 16.0,
|
width: constraints.maxWidth,
|
||||||
|
zoom: 12.0,
|
||||||
markers: [
|
markers: [
|
||||||
Marker(
|
Marker(
|
||||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||||
@ -139,7 +113,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
onTap: (tapPosition, latLong) async {
|
onTap: (tapPosition, latLong) async {
|
||||||
Uri? uri = await _createCoordinatesUri(exifInfo);
|
Uri? uri = await createCoordinatesUri();
|
||||||
|
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
return;
|
return;
|
||||||
@ -181,8 +155,26 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
buildLocation() {
|
buildLocation() {
|
||||||
// Guard no lat/lng
|
// Guard no lat/lng
|
||||||
if (!hasCoordinates(exifInfo)) {
|
if (!hasCoordinates()) {
|
||||||
return Container();
|
return asset.isRemote
|
||||||
|
? ListTile(
|
||||||
|
minLeadingWidth: 0,
|
||||||
|
contentPadding: const EdgeInsets.all(0),
|
||||||
|
leading: const Icon(Icons.location_on),
|
||||||
|
title: Text(
|
||||||
|
"exif_bottom_sheet_location_add",
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
onTap: () => handleEditLocation(
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
[assetWithExif.value ?? asset],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@ -190,14 +182,30 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
// Location
|
// Location
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"exif_bottom_sheet_location",
|
"exif_bottom_sheet_location",
|
||||||
style: context.textTheme.labelMedium?.copyWith(
|
style: context.textTheme.labelMedium?.copyWith(
|
||||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
color:
|
||||||
|
context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
|
if (asset.isRemote)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => handleEditLocation(
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
[assetWithExif.value ?? asset],
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
buildMap(),
|
buildMap(),
|
||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
@ -233,12 +241,27 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildDate() {
|
buildDate() {
|
||||||
return Text(
|
return Row(
|
||||||
formattedDateTime,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
formattedDateTime(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (asset.isRemote)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => handleEditDateTime(
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
[assetWithExif.value ?? asset],
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,7 +386,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
flex: hasCoordinates(exifInfo) ? 5 : 0,
|
flex: hasCoordinates() ? 5 : 0,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
child: buildLocation(),
|
child: buildLocation(),
|
||||||
@ -402,9 +425,8 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
child: CircularProgressIndicator.adaptive(),
|
child: CircularProgressIndicator.adaptive(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8.0),
|
|
||||||
buildLocation(),
|
buildLocation(),
|
||||||
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
|
SizedBox(height: hasCoordinates() ? 16.0 : 6.0),
|
||||||
buildDetail(),
|
buildDetail(),
|
||||||
const SizedBox(height: 50),
|
const SizedBox(height: 50),
|
||||||
],
|
],
|
||||||
|
@ -19,6 +19,8 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
final void Function() onCreateNewAlbum;
|
final void Function() onCreateNewAlbum;
|
||||||
final void Function() onUpload;
|
final void Function() onUpload;
|
||||||
final void Function() onStack;
|
final void Function() onStack;
|
||||||
|
final void Function() onEditTime;
|
||||||
|
final void Function() onEditLocation;
|
||||||
|
|
||||||
final List<Album> albums;
|
final List<Album> albums;
|
||||||
final List<Album> sharedAlbums;
|
final List<Album> sharedAlbums;
|
||||||
@ -37,6 +39,8 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
required this.onCreateNewAlbum,
|
required this.onCreateNewAlbum,
|
||||||
required this.onUpload,
|
required this.onUpload,
|
||||||
required this.onStack,
|
required this.onStack,
|
||||||
|
required this.onEditTime,
|
||||||
|
required this.onEditLocation,
|
||||||
this.selectionAssetState = const SelectionAssetState(),
|
this.selectionAssetState = const SelectionAssetState(),
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@ -74,6 +78,18 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
label: "control_bottom_app_bar_favorite".tr(),
|
label: "control_bottom_app_bar_favorite".tr(),
|
||||||
onPressed: enabled ? onFavorite : null,
|
onPressed: enabled ? onFavorite : null,
|
||||||
),
|
),
|
||||||
|
if (hasRemote)
|
||||||
|
ControlBoxButton(
|
||||||
|
iconData: Icons.edit_calendar_outlined,
|
||||||
|
label: "control_bottom_app_bar_edit_time".tr(),
|
||||||
|
onPressed: enabled ? onEditTime : null,
|
||||||
|
),
|
||||||
|
if (hasRemote)
|
||||||
|
ControlBoxButton(
|
||||||
|
iconData: Icons.edit_location_alt_outlined,
|
||||||
|
label: "control_bottom_app_bar_edit_location".tr(),
|
||||||
|
onPressed: enabled ? onEditLocation : null,
|
||||||
|
),
|
||||||
ControlBoxButton(
|
ControlBoxButton(
|
||||||
iconData: Icons.delete_outline_rounded,
|
iconData: Icons.delete_outline_rounded,
|
||||||
label: "control_bottom_app_bar_delete".tr(),
|
label: "control_bottom_app_bar_delete".tr(),
|
||||||
|
@ -312,6 +312,34 @@ class HomePage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onEditTime() async {
|
||||||
|
try {
|
||||||
|
final remoteAssets = ownedRemoteSelection(
|
||||||
|
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||||
|
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
|
||||||
|
);
|
||||||
|
if (remoteAssets.isNotEmpty) {
|
||||||
|
handleEditDateTime(ref, context, remoteAssets.toList());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
selectionEnabledHook.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onEditLocation() async {
|
||||||
|
try {
|
||||||
|
final remoteAssets = ownedRemoteSelection(
|
||||||
|
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||||
|
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
|
||||||
|
);
|
||||||
|
if (remoteAssets.isNotEmpty) {
|
||||||
|
handleEditLocation(ref, context, remoteAssets.toList());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
selectionEnabledHook.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> refreshAssets() async {
|
Future<void> refreshAssets() async {
|
||||||
final fullRefresh = refreshCount.value > 0;
|
final fullRefresh = refreshCount.value > 0;
|
||||||
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
||||||
@ -411,6 +439,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
enabled: !processing.value,
|
enabled: !processing.value,
|
||||||
selectionAssetState: selectionAssetState.value,
|
selectionAssetState: selectionAssetState.value,
|
||||||
onStack: onStack,
|
onStack: onStack,
|
||||||
|
onEditTime: onEditTime,
|
||||||
|
onEditLocation: onEditLocation,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
113
mobile/lib/modules/map/ui/map_location_picker.dart
Normal file
113
mobile/lib/modules/map/ui/map_location_picker.dart
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
|
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
class MapLocationPickerPage extends HookConsumerWidget {
|
||||||
|
final LatLng? initialLatLng;
|
||||||
|
|
||||||
|
const MapLocationPickerPage({super.key, this.initialLatLng});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final selectedLatLng = useState<LatLng>(initialLatLng ?? LatLng(0, 0));
|
||||||
|
final isDarkTheme =
|
||||||
|
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
|
||||||
|
final isLoading =
|
||||||
|
ref.watch(mapStateNotifier.select((state) => state.isLoading));
|
||||||
|
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
// Override app theme based on map theme
|
||||||
|
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||||
|
child: Scaffold(
|
||||||
|
extendBodyBehindAppBar: true,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
if (!isLoading)
|
||||||
|
FlutterMap(
|
||||||
|
options: MapOptions(
|
||||||
|
maxBounds:
|
||||||
|
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
|
||||||
|
interactiveFlags: InteractiveFlag.doubleTapZoom |
|
||||||
|
InteractiveFlag.drag |
|
||||||
|
InteractiveFlag.flingAnimation |
|
||||||
|
InteractiveFlag.pinchMove |
|
||||||
|
InteractiveFlag.pinchZoom,
|
||||||
|
center: LatLng(20, 20),
|
||||||
|
zoom: 2,
|
||||||
|
minZoom: 1,
|
||||||
|
maxZoom: maxZoom,
|
||||||
|
onTap: (tapPosition, point) => selectedLatLng.value = point,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
ref.read(mapStateNotifier.notifier).getTileLayer(),
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||||
|
point: selectedLatLng.value,
|
||||||
|
builder: (ctx) => const Image(
|
||||||
|
image: AssetImage('assets/location-pin.png'),
|
||||||
|
),
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isLoading)
|
||||||
|
Positioned(
|
||||||
|
top: context.height * 0.35,
|
||||||
|
left: context.width * 0.425,
|
||||||
|
child: const ImmichLoadingIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomSheet: BottomSheet(
|
||||||
|
onClosing: () {},
|
||||||
|
builder: (context) => SizedBox(
|
||||||
|
height: 150,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}",
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: context.primaryColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => context.autoPop(selectedLatLng.value),
|
||||||
|
child: const Text("map_location_picker_page_use_location")
|
||||||
|
.tr(),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => context.autoPop(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: context.colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text("action_common_cancel").tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_map/plugin_api.dart';
|
import 'package:flutter_map/plugin_api.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/map/utils/map_controller_hook.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@ -12,13 +14,15 @@ class MapThumbnail extends HookConsumerWidget {
|
|||||||
final double zoom;
|
final double zoom;
|
||||||
final List<Marker> markers;
|
final List<Marker> markers;
|
||||||
final double height;
|
final double height;
|
||||||
|
final double width;
|
||||||
final bool showAttribution;
|
final bool showAttribution;
|
||||||
final bool isDarkTheme;
|
final bool isDarkTheme;
|
||||||
|
|
||||||
const MapThumbnail({
|
const MapThumbnail({
|
||||||
super.key,
|
super.key,
|
||||||
required this.coords,
|
required this.coords,
|
||||||
required this.height,
|
this.height = 100,
|
||||||
|
this.width = 100,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.zoom = 1,
|
this.zoom = 1,
|
||||||
this.showAttribution = true,
|
this.showAttribution = true,
|
||||||
@ -28,18 +32,33 @@ class MapThumbnail extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final mapController = useMapController();
|
||||||
|
final isMapReady = useRef(false);
|
||||||
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
|
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
if (isMapReady.value && mapController.center != coords) {
|
||||||
|
mapController.move(coords, zoom);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[coords],
|
||||||
|
);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: height,
|
height: height,
|
||||||
|
width: width,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
|
mapController: mapController,
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
interactiveFlags: InteractiveFlag.none,
|
interactiveFlags: InteractiveFlag.none,
|
||||||
center: coords,
|
center: coords,
|
||||||
zoom: zoom,
|
zoom: zoom,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
onMapReady: () => isMapReady.value = true,
|
||||||
),
|
),
|
||||||
nonRotatedChildren: [
|
nonRotatedChildren: [
|
||||||
if (showAttribution)
|
if (showAttribution)
|
||||||
|
32
mobile/lib/modules/map/utils/map_controller_hook.dart
Normal file
32
mobile/lib/modules/map/utils/map_controller_hook.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
|
||||||
|
MapController useMapController({
|
||||||
|
String? debugLabel,
|
||||||
|
List<Object?>? keys,
|
||||||
|
}) {
|
||||||
|
return use(_MapControllerHook(keys: keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapControllerHook extends Hook<MapController> {
|
||||||
|
const _MapControllerHook({List<Object?>? keys}) : super(keys: keys);
|
||||||
|
|
||||||
|
@override
|
||||||
|
HookState<MapController, Hook<MapController>> createState() =>
|
||||||
|
_MapControllerHookState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapControllerHookState
|
||||||
|
extends HookState<MapController, _MapControllerHook> {
|
||||||
|
late final controller = MapController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
MapController build(BuildContext context) => controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => controller.dispose();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugLabel => 'useMapController';
|
||||||
|
}
|
@ -55,6 +55,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
|||||||
// in onMapEvent() since MapEventMove#id is not populated properly in the
|
// in onMapEvent() since MapEventMove#id is not populated properly in the
|
||||||
// current version of flutter_map(4.0.0) used
|
// current version of flutter_map(4.0.0) used
|
||||||
bool forceAssetUpdate = false;
|
bool forceAssetUpdate = false;
|
||||||
|
bool isMapReady = false;
|
||||||
late final Debounce debounce;
|
late final Debounce debounce;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -79,7 +80,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
|||||||
bool forceReload = false,
|
bool forceReload = false,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
final bounds = mapController.bounds;
|
final bounds = isMapReady ? mapController.bounds : null;
|
||||||
if (bounds != null) {
|
if (bounds != null) {
|
||||||
final oldAssetsInBounds = assetsInBounds.toSet();
|
final oldAssetsInBounds = assetsInBounds.toSet();
|
||||||
assetsInBounds =
|
assetsInBounds =
|
||||||
@ -455,6 +456,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
|||||||
minZoom: 1,
|
minZoom: 1,
|
||||||
maxZoom: maxZoom,
|
maxZoom: maxZoom,
|
||||||
onMapReady: () {
|
onMapReady: () {
|
||||||
|
isMapReady = true;
|
||||||
mapController.mapEventStream.listen(onMapEvent);
|
mapController.mapEventStream.listen(onMapEvent);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -29,9 +29,8 @@ class CuratedPlacesRow extends CuratedRow {
|
|||||||
onTap: () => context.autoPush(
|
onTap: () => context.autoPush(
|
||||||
const MapRoute(),
|
const MapRoute(),
|
||||||
),
|
),
|
||||||
child: SizedBox(
|
child: SizedBox.square(
|
||||||
height: imageSize,
|
dimension: imageSize,
|
||||||
width: imageSize,
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@ -43,6 +42,7 @@ class CuratedPlacesRow extends CuratedRow {
|
|||||||
5,
|
5,
|
||||||
),
|
),
|
||||||
height: imageSize,
|
height: imageSize,
|
||||||
|
width: imageSize,
|
||||||
showAttribution: false,
|
showAttribution: false,
|
||||||
isDarkTheme: context.isDarkTheme,
|
isDarkTheme: context.isDarkTheme,
|
||||||
),
|
),
|
||||||
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
|
|||||||
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
|
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
|
||||||
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
||||||
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
||||||
|
import 'package:immich_mobile/modules/map/ui/map_location_picker.dart';
|
||||||
import 'package:immich_mobile/modules/map/views/map_page.dart';
|
import 'package:immich_mobile/modules/map/views/map_page.dart';
|
||||||
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
||||||
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
||||||
@ -57,7 +58,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart';
|
|||||||
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
||||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart' hide LatLng;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
part 'router.gr.dart';
|
part 'router.gr.dart';
|
||||||
|
|
||||||
@ -172,6 +174,10 @@ part 'router.gr.dart';
|
|||||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||||
durationInMilliseconds: 200,
|
durationInMilliseconds: 200,
|
||||||
),
|
),
|
||||||
|
CustomRoute<LatLng?>(
|
||||||
|
page: MapLocationPickerPage,
|
||||||
|
guards: [AuthGuard, DuplicateGuard],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppRouter extends _$AppRouter {
|
class AppRouter extends _$AppRouter {
|
||||||
|
@ -360,6 +360,19 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
MapLocationPickerRoute.name: (routeData) {
|
||||||
|
final args = routeData.argsAs<MapLocationPickerRouteArgs>(
|
||||||
|
orElse: () => const MapLocationPickerRouteArgs());
|
||||||
|
return CustomPage<LatLng?>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: MapLocationPickerPage(
|
||||||
|
key: args.key,
|
||||||
|
initialLatLng: args.initialLatLng,
|
||||||
|
),
|
||||||
|
opaque: true,
|
||||||
|
barrierDismissible: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
HomeRoute.name: (routeData) {
|
HomeRoute.name: (routeData) {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData,
|
routeData: routeData,
|
||||||
@ -704,6 +717,14 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
duplicateGuard,
|
duplicateGuard,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
RouteConfig(
|
||||||
|
MapLocationPickerRoute.name,
|
||||||
|
path: '/map-location-picker-page',
|
||||||
|
guards: [
|
||||||
|
authGuard,
|
||||||
|
duplicateGuard,
|
||||||
|
],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1621,6 +1642,40 @@ class ActivitiesRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [MapLocationPickerPage]
|
||||||
|
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||||
|
MapLocationPickerRoute({
|
||||||
|
Key? key,
|
||||||
|
LatLng? initialLatLng,
|
||||||
|
}) : super(
|
||||||
|
MapLocationPickerRoute.name,
|
||||||
|
path: '/map-location-picker-page',
|
||||||
|
args: MapLocationPickerRouteArgs(
|
||||||
|
key: key,
|
||||||
|
initialLatLng: initialLatLng,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'MapLocationPickerRoute';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapLocationPickerRouteArgs {
|
||||||
|
const MapLocationPickerRouteArgs({
|
||||||
|
this.key,
|
||||||
|
this.initialLatLng,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final LatLng? initialLatLng;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'MapLocationPickerRouteArgs{key: $key, initialLatLng: $initialLatLng}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [HomePage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
class HomeRoute extends PageRouteInfo<void> {
|
||||||
|
@ -256,6 +256,8 @@ class Asset {
|
|||||||
isFavorite != a.isFavorite ||
|
isFavorite != a.isFavorite ||
|
||||||
isArchived != a.isArchived ||
|
isArchived != a.isArchived ||
|
||||||
isTrashed != a.isTrashed ||
|
isTrashed != a.isTrashed ||
|
||||||
|
a.exifInfo?.latitude != exifInfo?.latitude ||
|
||||||
|
a.exifInfo?.longitude != exifInfo?.longitude ||
|
||||||
// no local stack count or different count from remote
|
// no local stack count or different count from remote
|
||||||
((stackCount == null && a.stackCount != null) ||
|
((stackCount == null && a.stackCount != null) ||
|
||||||
(stackCount != null &&
|
(stackCount != null &&
|
||||||
|
@ -11,6 +11,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
|
|||||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@ -181,4 +182,27 @@ class AssetService {
|
|||||||
Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
|
Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
|
||||||
return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
|
return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Asset?>> changeDateTime(
|
||||||
|
List<Asset> assets,
|
||||||
|
String updatedDt,
|
||||||
|
) {
|
||||||
|
return updateAssets(
|
||||||
|
assets,
|
||||||
|
UpdateAssetDto(dateTimeOriginal: updatedDt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Asset?>> changeLocation(
|
||||||
|
List<Asset> assets,
|
||||||
|
LatLng location,
|
||||||
|
) {
|
||||||
|
return updateAssets(
|
||||||
|
assets,
|
||||||
|
UpdateAssetDto(
|
||||||
|
latitude: location.latitude,
|
||||||
|
longitude: location.longitude,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
257
mobile/lib/shared/ui/date_time_picker.dart
Normal file
257
mobile/lib/shared/ui/date_time_picker.dart
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
import 'package:timezone/timezone.dart';
|
||||||
|
|
||||||
|
Future<String?> showDateTimePicker({
|
||||||
|
required BuildContext context,
|
||||||
|
DateTime? initialDateTime,
|
||||||
|
String? initialTZ,
|
||||||
|
Duration? initialTZOffset,
|
||||||
|
}) {
|
||||||
|
return showDialog<String?>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _DateTimePicker(
|
||||||
|
initialDateTime: initialDateTime,
|
||||||
|
initialTZ: initialTZ,
|
||||||
|
initialTZOffset: initialTZOffset,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
|
||||||
|
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateTimePicker extends HookWidget {
|
||||||
|
final DateTime? initialDateTime;
|
||||||
|
final String? initialTZ;
|
||||||
|
final Duration? initialTZOffset;
|
||||||
|
|
||||||
|
const _DateTimePicker({
|
||||||
|
this.initialDateTime,
|
||||||
|
this.initialTZ,
|
||||||
|
this.initialTZOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
_TimeZoneOffset _getInitiationLocation() {
|
||||||
|
if (initialTZ != null) {
|
||||||
|
try {
|
||||||
|
return _TimeZoneOffset.fromLocation(
|
||||||
|
tz.timeZoneDatabase.get(initialTZ!),
|
||||||
|
);
|
||||||
|
} on LocationNotFoundException {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset;
|
||||||
|
|
||||||
|
if (tzOffset != null) {
|
||||||
|
final offsetInMilli = tzOffset.inMilliseconds;
|
||||||
|
// get all locations with matching offset
|
||||||
|
final locations = tz.timeZoneDatabase.locations.values.where(
|
||||||
|
(location) => location.currentTimeZone.offset == offsetInMilli,
|
||||||
|
);
|
||||||
|
// Prefer locations with abbreviation first
|
||||||
|
final location = locations.firstWhereOrNull(
|
||||||
|
(e) => !e.currentTimeZone.abbreviation.contains("0"),
|
||||||
|
) ??
|
||||||
|
locations.firstOrNull;
|
||||||
|
if (location != null) {
|
||||||
|
return _TimeZoneOffset.fromLocation(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _TimeZoneOffset.fromLocation(tz.getLocation("UTC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns a list of location<name> along with it's offset in duration
|
||||||
|
List<_TimeZoneOffset> getAllTimeZones() {
|
||||||
|
return tz.timeZoneDatabase.locations.values
|
||||||
|
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
|
||||||
|
.map(_TimeZoneOffset.fromLocation)
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final date = useState<DateTime>(initialDateTime ?? DateTime.now());
|
||||||
|
final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation());
|
||||||
|
final timeZones = useMemoized(() => getAllTimeZones(), const []);
|
||||||
|
|
||||||
|
void pickDate() async {
|
||||||
|
final newDate = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: date.value,
|
||||||
|
firstDate: DateTime(1800),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (newDate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newTime = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(date.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newTime == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void popWithDateTime() {
|
||||||
|
final formattedDateTime =
|
||||||
|
DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
|
||||||
|
final dtWithOffset = formattedDateTime +
|
||||||
|
Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
|
||||||
|
.formatAsOffset();
|
||||||
|
context.pop(dtWithOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
contentPadding: const EdgeInsets.all(30),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"edit_date_time_dialog_date_time",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
).tr(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: pickDate,
|
||||||
|
icon: Text(
|
||||||
|
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
|
||||||
|
style: context.textTheme.bodyLarge
|
||||||
|
?.copyWith(color: context.primaryColor),
|
||||||
|
),
|
||||||
|
label: const Icon(
|
||||||
|
Icons.edit_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
"edit_date_time_dialog_timezone",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
).tr(),
|
||||||
|
DropdownMenu(
|
||||||
|
menuHeight: 300,
|
||||||
|
width: 280,
|
||||||
|
inputDecorationTheme: const InputDecorationTheme(
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
trailingIcon: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 10),
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textStyle: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
menuStyle: const MenuStyle(
|
||||||
|
fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
|
||||||
|
alignment: Alignment(-1.25, 0.5),
|
||||||
|
),
|
||||||
|
onSelected: (value) => tzOffset.value = value!,
|
||||||
|
initialSelection: tzOffset.value,
|
||||||
|
dropdownMenuEntries: timeZones
|
||||||
|
.map(
|
||||||
|
(t) => DropdownMenuEntry<_TimeZoneOffset>(
|
||||||
|
value: t,
|
||||||
|
label: t.display,
|
||||||
|
style: ButtonStyle(
|
||||||
|
textStyle: MaterialStatePropertyAll(
|
||||||
|
context.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: Text(
|
||||||
|
"action_common_cancel",
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.colorScheme.error,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: popWithDateTime,
|
||||||
|
child: Text(
|
||||||
|
"action_common_update",
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
|
||||||
|
final String display;
|
||||||
|
final Location location;
|
||||||
|
|
||||||
|
const _TimeZoneOffset({
|
||||||
|
required this.display,
|
||||||
|
required this.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
_TimeZoneOffset copyWith({
|
||||||
|
String? display,
|
||||||
|
Location? location,
|
||||||
|
}) {
|
||||||
|
return _TimeZoneOffset(
|
||||||
|
display: display ?? this.display,
|
||||||
|
location: location ?? this.location,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get offsetInMilliseconds => location.currentTimeZone.offset;
|
||||||
|
|
||||||
|
_TimeZoneOffset.fromLocation(tz.Location l)
|
||||||
|
: display = _getFormattedOffset(l.currentTimeZone.offset, l),
|
||||||
|
location = l;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int compareTo(_TimeZoneOffset other) {
|
||||||
|
return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'_TimeZoneOffset(display: $display, location: $location)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is _TimeZoneOffset &&
|
||||||
|
other.display == display &&
|
||||||
|
other.offsetInMilliseconds == offsetInMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
|
||||||
|
}
|
256
mobile/lib/shared/ui/location_picker.dart
Normal file
256
mobile/lib/shared/ui/location_picker.dart
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_map/plugin_api.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||||
|
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
Future<LatLng?> showLocationPicker({
|
||||||
|
required BuildContext context,
|
||||||
|
LatLng? initialLatLng,
|
||||||
|
}) {
|
||||||
|
return showDialog<LatLng?>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: false,
|
||||||
|
builder: (ctx) => _LocationPicker(
|
||||||
|
initialLatLng: initialLatLng,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _LocationPickerMode { map, manual }
|
||||||
|
|
||||||
|
bool _validateLat(String value) {
|
||||||
|
final l = double.tryParse(value);
|
||||||
|
return l != null && l > -90 && l < 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateLong(String value) {
|
||||||
|
final l = double.tryParse(value);
|
||||||
|
return l != null && l > -180 && l < 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocationPicker extends HookWidget {
|
||||||
|
final LatLng? initialLatLng;
|
||||||
|
|
||||||
|
const _LocationPicker({
|
||||||
|
this.initialLatLng,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final latitude = useState(initialLatLng?.latitude ?? 0.0);
|
||||||
|
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
||||||
|
final latlng = LatLng(latitude.value, longitude.value);
|
||||||
|
final pickerMode = useState(_LocationPickerMode.map);
|
||||||
|
final latitudeController = useTextEditingController();
|
||||||
|
final isValidLatitude = useState(true);
|
||||||
|
final latitiudeFocusNode = useFocusNode();
|
||||||
|
final longitudeController = useTextEditingController();
|
||||||
|
final longitudeFocusNode = useFocusNode();
|
||||||
|
final isValidLongitude = useState(true);
|
||||||
|
|
||||||
|
void validateInputs() {
|
||||||
|
isValidLatitude.value = _validateLat(latitudeController.text);
|
||||||
|
if (isValidLatitude.value) {
|
||||||
|
latitude.value = latitudeController.text.toDouble();
|
||||||
|
}
|
||||||
|
isValidLongitude.value = _validateLong(longitudeController.text);
|
||||||
|
if (isValidLongitude.value) {
|
||||||
|
longitude.value = longitudeController.text.toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void validateAndPop() {
|
||||||
|
if (pickerMode.value == _LocationPickerMode.manual) {
|
||||||
|
validateInputs();
|
||||||
|
}
|
||||||
|
if (isValidLatitude.value && isValidLongitude.value) {
|
||||||
|
return context.pop(latlng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> buildMapPickerMode() {
|
||||||
|
return [
|
||||||
|
TextButton.icon(
|
||||||
|
icon: Text(
|
||||||
|
"${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}",
|
||||||
|
),
|
||||||
|
label: const Icon(Icons.edit_outlined, size: 16),
|
||||||
|
onPressed: () {
|
||||||
|
latitudeController.text = latitude.value.toStringAsFixed(4);
|
||||||
|
longitudeController.text = longitude.value.toStringAsFixed(4);
|
||||||
|
pickerMode.value = _LocationPickerMode.manual;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 12,
|
||||||
|
),
|
||||||
|
MapThumbnail(
|
||||||
|
coords: latlng,
|
||||||
|
height: 200,
|
||||||
|
width: 200,
|
||||||
|
zoom: 6,
|
||||||
|
showAttribution: false,
|
||||||
|
onTap: (p0, p1) async {
|
||||||
|
final newLatLng = await context.autoPush<LatLng?>(
|
||||||
|
MapLocationPickerRoute(initialLatLng: latlng),
|
||||||
|
);
|
||||||
|
if (newLatLng != null) {
|
||||||
|
latitude.value = newLatLng.latitude;
|
||||||
|
longitude.value = newLatLng.longitude;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||||
|
point: LatLng(
|
||||||
|
latitude.value,
|
||||||
|
longitude.value,
|
||||||
|
),
|
||||||
|
builder: (ctx) => const Image(
|
||||||
|
image: AssetImage('assets/location-pin.png'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> buildManualPickerMode() {
|
||||||
|
return [
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Text("location_picker_choose_on_map").tr(),
|
||||||
|
label: const Icon(Icons.map_outlined, size: 16),
|
||||||
|
onPressed: () {
|
||||||
|
validateInputs();
|
||||||
|
if (isValidLatitude.value && isValidLongitude.value) {
|
||||||
|
pickerMode.value = _LocationPickerMode.map;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 12,
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: latitudeController,
|
||||||
|
focusNode: latitiudeFocusNode,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
autofocus: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'location_picker_latitude'.tr(),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: 'location_picker_latitude_hint'.tr(),
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
errorText: isValidLatitude.value
|
||||||
|
? null
|
||||||
|
: "location_picker_latitude_error".tr(),
|
||||||
|
),
|
||||||
|
onEditingComplete: () {
|
||||||
|
isValidLatitude.value = _validateLat(latitudeController.text);
|
||||||
|
if (isValidLatitude.value) {
|
||||||
|
latitude.value = latitudeController.text.toDouble();
|
||||||
|
longitudeFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||||
|
onTapOutside: (_) => latitiudeFocusNode.unfocus(),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 24,
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: longitudeController,
|
||||||
|
focusNode: longitudeFocusNode,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
autofocus: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'location_picker_longitude'.tr(),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: 'location_picker_longitude_hint'.tr(),
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
errorText: isValidLongitude.value
|
||||||
|
? null
|
||||||
|
: "location_picker_longitude_error".tr(),
|
||||||
|
),
|
||||||
|
onEditingComplete: () {
|
||||||
|
isValidLongitude.value = _validateLong(longitudeController.text);
|
||||||
|
if (isValidLongitude.value) {
|
||||||
|
longitude.value = longitudeController.text.toDouble();
|
||||||
|
longitudeFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||||
|
onTapOutside: (_) => longitudeFocusNode.unfocus(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
contentPadding: const EdgeInsets.all(30),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"edit_location_dialog_title",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
).tr(),
|
||||||
|
const SizedBox(
|
||||||
|
height: 12,
|
||||||
|
),
|
||||||
|
if (pickerMode.value == _LocationPickerMode.manual)
|
||||||
|
...buildManualPickerMode(),
|
||||||
|
if (pickerMode.value == _LocationPickerMode.map)
|
||||||
|
...buildMapPickerMode(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: Text(
|
||||||
|
"action_common_cancel",
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.colorScheme.error,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: validateAndPop,
|
||||||
|
child: Text(
|
||||||
|
"action_common_update",
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -15,7 +15,7 @@ class ScaffoldErrorBody extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"scaffold_body_error_occured",
|
"scaffold_body_error_occurred",
|
||||||
style: context.textTheme.displayMedium,
|
style: context.textTheme.displayMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
).tr(),
|
).tr(),
|
||||||
|
@ -2,12 +2,17 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/date_time_picker.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
import 'package:immich_mobile/shared/ui/location_picker.dart';
|
||||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
void handleShareAssets(
|
void handleShareAssets(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@ -85,3 +90,60 @@ Future<void> handleFavoriteAssets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> handleEditDateTime(
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
List<Asset> selection,
|
||||||
|
) async {
|
||||||
|
DateTime? initialDate;
|
||||||
|
String? timeZone;
|
||||||
|
Duration? offset;
|
||||||
|
if (selection.length == 1) {
|
||||||
|
final asset = selection.first;
|
||||||
|
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
|
||||||
|
final (dt, oft) = assetWithExif.getTZAdjustedTimeAndOffset();
|
||||||
|
initialDate = dt;
|
||||||
|
offset = oft;
|
||||||
|
timeZone = assetWithExif.exifInfo?.timeZone;
|
||||||
|
}
|
||||||
|
final dateTime = await showDateTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialDateTime: initialDate,
|
||||||
|
initialTZ: timeZone,
|
||||||
|
initialTZOffset: offset,
|
||||||
|
);
|
||||||
|
if (dateTime == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleEditLocation(
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
List<Asset> selection,
|
||||||
|
) async {
|
||||||
|
LatLng? initialLatLng;
|
||||||
|
if (selection.length == 1) {
|
||||||
|
final asset = selection.first;
|
||||||
|
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
|
||||||
|
if (assetWithExif.exifInfo?.latitude != null &&
|
||||||
|
assetWithExif.exifInfo?.longitude != null) {
|
||||||
|
initialLatLng = LatLng(
|
||||||
|
assetWithExif.exifInfo!.latitude!,
|
||||||
|
assetWithExif.exifInfo!.longitude!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final location = await showLocationPicker(
|
||||||
|
context: context,
|
||||||
|
initialLatLng: initialLatLng,
|
||||||
|
);
|
||||||
|
if (location == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
|
||||||
|
}
|
||||||
|
131
mobile/test/asset_extensions_test.dart
Normal file
131
mobile/test/asset_extensions_test.dart
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
|
import 'package:timezone/data/latest.dart';
|
||||||
|
import 'package:timezone/timezone.dart';
|
||||||
|
|
||||||
|
ExifInfo makeExif({
|
||||||
|
DateTime? dateTimeOriginal,
|
||||||
|
String? timeZone,
|
||||||
|
}) {
|
||||||
|
return ExifInfo(
|
||||||
|
dateTimeOriginal: dateTimeOriginal,
|
||||||
|
timeZone: timeZone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Asset makeAsset({
|
||||||
|
required String id,
|
||||||
|
required DateTime createdAt,
|
||||||
|
ExifInfo? exifInfo,
|
||||||
|
}) {
|
||||||
|
return Asset(
|
||||||
|
checksum: '',
|
||||||
|
localId: id,
|
||||||
|
remoteId: id,
|
||||||
|
ownerId: 1,
|
||||||
|
fileCreatedAt: createdAt,
|
||||||
|
fileModifiedAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
durationInSeconds: 0,
|
||||||
|
type: AssetType.image,
|
||||||
|
fileName: id,
|
||||||
|
isFavorite: false,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashed: false,
|
||||||
|
stackCount: 0,
|
||||||
|
exifInfo: exifInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Init Timezone DB
|
||||||
|
initializeTimeZones();
|
||||||
|
|
||||||
|
group("Returns local time and offset if no exifInfo", () {
|
||||||
|
test('returns createdAt directly if in local', () {
|
||||||
|
final createdAt = DateTime(2023, 12, 12, 12, 12, 12);
|
||||||
|
final a = makeAsset(id: '1', createdAt: createdAt);
|
||||||
|
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||||
|
|
||||||
|
expect(dt, createdAt);
|
||||||
|
expect(tz, createdAt.timeZoneOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns createdAt in local if in utc', () {
|
||||||
|
final createdAt = DateTime.utc(2023, 12, 12, 12, 12, 12);
|
||||||
|
final a = makeAsset(id: '1', createdAt: createdAt);
|
||||||
|
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||||
|
|
||||||
|
final localCreatedAt = createdAt.toLocal();
|
||||||
|
expect(dt, localCreatedAt);
|
||||||
|
expect(tz, localCreatedAt.timeZoneOffset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("Returns dateTimeOriginal", () {
|
||||||
|
test('Returns dateTimeOriginal in UTC from exifInfo without timezone', () {
|
||||||
|
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||||
|
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||||
|
final e = makeExif(dateTimeOriginal: dateTimeOriginal);
|
||||||
|
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||||
|
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||||
|
|
||||||
|
final dateTimeInUTC = dateTimeOriginal.toUtc();
|
||||||
|
expect(dt, dateTimeInUTC);
|
||||||
|
expect(tz, dateTimeInUTC.timeZoneOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns dateTimeOriginal in UTC from exifInfo with invalid timezone',
|
||||||
|
() {
|
||||||
|
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||||
|
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||||
|
final e = makeExif(
|
||||||
|
dateTimeOriginal: dateTimeOriginal,
|
||||||
|
timeZone: "#_#",
|
||||||
|
); // Invalid timezone
|
||||||
|
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||||
|
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||||
|
|
||||||
|
final dateTimeInUTC = dateTimeOriginal.toUtc();
|
||||||
|
expect(dt, dateTimeInUTC);
|
||||||
|
expect(tz, dateTimeInUTC.timeZoneOffset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("Returns adjusted time if timezone available", () {
|
||||||
|
test('With timezone as location', () {
|
||||||
|
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||||
|
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||||
|
const location = "Asia/Hong_Kong";
|
||||||
|
final e =
|
||||||
|
makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: location);
|
||||||
|
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||||
|
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||||
|
|
||||||
|
final adjustedTime =
|
||||||
|
TZDateTime.from(dateTimeOriginal.toUtc(), getLocation(location));
|
||||||
|
expect(dt, adjustedTime);
|
||||||
|
expect(tz, adjustedTime.timeZoneOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('With timezone as offset', () {
|
||||||
|
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||||
|
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||||
|
const offset = "utc+08:00";
|
||||||
|
final e = makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: offset);
|
||||||
|
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||||
|
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||||
|
|
||||||
|
final location = getLocation("Asia/Hong_Kong");
|
||||||
|
final offsetFromLocation =
|
||||||
|
Duration(milliseconds: location.currentTimeZone.offset);
|
||||||
|
final adjustedTime = dateTimeOriginal.toUtc().add(offsetFromLocation);
|
||||||
|
|
||||||
|
// Adds the offset to the actual time and returns the offset separately
|
||||||
|
expect(dt, adjustedTime);
|
||||||
|
expect(tz, offsetFromLocation);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user