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:
Brandon Wees 2025-11-18 21:06:51 -06:00 committed by GitHub
parent 5f987a95f5
commit 2a281e7906
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 217 additions and 238 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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)),
),
], ],
), ),
); );

View File

@ -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,
);
}
}

View File

@ -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);

View File

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