merge main

This commit is contained in:
martabal 2023-12-06 00:11:15 +01:00
commit 7e32f8b292
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
28 changed files with 1321 additions and 205 deletions

View file

@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip
distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80

View file

@ -144,6 +144,8 @@
"control_bottom_app_bar_stack": "Stack",
"control_bottom_app_bar_unarchive": "Unarchive",
"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_shared_album_page_create": "Create",
"create_shared_album_page_share": "Share",
@ -165,6 +167,7 @@
"exif_bottom_sheet_description": "Add Description...",
"exif_bottom_sheet_details": "DETAILS",
"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_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!",
@ -461,5 +464,18 @@
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"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"
}

View 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);
}
}

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

View file

@ -95,7 +95,11 @@ class ActivityStatisticsNotifier extends StateNotifier<int> {
}
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 {

View file

@ -4,14 +4,15 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.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: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/map/ui/map_thumbnail.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/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:url_launcher/url_launcher.dart';
@ -21,111 +22,84 @@ class ExifBottomSheet extends HookConsumerWidget {
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
bool hasCoordinates(ExifInfo? exifInfo) =>
exifInfo != null &&
exifInfo.latitude != null &&
exifInfo.longitude != null &&
exifInfo.latitude != 0 &&
exifInfo.longitude != 0;
String formatTimeZone(Duration d) =>
"GMT${d.isNegative ? '-' : '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
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 time = DateFormat.jm().format(dt);
timeZone ??= formatTimeZone(dt.timeZoneOffset);
return '$date$time $timeZone';
}
Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {
if (!hasCoordinates(exifInfo)) {
return null;
}
final double latitude = exifInfo!.latitude!;
final double longitude = exifInfo.longitude!;
const zoomLevel = 16;
if (Platform.isAndroid) {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {
'z': '$zoomLevel',
'q': '$latitude,$longitude($formattedDateTime)',
},
);
if (await canLaunchUrl(uri)) {
return uri;
}
} else if (Platform.isIOS) {
var params = {
'll': '$latitude,$longitude',
'q': formattedDateTime,
'z': '$zoomLevel',
};
Uri uri = Uri.https('maps.apple.com', '/', params);
if (await canLaunchUrl(uri)) {
return uri;
}
}
return Uri(
scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
);
}
@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.latitude != null &&
exifInfo.longitude != null &&
exifInfo.latitude != 0 &&
exifInfo.longitude != 0;
String formattedDateTime() {
final (dt, timeZone) =
(assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset();
final date = DateFormat.yMMMEd().format(dt);
final time = DateFormat.jm().format(dt);
return '$date$time GMT${timeZone.formatAsOffset()}';
}
Future<Uri?> createCoordinatesUri() async {
if (!hasCoordinates()) {
return null;
}
final double latitude = exifInfo!.latitude!;
final double longitude = exifInfo.longitude!;
const zoomLevel = 16;
if (Platform.isAndroid) {
Uri uri = Uri(
scheme: 'geo',
host: '$latitude,$longitude',
queryParameters: {
'z': '$zoomLevel',
'q': '$latitude,$longitude($formattedDateTime)',
},
);
if (await canLaunchUrl(uri)) {
return uri;
}
} else if (Platform.isIOS) {
var params = {
'll': '$latitude,$longitude',
'q': formattedDateTime,
'z': '$zoomLevel',
};
Uri uri = Uri.https('maps.apple.com', '/', params);
if (await canLaunchUrl(uri)) {
return uri;
}
}
return Uri(
scheme: 'https',
host: 'openstreetmap.org',
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
fragment: 'map=$zoomLevel/$latitude/$longitude',
);
}
buildMap() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
return MapThumbnail(
showAttribution: false,
coords: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
height: 150,
zoom: 16.0,
width: constraints.maxWidth,
zoom: 12.0,
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
@ -139,7 +113,7 @@ class ExifBottomSheet extends HookConsumerWidget {
),
],
onTap: (tapPosition, latLong) async {
Uri? uri = await _createCoordinatesUri(exifInfo);
Uri? uri = await createCoordinatesUri();
if (uri == null) {
return;
@ -181,8 +155,26 @@ class ExifBottomSheet extends HookConsumerWidget {
buildLocation() {
// Guard no lat/lng
if (!hasCoordinates(exifInfo)) {
return Container();
if (!hasCoordinates()) {
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(
@ -191,13 +183,29 @@ class ExifBottomSheet extends HookConsumerWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"exif_bottom_sheet_location",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"exif_bottom_sheet_location",
style: context.textTheme.labelMedium?.copyWith(
color:
context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
if (asset.isRemote)
IconButton(
onPressed: () => handleEditLocation(
ref,
context,
[assetWithExif.value ?? asset],
),
icon: const Icon(Icons.edit_outlined),
iconSize: 20,
),
],
),
buildMap(),
RichText(
text: TextSpan(
@ -233,12 +241,27 @@ class ExifBottomSheet extends HookConsumerWidget {
}
buildDate() {
return Text(
formattedDateTime,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
formattedDateTime(),
style: const TextStyle(
fontWeight: FontWeight.bold,
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,
children: [
Flexible(
flex: hasCoordinates(exifInfo) ? 5 : 0,
flex: hasCoordinates() ? 5 : 0,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: buildLocation(),
@ -402,9 +425,8 @@ class ExifBottomSheet extends HookConsumerWidget {
child: CircularProgressIndicator.adaptive(),
),
),
const SizedBox(height: 8.0),
buildLocation(),
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
SizedBox(height: hasCoordinates() ? 16.0 : 6.0),
buildDetail(),
const SizedBox(height: 50),
],

View file

@ -19,6 +19,8 @@ class ControlBottomAppBar extends ConsumerWidget {
final void Function() onCreateNewAlbum;
final void Function() onUpload;
final void Function() onStack;
final void Function() onEditTime;
final void Function() onEditLocation;
final List<Album> albums;
final List<Album> sharedAlbums;
@ -37,6 +39,8 @@ class ControlBottomAppBar extends ConsumerWidget {
required this.onCreateNewAlbum,
required this.onUpload,
required this.onStack,
required this.onEditTime,
required this.onEditLocation,
this.selectionAssetState = const SelectionAssetState(),
this.enabled = true,
}) : super(key: key);
@ -74,6 +78,18 @@ class ControlBottomAppBar extends ConsumerWidget {
label: "control_bottom_app_bar_favorite".tr(),
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(
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),

View file

@ -213,10 +213,10 @@ class HomePage extends HookConsumerWidget {
processing.value = true;
selectionEnabledHook.value = false;
try {
ref.read(manualUploadProvider.notifier).uploadAssets(
context,
selection.value.where((a) => a.storage == AssetState.local),
);
ref.read(manualUploadProvider.notifier).uploadAssets(
context,
selection.value.where((a) => a.storage == AssetState.local),
);
} finally {
processing.value = false;
}
@ -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 {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
@ -411,6 +439,8 @@ class HomePage extends HookConsumerWidget {
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
onStack: onStack,
onEditTime: onEditTime,
onEditLocation: onEditLocation,
),
],
),

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

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/plugin_api.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/utils/map_controller_hook.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
@ -12,13 +14,15 @@ class MapThumbnail extends HookConsumerWidget {
final double zoom;
final List<Marker> markers;
final double height;
final double width;
final bool showAttribution;
final bool isDarkTheme;
const MapThumbnail({
super.key,
required this.coords,
required this.height,
this.height = 100,
this.width = 100,
this.onTap,
this.zoom = 1,
this.showAttribution = true,
@ -28,18 +32,33 @@ class MapThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapController = useMapController();
final isMapReady = useRef(false);
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
useEffect(
() {
if (isMapReady.value && mapController.center != coords) {
mapController.move(coords, zoom);
}
return null;
},
[coords],
);
return SizedBox(
height: height,
width: width,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: FlutterMap(
mapController: mapController,
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: coords,
zoom: zoom,
onTap: onTap,
onMapReady: () => isMapReady.value = true,
),
nonRotatedChildren: [
if (showAttribution)

View 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';
}

View file

@ -55,6 +55,7 @@ class MapPageState extends ConsumerState<MapPage> {
// in onMapEvent() since MapEventMove#id is not populated properly in the
// current version of flutter_map(4.0.0) used
bool forceAssetUpdate = false;
bool isMapReady = false;
late final Debounce debounce;
@override
@ -79,7 +80,7 @@ class MapPageState extends ConsumerState<MapPage> {
bool forceReload = false,
}) {
try {
final bounds = mapController.bounds;
final bounds = isMapReady ? mapController.bounds : null;
if (bounds != null) {
final oldAssetsInBounds = assetsInBounds.toSet();
assetsInBounds =
@ -455,6 +456,7 @@ class MapPageState extends ConsumerState<MapPage> {
minZoom: 1,
maxZoom: maxZoom,
onMapReady: () {
isMapReady = true;
mapController.mapEventStream.listen(onMapEvent);
},
),

View file

@ -29,9 +29,8 @@ class CuratedPlacesRow extends CuratedRow {
onTap: () => context.autoPush(
const MapRoute(),
),
child: SizedBox(
height: imageSize,
width: imageSize,
child: SizedBox.square(
dimension: imageSize,
child: Stack(
children: [
Padding(
@ -43,6 +42,7 @@ class CuratedPlacesRow extends CuratedRow {
5,
),
height: imageSize,
width: imageSize,
showAttribution: false,
isDarkTheme: context.isDarkTheme,
),

View file

@ -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/create_album_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/memories/models/memory.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/tab_controller_page.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';
@ -172,6 +174,10 @@ part 'router.gr.dart';
transitionsBuilder: TransitionsBuilders.slideLeft,
durationInMilliseconds: 200,
),
CustomRoute<LatLng?>(
page: MapLocationPickerPage,
guards: [AuthGuard, DuplicateGuard],
),
],
)
class AppRouter extends _$AppRouter {

View file

@ -360,6 +360,19 @@ class _$AppRouter extends RootStackRouter {
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) {
return MaterialPageX<dynamic>(
routeData: routeData,
@ -704,6 +717,14 @@ class _$AppRouter extends RootStackRouter {
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
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View file

@ -256,6 +256,8 @@ class Asset {
isFavorite != a.isFavorite ||
isArchived != a.isArchived ||
isTrashed != a.isTrashed ||
a.exifInfo?.latitude != exifInfo?.latitude ||
a.exifInfo?.longitude != exifInfo?.longitude ||
// no local stack count or different count from remote
((stackCount == null && a.stackCount != null) ||
(stackCount != null &&

View file

@ -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/sync.service.dart';
import 'package:isar/isar.dart';
import 'package:latlong2/latlong.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@ -181,4 +182,27 @@ class AssetService {
Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool 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,
),
);
}
}

View 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;
}

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

View file

@ -15,7 +15,7 @@ class ScaffoldErrorBody extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"scaffold_body_error_occured",
"scaffold_body_error_occurred",
style: context.textTheme.displayMedium,
textAlign: TextAlign.center,
).tr(),

View file

@ -2,12 +2,17 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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/shared/models/asset.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/ui/date_time_picker.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:latlong2/latlong.dart';
void handleShareAssets(
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);
}

View 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);
});
});
}

140
web/package-lock.json generated
View file

@ -116,9 +116,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz",
"integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==",
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz",
"integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@ -452,9 +452,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
"integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz",
"integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@ -867,9 +867,9 @@
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.3.tgz",
"integrity": "sha512-59GsVNavGxAXCDDbakWSMJhajASb4kBCqDjqJsv+p5nKdbz7istmZ3HrX3L2LuiI80+zsOADCvooqQH3qGCucQ==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.4.tgz",
"integrity": "sha512-efdkfPhHYTtn0G6n2ddrESE91fgXxjlqLsnUtPWnJs4a4mZIbUaK7ffqKIIUKXSHwcDvaCVX6GXkaJJFqtX7jw==",
"dev": true,
"dependencies": {
"@babel/helper-environment-visitor": "^7.22.20",
@ -917,9 +917,9 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.3.tgz",
"integrity": "sha512-QPZxHrThbQia7UdvfpaRRlq/J9ciz1J4go0k+lPBXbgaNeY7IQrBj/9ceWjvMMI07/ZBzHl/F0R/2K0qH7jCVw==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz",
"integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5"
@ -948,9 +948,9 @@
}
},
"node_modules/@babel/plugin-transform-class-static-block": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.3.tgz",
"integrity": "sha512-PENDVxdr7ZxKPyi5Ffc0LjXdnJyrJxyqF5T5YjlVg4a0VFfQHW0r8iAtRiDXkfHlu1wwcvdtnndGYIeJLSuRMQ==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz",
"integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==",
"dev": true,
"dependencies": {
"@babel/helper-create-class-features-plugin": "^7.22.15",
@ -965,9 +965,9 @@
}
},
"node_modules/@babel/plugin-transform-classes": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.3.tgz",
"integrity": "sha512-FGEQmugvAEu2QtgtU0uTASXevfLMFfBeVCIIdcQhn/uBQsMTjBajdnAtanQlOcuihWh10PZ7+HWvc7NtBwP74w==",
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz",
"integrity": "sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==",
"dev": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.22.5",
@ -1050,9 +1050,9 @@
}
},
"node_modules/@babel/plugin-transform-dynamic-import": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.3.tgz",
"integrity": "sha512-vTG+cTGxPFou12Rj7ll+eD5yWeNl5/8xvQvF08y5Gv3v4mZQoyFf8/n9zg4q5vvCWt5jmgymfzMAldO7orBn7A==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz",
"integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1082,9 +1082,9 @@
}
},
"node_modules/@babel/plugin-transform-export-namespace-from": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.3.tgz",
"integrity": "sha512-yCLhW34wpJWRdTxxWtFZASJisihrfyMOTOQexhVzA78jlU+dH7Dw+zQgcPepQ5F3C6bAIiblZZ+qBggJdHiBAg==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz",
"integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1130,9 +1130,9 @@
}
},
"node_modules/@babel/plugin-transform-json-strings": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.3.tgz",
"integrity": "sha512-H9Ej2OiISIZowZHaBwF0tsJOih1PftXJtE8EWqlEIwpc7LMTGq0rPOrywKLQ4nefzx8/HMR0D3JGXoMHYvhi0A==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz",
"integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1161,9 +1161,9 @@
}
},
"node_modules/@babel/plugin-transform-logical-assignment-operators": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.3.tgz",
"integrity": "sha512-+pD5ZbxofyOygEp+zZAfujY2ShNCXRpDRIPOiBmTO693hhyOEteZgl876Xs9SAHPQpcV0vz8LvA/T+w8AzyX8A==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz",
"integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1290,9 +1290,9 @@
}
},
"node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.3.tgz",
"integrity": "sha512-xzg24Lnld4DYIdysyf07zJ1P+iIfJpxtVFOzX4g+bsJ3Ng5Le7rXx9KwqKzuyaUeRnt+I1EICwQITqc0E2PmpA==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz",
"integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1306,9 +1306,9 @@
}
},
"node_modules/@babel/plugin-transform-numeric-separator": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.3.tgz",
"integrity": "sha512-s9GO7fIBi/BLsZ0v3Rftr6Oe4t0ctJ8h4CCXfPoEJwmvAPMyNrfkOOJzm6b9PX9YXcCJWWQd/sBF/N26eBiMVw==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz",
"integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1322,9 +1322,9 @@
}
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.3.tgz",
"integrity": "sha512-VxHt0ANkDmu8TANdE9Kc0rndo/ccsmfe2Cx2y5sI4hu3AukHQ5wAu4cM7j3ba8B9548ijVyclBU+nuDQftZsog==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz",
"integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==",
"dev": true,
"dependencies": {
"@babel/compat-data": "^7.23.3",
@ -1357,9 +1357,9 @@
}
},
"node_modules/@babel/plugin-transform-optional-catch-binding": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.3.tgz",
"integrity": "sha512-LxYSb0iLjUamfm7f1D7GpiS4j0UAC8AOiehnsGAP8BEsIX8EOi3qV6bbctw8M7ZvLtcoZfZX5Z7rN9PlWk0m5A==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz",
"integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1373,9 +1373,9 @@
}
},
"node_modules/@babel/plugin-transform-optional-chaining": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.3.tgz",
"integrity": "sha512-zvL8vIfIUgMccIAK1lxjvNv572JHFJIKb4MWBz5OGdBQA0fB0Xluix5rmOby48exiJc987neOmP/m9Fnpkz3Tg==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz",
"integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.22.5",
@ -1421,9 +1421,9 @@
}
},
"node_modules/@babel/plugin-transform-private-property-in-object": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.3.tgz",
"integrity": "sha512-a5m2oLNFyje2e/rGKjVfAELTVI5mbA0FeZpBnkOWWV7eSmKQ+T/XW0Vf+29ScLzSxX+rnsarvU0oie/4m6hkxA==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz",
"integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==",
"dev": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.22.5",
@ -1642,15 +1642,15 @@
}
},
"node_modules/@babel/preset-env": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.3.tgz",
"integrity": "sha512-ovzGc2uuyNfNAs/jyjIGxS8arOHS5FENZaNn4rtE7UdKMMkqHCvboHfcuhWLZNX5cB44QfcGNWjaevxMzzMf+Q==",
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.5.tgz",
"integrity": "sha512-0d/uxVD6tFGWXGDSfyMD1p2otoaKmu6+GD+NfAx0tMaH+dxORnp7T9TaVQ6mKyya7iBtCIVxHjWT7MuzzM9z+A==",
"dev": true,
"dependencies": {
"@babel/compat-data": "^7.23.3",
"@babel/compat-data": "^7.23.5",
"@babel/helper-compilation-targets": "^7.22.15",
"@babel/helper-plugin-utils": "^7.22.5",
"@babel/helper-validator-option": "^7.22.15",
"@babel/helper-validator-option": "^7.23.5",
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3",
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3",
"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.3",
@ -1674,25 +1674,25 @@
"@babel/plugin-syntax-top-level-await": "^7.14.5",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
"@babel/plugin-transform-arrow-functions": "^7.23.3",
"@babel/plugin-transform-async-generator-functions": "^7.23.3",
"@babel/plugin-transform-async-generator-functions": "^7.23.4",
"@babel/plugin-transform-async-to-generator": "^7.23.3",
"@babel/plugin-transform-block-scoped-functions": "^7.23.3",
"@babel/plugin-transform-block-scoping": "^7.23.3",
"@babel/plugin-transform-block-scoping": "^7.23.4",
"@babel/plugin-transform-class-properties": "^7.23.3",
"@babel/plugin-transform-class-static-block": "^7.23.3",
"@babel/plugin-transform-classes": "^7.23.3",
"@babel/plugin-transform-class-static-block": "^7.23.4",
"@babel/plugin-transform-classes": "^7.23.5",
"@babel/plugin-transform-computed-properties": "^7.23.3",
"@babel/plugin-transform-destructuring": "^7.23.3",
"@babel/plugin-transform-dotall-regex": "^7.23.3",
"@babel/plugin-transform-duplicate-keys": "^7.23.3",
"@babel/plugin-transform-dynamic-import": "^7.23.3",
"@babel/plugin-transform-dynamic-import": "^7.23.4",
"@babel/plugin-transform-exponentiation-operator": "^7.23.3",
"@babel/plugin-transform-export-namespace-from": "^7.23.3",
"@babel/plugin-transform-export-namespace-from": "^7.23.4",
"@babel/plugin-transform-for-of": "^7.23.3",
"@babel/plugin-transform-function-name": "^7.23.3",
"@babel/plugin-transform-json-strings": "^7.23.3",
"@babel/plugin-transform-json-strings": "^7.23.4",
"@babel/plugin-transform-literals": "^7.23.3",
"@babel/plugin-transform-logical-assignment-operators": "^7.23.3",
"@babel/plugin-transform-logical-assignment-operators": "^7.23.4",
"@babel/plugin-transform-member-expression-literals": "^7.23.3",
"@babel/plugin-transform-modules-amd": "^7.23.3",
"@babel/plugin-transform-modules-commonjs": "^7.23.3",
@ -1700,15 +1700,15 @@
"@babel/plugin-transform-modules-umd": "^7.23.3",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5",
"@babel/plugin-transform-new-target": "^7.23.3",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.23.3",
"@babel/plugin-transform-numeric-separator": "^7.23.3",
"@babel/plugin-transform-object-rest-spread": "^7.23.3",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4",
"@babel/plugin-transform-numeric-separator": "^7.23.4",
"@babel/plugin-transform-object-rest-spread": "^7.23.4",
"@babel/plugin-transform-object-super": "^7.23.3",
"@babel/plugin-transform-optional-catch-binding": "^7.23.3",
"@babel/plugin-transform-optional-chaining": "^7.23.3",
"@babel/plugin-transform-optional-catch-binding": "^7.23.4",
"@babel/plugin-transform-optional-chaining": "^7.23.4",
"@babel/plugin-transform-parameters": "^7.23.3",
"@babel/plugin-transform-private-methods": "^7.23.3",
"@babel/plugin-transform-private-property-in-object": "^7.23.3",
"@babel/plugin-transform-private-property-in-object": "^7.23.4",
"@babel/plugin-transform-property-literals": "^7.23.3",
"@babel/plugin-transform-regenerator": "^7.23.3",
"@babel/plugin-transform-reserved-words": "^7.23.3",
@ -11155,9 +11155,9 @@
}
},
"node_modules/svelte": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.7.tgz",
"integrity": "sha512-UExR1KS7raTdycsUrKLtStayu4hpdV3VZQgM0akX8XbXgLBlosdE/Sf3crOgyh9xIjqSYB3UEBuUlIQKRQX2hg==",
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.8.tgz",
"integrity": "sha512-hU6dh1MPl8gh6klQZwK/n73GiAHiR95IkFsesLPbMeEZi36ydaXL/ZAb4g9sayT0MXzpxyZjR28yderJHxcmYA==",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@jridgewell/sourcemap-codec": "^1.4.15",

View file

@ -15,8 +15,12 @@
import { fly } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
let className = '';
export { className as class };
const dispatch = createEventDispatcher<{
select: T;
'click-outside': void;
}>();
export let options: T[];
@ -36,6 +40,8 @@
if (!controlable) {
showMenu = false;
}
dispatch('click-outside');
};
const handleSelectOption = (option: T) => {
@ -76,7 +82,7 @@
{#if showMenu}
<div
transition:fly={{ y: -30, x: 30, duration: 100 }}
class="text-md fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-scroll immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white"
class="text-md fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-scroll immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
>
{#each options as option (option)}
{@const renderedOption = renderOption(option)}

View file

@ -70,7 +70,7 @@
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 z-[2002] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
class="absolute top-0 z-[2002] h-full w-[360px] overflow-x-hidden p-2 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
>
<div class="flex place-items-center justify-between gap-2">
{#if !searchFaces}

View file

@ -267,7 +267,7 @@
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
>
<div class="flex place-items-center justify-between gap-2">
<div class="flex items-center gap-2">

View file

@ -59,12 +59,27 @@
dispatch('confirm', value);
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
event.stopPropagation();
}
};
let isDropdownOpen = false;
let isSearching = false;
const onSearchFocused = () => {
isSearching = true;
openDropdown();
};
const onSearchBlurred = () => {
isSearching = false;
closeDropdown();
};
const openDropdown = () => {
isDropdownOpen = true;
@ -84,42 +99,46 @@
<ConfirmDialogue
confirmColor="primary"
cancelColor="secondary"
title="Change Date"
title="Edit date & time"
prompt="Please select a new date:"
{disabled}
on:confirm={handleConfirm}
on:cancel={handleCancel}
>
<div class="flex flex-col text-md px-4 py-5 text-center gap-2" slot="prompt">
<div class="flex flex-col text-md px-4 text-center gap-2" slot="prompt">
<div class="mt-2" />
<div class="flex flex-col">
<label for="datetime">Date and Time</label>
<input
class="immich-form-label text-sm mt-2 w-full text-black"
class="text-sm my-4 w-full bg-gray-200 p-4 rounded-lg dark:text-white dark:bg-gray-600"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
</div>
<div class="flex flex-col w-full">
<div class="flex flex-col w-full mt-2">
<label for="timezone">Timezone</label>
<div class="relative">
<input
class="immich-form-label text-sm mt-2 w-full text-black"
class="text-sm my-4 w-full bg-gray-200 p-3 rounded-lg dark:text-white dark:bg-gray-600"
id="timezoneSearch"
type="text"
placeholder="Search timezone..."
bind:value={searchQuery}
on:input={updateSearchQuery}
on:focus={openDropdown}
on:focus={onSearchFocused}
on:blur={onSearchBlurred}
/>
<Dropdown
class="h-[400px]"
selectedOption={initialOption}
options={filteredTimezones}
render={(item) => (item ? `${item.zone} (${item.offset})` : '(not selected)')}
on:select={({ detail: item }) => handleSelectTz(item)}
controlable={true}
bind:showMenu={isDropdownOpen}
on:click-outside={isSearching ? null : closeDropdown}
/>
</div>
</div>

View file

@ -13,7 +13,7 @@
export let hideCancelButton = false;
export let disabled = false;
const dispatch = createEventDispatcher<{ cancel: void; confirm: void }>();
const dispatch = createEventDispatcher<{ cancel: void; confirm: void; 'click-outside': void }>();
let isConfirmButtonDisabled = false;
@ -28,9 +28,13 @@
isConfirmButtonDisabled = true;
dispatch('confirm');
};
const handleClickOutside = () => {
dispatch('click-outside');
};
</script>
<FullScreenModal on:clickOutside={handleCancel} on:escape={() => handleEscape()}>
<FullScreenModal on:clickOutside={handleClickOutside} on:escape={() => handleEscape()}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>