mirror of
https://github.com/immich-app/immich.git
synced 2025-11-25 15:55:17 -05:00
feat(mobile): location edit from asset viewer (#23925)
* chore: break sheet tile into own file * feat: set location from bottom sheet * refactor: location picker There was a lot of confusing controls here, simplified to 1 mode * fix: local asset check * chore: refactoring of location details widget * fix: update currentAssetExifProvider when changing location * chore: use SheetTile for location header * chore: remove coordinate change check * chore: remove comment
This commit is contained in:
parent
5f987a95f5
commit
2a281e7906
@ -1450,6 +1450,7 @@
|
|||||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||||
"no_local_assets_found": "No local assets found with this checksum",
|
"no_local_assets_found": "No local assets found with this checksum",
|
||||||
|
"no_location_set": "No location set",
|
||||||
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
|
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
|
||||||
"no_name": "No Name",
|
"no_name": "No Name",
|
||||||
"no_notifications": "No notifications",
|
"no_notifications": "No notifications",
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
@ -16,8 +15,8 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
@ -181,7 +180,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
spacing: 12,
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
if (albums.isNotEmpty)
|
if (albums.isNotEmpty)
|
||||||
_SheetTile(
|
SheetTile(
|
||||||
title: 'appears_in'.t(context: context).toUpperCase(),
|
title: 'appears_in'.t(context: context).toUpperCase(),
|
||||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||||
@ -233,7 +232,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final displayName = snapshot.data ?? asset.name;
|
final displayName = snapshot.data ?? asset.name;
|
||||||
return _SheetTile(
|
return SheetTile(
|
||||||
title: displayName,
|
title: displayName,
|
||||||
titleStyle: context.textTheme.labelLarge,
|
titleStyle: context.textTheme.labelLarge,
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
@ -250,7 +249,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// For remote assets, use the name directly
|
// For remote assets, use the name directly
|
||||||
return _SheetTile(
|
return SheetTile(
|
||||||
title: asset.name,
|
title: asset.name,
|
||||||
titleStyle: context.textTheme.labelLarge,
|
titleStyle: context.textTheme.labelLarge,
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
@ -269,7 +268,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
return SliverList.list(
|
return SliverList.list(
|
||||||
children: [
|
children: [
|
||||||
// Asset Date and Time
|
// Asset Date and Time
|
||||||
_SheetTile(
|
SheetTile(
|
||||||
title: _getDateTime(context, asset),
|
title: _getDateTime(context, asset),
|
||||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||||
@ -279,7 +278,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
const SheetPeopleDetails(),
|
const SheetPeopleDetails(),
|
||||||
const SheetLocationDetails(),
|
const SheetLocationDetails(),
|
||||||
// Details header
|
// Details header
|
||||||
_SheetTile(
|
SheetTile(
|
||||||
title: 'exif_bottom_sheet_details'.t(context: context),
|
title: 'exif_bottom_sheet_details'.t(context: context),
|
||||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||||
@ -290,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
buildFileInfoTile(),
|
buildFileInfoTile(),
|
||||||
// Camera info
|
// Camera info
|
||||||
if (cameraTitle != null)
|
if (cameraTitle != null)
|
||||||
_SheetTile(
|
SheetTile(
|
||||||
title: cameraTitle,
|
title: cameraTitle,
|
||||||
titleStyle: context.textTheme.labelLarge,
|
titleStyle: context.textTheme.labelLarge,
|
||||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||||
@ -301,7 +300,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
// Lens info
|
// Lens info
|
||||||
if (lensTitle != null)
|
if (lensTitle != null)
|
||||||
_SheetTile(
|
SheetTile(
|
||||||
title: lensTitle,
|
title: lensTitle,
|
||||||
titleStyle: context.textTheme.labelLarge,
|
titleStyle: context.textTheme.labelLarge,
|
||||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||||
@ -319,77 +318,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SheetTile extends ConsumerWidget {
|
|
||||||
final String title;
|
|
||||||
final Widget? leading;
|
|
||||||
final Widget? trailing;
|
|
||||||
final String? subtitle;
|
|
||||||
final TextStyle? titleStyle;
|
|
||||||
final TextStyle? subtitleStyle;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const _SheetTile({
|
|
||||||
required this.title,
|
|
||||||
this.titleStyle,
|
|
||||||
this.leading,
|
|
||||||
this.subtitle,
|
|
||||||
this.subtitleStyle,
|
|
||||||
this.trailing,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
void copyTitle(BuildContext context, WidgetRef ref) {
|
|
||||||
Clipboard.setData(ClipboardData(text: title));
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: 'copied_to_clipboard'.t(context: context),
|
|
||||||
toastType: ToastType.info,
|
|
||||||
);
|
|
||||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final Widget titleWidget;
|
|
||||||
if (leading == null) {
|
|
||||||
titleWidget = LimitedBox(
|
|
||||||
maxWidth: double.infinity,
|
|
||||||
child: Text(title, style: titleStyle),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
titleWidget = Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.only(left: 15),
|
|
||||||
child: Text(title, style: titleStyle),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Widget? subtitleWidget;
|
|
||||||
if (leading == null && subtitle != null) {
|
|
||||||
subtitleWidget = Text(subtitle!, style: subtitleStyle);
|
|
||||||
} else if (leading != null && subtitle != null) {
|
|
||||||
subtitleWidget = Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 15),
|
|
||||||
child: Text(subtitle!, style: subtitleStyle),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
subtitleWidget = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
|
||||||
titleAlignment: ListTileTitleAlignment.center,
|
|
||||||
leading: leading,
|
|
||||||
trailing: trailing,
|
|
||||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
|
||||||
subtitle: subtitleWidget,
|
|
||||||
onTap: onTap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||||
final ExifInfo exif;
|
final ExifInfo exif;
|
||||||
final bool isEditable;
|
final bool isEditable;
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
@ -16,8 +19,6 @@ class SheetLocationDetails extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||||
BaseAsset? asset;
|
|
||||||
ExifInfo? exifInfo;
|
|
||||||
MapLibreMapController? _mapController;
|
MapLibreMapController? _mapController;
|
||||||
|
|
||||||
String? _getLocationName(ExifInfo? exifInfo) {
|
String? _getLocationName(ExifInfo? exifInfo) {
|
||||||
@ -39,14 +40,11 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||||
asset = ref.read(currentAssetNotifier);
|
final currentExif = current.valueOrNull;
|
||||||
setState(() {
|
|
||||||
exifInfo = current.valueOrNull;
|
if (currentExif != null && currentExif.hasCoordinates) {
|
||||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||||
if (exifInfo != null && hasCoordinates) {
|
}
|
||||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exifInfo!.latitude!, exifInfo!.longitude!)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -55,45 +53,71 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
|||||||
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void editLocation() async {
|
||||||
|
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||||
|
|
||||||
// Guard no lat/lng
|
// Guard local assets
|
||||||
if (!hasCoordinates || (asset != null && asset is LocalAsset && asset!.hasRemote)) {
|
if (asset != null && asset is LocalAsset && asset.hasRemote) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final remoteId = asset is LocalAsset ? (asset as LocalAsset).remoteId : (asset as RemoteAsset).id;
|
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
|
||||||
final locationName = _getLocationName(exifInfo);
|
final locationName = _getLocationName(exifInfo);
|
||||||
final coordinates = "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
|
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: context.isMobile ? 16.0 : 56.0),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
SheetTile(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
title: 'exif_bottom_sheet_location'.t(context: context),
|
||||||
child: Text(
|
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||||
"exif_bottom_sheet_location".t(context: context),
|
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||||
style: context.textTheme.labelMedium?.copyWith(
|
fontWeight: FontWeight.w600,
|
||||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
),
|
||||||
fontWeight: FontWeight.w600,
|
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
|
||||||
|
onTap: editLocation,
|
||||||
|
),
|
||||||
|
if (hasCoordinates)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: context.isMobile ? 16.0 : 56.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (locationName != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
|
child: Text(locationName, style: context.textTheme.labelLarge),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
coordinates,
|
||||||
|
style: context.textTheme.labelMedium?.copyWith(
|
||||||
|
color: context.textTheme.labelMedium?.color?.withAlpha(150),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (!hasCoordinates)
|
||||||
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
|
SheetTile(
|
||||||
const SizedBox(height: 15),
|
title: "add_a_location".t(context: context),
|
||||||
if (locationName != null)
|
titleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||||
Padding(
|
fontWeight: FontWeight.w600,
|
||||||
padding: const EdgeInsets.only(bottom: 4.0),
|
color: context.primaryColor,
|
||||||
child: Text(locationName, style: context.textTheme.labelLarge),
|
),
|
||||||
|
leading: const Icon(Icons.location_off),
|
||||||
|
onTap: editLocation,
|
||||||
),
|
),
|
||||||
Text(
|
|
||||||
coordinates,
|
|
||||||
style: context.textTheme.labelMedium?.copyWith(color: context.textTheme.labelMedium?.color?.withAlpha(150)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
|
class SheetTile extends ConsumerWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget? leading;
|
||||||
|
final Widget? trailing;
|
||||||
|
final String? subtitle;
|
||||||
|
final TextStyle? titleStyle;
|
||||||
|
final TextStyle? subtitleStyle;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const SheetTile({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.titleStyle,
|
||||||
|
this.leading,
|
||||||
|
this.subtitle,
|
||||||
|
this.subtitleStyle,
|
||||||
|
this.trailing,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
void copyTitle(BuildContext context, WidgetRef ref) {
|
||||||
|
Clipboard.setData(ClipboardData(text: title));
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: 'copied_to_clipboard'.t(context: context),
|
||||||
|
toastType: ToastType.info,
|
||||||
|
);
|
||||||
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final Widget titleWidget;
|
||||||
|
if (leading == null) {
|
||||||
|
titleWidget = LimitedBox(
|
||||||
|
maxWidth: double.infinity,
|
||||||
|
child: Text(title, style: titleStyle),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
titleWidget = Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.only(left: 15),
|
||||||
|
child: Text(title, style: titleStyle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Widget? subtitleWidget;
|
||||||
|
if (leading == null && subtitle != null) {
|
||||||
|
subtitleWidget = Text(subtitle!, style: subtitleStyle);
|
||||||
|
} else if (leading != null && subtitle != null) {
|
||||||
|
subtitleWidget = Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 15),
|
||||||
|
child: Text(subtitle!, style: subtitleStyle),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
subtitleWidget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||||
|
titleAlignment: ListTileTitleAlignment.center,
|
||||||
|
leading: leading,
|
||||||
|
trailing: trailing,
|
||||||
|
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||||
|
subtitle: subtitleWidget,
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -301,6 +301,13 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This must be called since editing location
|
||||||
|
// does not update the currentAsset which means
|
||||||
|
// the exif provider will not be refreshed automatically
|
||||||
|
if (source == ActionSource.viewer) {
|
||||||
|
ref.invalidate(currentAssetExifProvider);
|
||||||
|
}
|
||||||
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
return ActionResult(count: ids.length, success: true);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Failed to edit location for assets', error, stack);
|
_logger.severe('Failed to edit location for assets', error, stack);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
@ -17,19 +16,36 @@ Future<LatLng?> showLocationPicker({required BuildContext context, LatLng? initi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _LocationPickerMode { map, manual }
|
|
||||||
|
|
||||||
class _LocationPicker extends HookWidget {
|
class _LocationPicker extends HookWidget {
|
||||||
final LatLng? initialLatLng;
|
final LatLng? initialLatLng;
|
||||||
|
|
||||||
const _LocationPicker({this.initialLatLng});
|
const _LocationPicker({this.initialLatLng});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final latitude = useState(initialLatLng?.latitude ?? 0.0);
|
final latitude = useState(initialLatLng?.latitude ?? 0.0);
|
||||||
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
||||||
final latlng = LatLng(latitude.value, longitude.value);
|
final latlng = LatLng(latitude.value, longitude.value);
|
||||||
final pickerMode = useState(_LocationPickerMode.map);
|
final latitiudeFocusNode = useFocusNode();
|
||||||
|
final longitudeFocusNode = useFocusNode();
|
||||||
|
final latitudeController = useTextEditingController(text: latitude.value.toStringAsFixed(4));
|
||||||
|
final longitudeController = useTextEditingController(text: longitude.value.toStringAsFixed(4));
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
latitudeController.text = latitude.value.toStringAsFixed(4);
|
||||||
|
longitudeController.text = longitude.value.toStringAsFixed(4);
|
||||||
|
return null;
|
||||||
|
}, [latitude.value, longitude.value]);
|
||||||
|
|
||||||
Future<void> onMapTap() async {
|
Future<void> onMapTap() async {
|
||||||
final newLatLng = await context.pushRoute<LatLng?>(MapLocationPickerRoute(initialLatLng: latlng));
|
final newLatLng = await context.pushRoute<LatLng?>(MapLocationPickerRoute(initialLatLng: latlng));
|
||||||
@ -39,23 +55,55 @@ class _LocationPicker extends HookWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onLatitudeUpdated(double value) {
|
||||||
|
latitude.value = value;
|
||||||
|
longitudeFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onLongitudeEditingCompleted(double value) {
|
||||||
|
longitude.value = value;
|
||||||
|
longitudeFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
contentPadding: const EdgeInsets.all(30),
|
contentPadding: const EdgeInsets.all(30),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: pickerMode.value == _LocationPickerMode.map
|
child: Column(
|
||||||
? _MapPicker(
|
mainAxisSize: MainAxisSize.min,
|
||||||
key: ValueKey(latlng),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
latlng: latlng,
|
children: [
|
||||||
onModeSwitch: () => pickerMode.value = _LocationPickerMode.manual,
|
Text("edit_location_dialog_title", style: context.textTheme.titleMedium).tr(),
|
||||||
onMapTap: onMapTap,
|
Align(
|
||||||
)
|
alignment: Alignment.center,
|
||||||
: _ManualPicker(
|
child: TextButton.icon(
|
||||||
latlng: latlng,
|
icon: const Text("location_picker_choose_on_map").tr(),
|
||||||
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
|
label: const Icon(Icons.map_outlined, size: 16),
|
||||||
onLatUpdated: (value) => latitude.value = value,
|
onPressed: onMapTap,
|
||||||
onLonUpdated: (value) => longitude.value = value,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ManualPickerInput(
|
||||||
|
controller: latitudeController,
|
||||||
|
decorationText: "latitude",
|
||||||
|
hintText: "location_picker_latitude_hint",
|
||||||
|
errorText: "location_picker_latitude_error",
|
||||||
|
focusNode: latitiudeFocusNode,
|
||||||
|
validator: _validateLat,
|
||||||
|
onUpdated: onLatitudeUpdated,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_ManualPickerInput(
|
||||||
|
controller: longitudeController,
|
||||||
|
decorationText: "longitude",
|
||||||
|
hintText: "location_picker_longitude_hint",
|
||||||
|
errorText: "location_picker_longitude_error",
|
||||||
|
focusNode: longitudeFocusNode,
|
||||||
|
validator: _validateLong,
|
||||||
|
onUpdated: onLongitudeEditingCompleted,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@ -81,7 +129,7 @@ class _LocationPicker extends HookWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ManualPickerInput extends HookWidget {
|
class _ManualPickerInput extends HookWidget {
|
||||||
final String initialValue;
|
final TextEditingController controller;
|
||||||
final String decorationText;
|
final String decorationText;
|
||||||
final String hintText;
|
final String hintText;
|
||||||
final String errorText;
|
final String errorText;
|
||||||
@ -90,7 +138,7 @@ class _ManualPickerInput extends HookWidget {
|
|||||||
final Function(double value) onUpdated;
|
final Function(double value) onUpdated;
|
||||||
|
|
||||||
const _ManualPickerInput({
|
const _ManualPickerInput({
|
||||||
required this.initialValue,
|
required this.controller,
|
||||||
required this.decorationText,
|
required this.decorationText,
|
||||||
required this.hintText,
|
required this.hintText,
|
||||||
required this.errorText,
|
required this.errorText,
|
||||||
@ -101,7 +149,6 @@ class _ManualPickerInput extends HookWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isValid = useState(true);
|
final isValid = useState(true);
|
||||||
final controller = useTextEditingController(text: initialValue);
|
|
||||||
|
|
||||||
void onEditingComplete() {
|
void onEditingComplete() {
|
||||||
isValid.value = validator(controller.text);
|
isValid.value = validator(controller.text);
|
||||||
@ -131,109 +178,3 @@ class _ManualPickerInput extends HookWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ManualPicker extends HookWidget {
|
|
||||||
final LatLng latlng;
|
|
||||||
final Function() onModeSwitch;
|
|
||||||
final Function(double) onLatUpdated;
|
|
||||||
final Function(double) onLonUpdated;
|
|
||||||
|
|
||||||
const _ManualPicker({
|
|
||||||
required this.latlng,
|
|
||||||
required this.onModeSwitch,
|
|
||||||
required this.onLatUpdated,
|
|
||||||
required this.onLonUpdated,
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final latitiudeFocusNode = useFocusNode();
|
|
||||||
final longitudeFocusNode = useFocusNode();
|
|
||||||
|
|
||||||
void onLatitudeUpdated(double value) {
|
|
||||||
onLatUpdated(value);
|
|
||||||
longitudeFocusNode.requestFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
void onLongitudeEditingCompleted(double value) {
|
|
||||||
onLonUpdated(value);
|
|
||||||
longitudeFocusNode.unfocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextButton.icon(
|
|
||||||
icon: const Text("location_picker_choose_on_map").tr(),
|
|
||||||
label: const Icon(Icons.map_outlined, size: 16),
|
|
||||||
onPressed: onModeSwitch,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_ManualPickerInput(
|
|
||||||
initialValue: latlng.latitude.toStringAsFixed(4),
|
|
||||||
decorationText: "latitude",
|
|
||||||
hintText: "location_picker_latitude_hint",
|
|
||||||
errorText: "location_picker_latitude_error",
|
|
||||||
focusNode: latitiudeFocusNode,
|
|
||||||
validator: _validateLat,
|
|
||||||
onUpdated: onLatitudeUpdated,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_ManualPickerInput(
|
|
||||||
initialValue: latlng.longitude.toStringAsFixed(4),
|
|
||||||
decorationText: "longitude",
|
|
||||||
hintText: "location_picker_longitude_hint",
|
|
||||||
errorText: "location_picker_longitude_error",
|
|
||||||
focusNode: longitudeFocusNode,
|
|
||||||
validator: _validateLong,
|
|
||||||
onUpdated: onLongitudeEditingCompleted,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MapPicker extends StatelessWidget {
|
|
||||||
final LatLng latlng;
|
|
||||||
final Function() onModeSwitch;
|
|
||||||
final Function() onMapTap;
|
|
||||||
|
|
||||||
const _MapPicker({required this.latlng, required this.onModeSwitch, required this.onMapTap, super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextButton.icon(
|
|
||||||
icon: Text("${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}"),
|
|
||||||
label: const Icon(Icons.edit_outlined, size: 16),
|
|
||||||
onPressed: onModeSwitch,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
MapThumbnail(
|
|
||||||
centre: latlng,
|
|
||||||
height: 200,
|
|
||||||
width: 200,
|
|
||||||
zoom: 8,
|
|
||||||
showMarkerPin: true,
|
|
||||||
onTap: (_, __) => onMapTap(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user