mirror of
https://github.com/immich-app/immich.git
synced 2025-12-17 17:23:20 +03: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:
@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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';
|
||||
@@ -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/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/sheet_tile.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/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
@@ -181,7 +180,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (albums.isNotEmpty)
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: 'appears_in'.t(context: context).toUpperCase(),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
@@ -233,7 +232,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||
builder: (context, snapshot) {
|
||||
final displayName = snapshot.data ?? asset.name;
|
||||
return _SheetTile(
|
||||
return SheetTile(
|
||||
title: displayName,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
@@ -250,7 +249,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
);
|
||||
} else {
|
||||
// For remote assets, use the name directly
|
||||
return _SheetTile(
|
||||
return SheetTile(
|
||||
title: asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
@@ -269,7 +268,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
return SliverList.list(
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
@@ -279,7 +278,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const SheetPeopleDetails(),
|
||||
const SheetLocationDetails(),
|
||||
// Details header
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: 'exif_bottom_sheet_details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
@@ -290,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
buildFileInfoTile(),
|
||||
// Camera info
|
||||
if (cameraTitle != null)
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
@@ -301,7 +300,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
),
|
||||
// Lens info
|
||||
if (lensTitle != null)
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
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 {
|
||||
final ExifInfo exif;
|
||||
final bool isEditable;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.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/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_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/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
@@ -16,8 +19,6 @@ class SheetLocationDetails extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
BaseAsset? asset;
|
||||
ExifInfo? exifInfo;
|
||||
MapLibreMapController? _mapController;
|
||||
|
||||
String? _getLocationName(ExifInfo? exifInfo) {
|
||||
@@ -39,14 +40,11 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
}
|
||||
|
||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||
asset = ref.read(currentAssetNotifier);
|
||||
setState(() {
|
||||
exifInfo = current.valueOrNull;
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
if (exifInfo != null && hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exifInfo!.latitude!, exifInfo!.longitude!)));
|
||||
}
|
||||
});
|
||||
final currentExif = current.valueOrNull;
|
||||
|
||||
if (currentExif != null && currentExif.hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -55,45 +53,71 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
||||
}
|
||||
|
||||
void editLocation() async {
|
||||
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
|
||||
// Guard no lat/lng
|
||||
if (!hasCoordinates || (asset != null && asset is LocalAsset && asset!.hasRemote)) {
|
||||
// Guard local assets
|
||||
if (asset != null && asset is LocalAsset && asset.hasRemote) {
|
||||
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 coordinates = "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
|
||||
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: context.isMobile ? 16.0 : 56.0),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
"exif_bottom_sheet_location".t(context: context),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
SheetTile(
|
||||
title: 'exif_bottom_sheet_location'.t(context: context),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
|
||||
const SizedBox(height: 15),
|
||||
if (locationName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(locationName, style: context.textTheme.labelLarge),
|
||||
if (!hasCoordinates)
|
||||
SheetTile(
|
||||
title: "add_a_location".t(context: context),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user