From 3a8d977f11b3339e5a837752eba6168a362637d3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 2 Nov 2026 15:02:49 -0500 Subject: [PATCH] add `m.location` support Needs cross-platform testing. Currently only tested on Android. --- android/app/src/main/AndroidManifest.xml | 2 + ios/Runner/Info.plist | 2 + lib/controllers/room_chat.dart | 12 ++ lib/helpers/current_location_helper.dart | 82 +++++++++ .../extensions/build_event_options.dart | 4 +- .../extensions/show_adaptive_sheet.dart | 6 +- lib/models/content/message.dart | 9 +- lib/models/location.dart | 84 ++++++++++ lib/models/location_exception.dart | 11 ++ lib/widgets/composer/composer.dart | 72 +++++++- lib/widgets/location_map.dart | 83 +++++++++ lib/widgets/location_message.dart | 68 ++++++++ lib/widgets/location_picker_sheet.dart | 157 ++++++++++++++++++ lib/widgets/location_viewer_sheet.dart | 43 +++++ lib/widgets/renderers/message.dart | 16 +- lib/widgets/room_chat/room_chat.dart | 10 ++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Info.plist | 4 + macos/Runner/Release.entitlements | 2 + pubspec.lock | 144 ++++++++++++++++ pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 24 files changed, 810 insertions(+), 12 deletions(-) create mode 100644 lib/helpers/current_location_helper.dart create mode 100644 lib/models/location.dart create mode 100644 lib/models/location_exception.dart create mode 100644 lib/widgets/location_map.dart create mode 100644 lib/widgets/location_message.dart create mode 100644 lib/widgets/location_picker_sheet.dart create mode 100644 lib/widgets/location_viewer_sheet.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8eb6d3f..7378547 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4647d94..7deed9e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -76,5 +76,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSLocationWhenInUseUsageDescription + Nexus uses your location only when you choose to share it in a message. diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 913c52a..23cfb8f 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -20,6 +20,7 @@ import "package:nexus/models/direction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/room_chat.dart"; @@ -415,6 +416,17 @@ class RoomChatController(final ChatParams info) repliedEventRowId: repliedEventRowId, ); + Future sendLocation( + LocationMessageContent location, { + bool shouldMention = true, + int? repliedEventRowId, + }) => sendWithReply( + .message, + location, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ); + /// Completes once the event has come down sync. Future sendPollReference( Event poll, diff --git a/lib/helpers/current_location_helper.dart b/lib/helpers/current_location_helper.dart new file mode 100644 index 0000000..dbdb9e8 --- /dev/null +++ b/lib/helpers/current_location_helper.dart @@ -0,0 +1,82 @@ +import "dart:async"; +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:geolocator/geolocator.dart"; +import "package:latlong2/latlong.dart"; +import "package:nexus/controllers/portal.dart"; +import "package:nexus/models/location_exception.dart"; + +class CurrentLocationHelper(final Ref ref) { + static const _timeLimit = Duration(seconds: 10); + + /// The user's location and its accuracy in meters, or null if [askPermission] + /// is off and they haven't allowed it. + Future<(LatLng, double?)?> get({required bool askPermission}) async { + // geolocator talks to GeoClue directly, which Flatpak's sandbox blocks, so + // Linux uses the location portal, which also asks the user for permission + if (Platform.isLinux) { + // The portal can't say whether it's allowed without asking + if (!askPermission) return null; + + try { + final portal = await ref.read(PortalController.provider.future); + final location = await portal.location + .createSession(accuracy: .exact) + .first + .timeout(_timeLimit); + + return switch ((location.latitude, location.longitude)) { + (final latitude?, final longitude?) => ( + LatLng(latitude, longitude), + location.accuracy, + ), + _ => throw LocationException.failed, + }; + } on TimeoutException { + throw LocationException.timedOut; + } catch (_) { + throw LocationException.failed; + } + } + + if (!await Geolocator.isLocationServiceEnabled()) { + if (!askPermission) return null; + throw LocationException.servicesOff; + } + + var permission = await Geolocator.checkPermission(); + if (permission == .denied && askPermission) { + permission = await Geolocator.requestPermission(); + } + if (permission == .denied || permission == .deniedForever) { + if (!askPermission) return null; + throw LocationException.denied; + } + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: Platform.isAndroid + // Play services' location can hang without Google, like on microG, + // so this uses Android's own, as other apps do + ? AndroidSettings(forceLocationManager: true, timeLimit: _timeLimit) + : const LocationSettings(timeLimit: _timeLimit), + ); + } on TimeoutException { + // A fix can take long indoors, but another app may have a recent one + position = await Geolocator.getLastKnownPosition( + forceAndroidLocationManager: true, + ); + } catch (_) { + throw LocationException.failed; + } + if (position == null) throw LocationException.timedOut; + + return (LatLng(position.latitude, position.longitude), position.accuracy); + } + + static final provider = Provider( + CurrentLocationHelper.new, + ); +} diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index c243478..297dfa6 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -173,7 +173,9 @@ extension BuildEventOptions on Event { ), ), - if (content is MessageContent && sender == userId) + if (content is MessageContent && + content is! LocationMessageContent && + sender == userId) PopupMenuItem( onTap: () => onRelation(rowId, .edit), child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")), diff --git a/lib/helpers/extensions/show_adaptive_sheet.dart b/lib/helpers/extensions/show_adaptive_sheet.dart index 1e916a2..f8dea6e 100644 --- a/lib/helpers/extensions/show_adaptive_sheet.dart +++ b/lib/helpers/extensions/show_adaptive_sheet.dart @@ -4,7 +4,10 @@ import "package:nexus/helpers/extensions/is_desktop.dart"; extension ShowAdaptiveSheet on BuildContext { /// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet /// on mobile. Use the context passed to [builder] to close it. - Future showAdaptiveSheet(WidgetBuilder builder) => isDesktop + Future showAdaptiveSheet( + WidgetBuilder builder, { + bool enableDrag = true, + }) => isDesktop ? showDialog( context: this, builder: (context) => Dialog( @@ -18,6 +21,7 @@ extension ShowAdaptiveSheet on BuildContext { : showModalBottomSheet( context: this, isScrollControlled: true, + enableDrag: enableDrag, constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80), builder: (context) => SafeArea(child: builder(context)), ); diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 5212cd8..67e3c8d 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -4,6 +4,7 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/info/file.dart"; import "package:nexus/models/info/image.dart"; import "package:nexus/models/info/video.dart"; +import "package:nexus/models/location.dart"; part "message.freezed.dart"; part "message.g.dart"; @@ -90,8 +91,12 @@ sealed class MessageContent extends Content with _$MessageContent { }) = VideoMessageContent; @FreezedUnionValue("m.location") - factory MessageContent.location({required String body, required Uri geoUri}) = - LocationMessageContent; + factory MessageContent.location({ + required String body, + required Uri geoUri, + @JsonKey(name: "m.location", includeIfNull: false) LocationInfo? location, + @JsonKey(name: "m.asset", includeIfNull: false) LocationAsset? asset, + }) = LocationMessageContent; factory MessageContent.fromJson(Map json) => _$MessageContentFromJson(json); diff --git a/lib/models/location.dart b/lib/models/location.dart new file mode 100644 index 0000000..767e2dc --- /dev/null +++ b/lib/models/location.dart @@ -0,0 +1,84 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:latlong2/latlong.dart"; +import "package:nexus/models/content/message.dart"; + +part "location.freezed.dart"; +part "location.g.dart"; + +/// A location message's `m.location` block. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const LocationInfo({required final Uri uri, final String? description}) + with _$LocationInfo { + Map toJson() => _$LocationInfoToJson(this); + + factory LocationInfo.fromJson(Map json) => + _$LocationInfoFromJson(json); +} + +/// A location message's `m.asset` block. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const LocationAsset({ + @JsonKey(unknownEnumValue: LocationAssetType.pin) + final LocationAssetType type = .self, +}) with _$LocationAsset { + Map toJson() => _$LocationAssetToJson(this); + + factory LocationAsset.fromJson(Map json) => + _$LocationAssetFromJson(json); +} + +enum LocationAssetType { + @JsonValue("m.self") + self, + + @JsonValue("m.pin") + pin, +} + +extension LocationMessagePoint on LocationMessageContent { + LatLng? get point => location?.uri.latLng ?? geoUri.latLng; + + String? get description => switch (location?.description?.trim()) { + final description? when description.isNotEmpty => description, + _ => null, + }; + + LocationAssetType get assetType => asset?.type ?? .self; +} + +extension FormatLatLng on LatLng { + String get formatted => + "${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}"; +} + +extension GeoUri on Uri { + /// The coordinates of a geo: URI, like `geo:51.5,-0.12;u=35`. + LatLng? get latLng { + if (scheme != "geo") return null; + final coordinates = path.split(";").first.split(","); + if (coordinates.length < 2) return null; + + return switch (( + double.tryParse(coordinates[0]), + double.tryParse(coordinates[1]), + )) { + (final latitude?, final longitude?) + when latitude.abs() <= 90 && longitude.abs() <= 180 => + LatLng(latitude, longitude), + _ => null, + }; + } + + static Uri openStreetMap(LatLng point) => Uri.https( + "www.openstreetmap.org", + "/", + {"mlat": "${point.latitude}", "mlon": "${point.longitude}"}, + ).replace(fragment: "map=16/${point.latitude}/${point.longitude}"); + + static Uri fromLatLng(LatLng point, {double? uncertainty}) => Uri.parse( + "geo:${point.latitude},${point.longitude}" + "${uncertainty == null ? "" : ";u=${uncertainty.round()}"}", + ); +} diff --git a/lib/models/location_exception.dart b/lib/models/location_exception.dart new file mode 100644 index 0000000..107cc55 --- /dev/null +++ b/lib/models/location_exception.dart @@ -0,0 +1,11 @@ +enum LocationException(final String message) implements Exception { + servicesOff("Location services are turned off."), + denied("Nexus doesn't have permission to see your location."), + timedOut("Couldn't get your location in time."), + failed( + "Couldn't get your location. Check that location services are on, and that Nexus is allowed to use them.", + ); + + @override + String toString() => message; +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 7727ce4..af60155 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -25,6 +25,8 @@ import "package:nexus/models/image_pack.dart"; import "package:nexus/widgets/mxc_emoji.dart"; import "package:nexus/widgets/sticker_picker.dart"; import "package:nexus/widgets/create_poll_sheet.dart"; +import "package:nexus/widgets/location_picker_sheet.dart"; +import "package:nexus/models/location.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/paste_files_action.dart"; @@ -57,6 +59,11 @@ class const Composer( required bool shouldMention, }) onSendPoll, + required final Future Function( + LocationMessageContent location, { + required bool shouldMention, + }) + onSendLocation, final FocusNode? node, super.key, }) extends HookConsumerWidget { @@ -110,13 +117,16 @@ class const Composer( final sticker = useState<(String, PackImage)?>(null); final poll = useState(null); + final location = useState(null); useEffect(() { sticker.value = null; poll.value = null; + location.value = null; return null; }, [roomId]); - final hasReplacement = sticker.value != null || poll.value != null; + final hasReplacement = + sticker.value != null || poll.value != null || location.value != null; // The relation type stays as edit after cancelling an edit final isEditing = relationType == .edit && relatedEvent != null; @@ -126,6 +136,7 @@ class const Composer( textBefore.value ??= controller.text; sticker.value = null; poll.value = null; + location.value = null; final text = relatedEvent.localContent?.editSource ?? @@ -243,11 +254,14 @@ class const Composer( onSendSticker(shortcode, image, shouldMention: shouldMention.value); } else if (poll.value case final draft?) { onSendPoll(draft, shouldMention: shouldMention.value); + } else if (location.value case final draft?) { + onSendLocation(draft, shouldMention: shouldMention.value); } onDismiss(); // The draft is kept, so it can still be sent after the replacement sticker.value = null; poll.value = null; + location.value = null; node?.requestFocus(); return; } @@ -443,6 +457,22 @@ class const Composer( leading: Icon(Icons.poll), ), ), + PopupMenuItem( + enabled: !isEditing, + onTap: () => context.showAdaptiveSheet( + enableDrag: false, + (context) => LocationPickerSheet( + onPick: (picked) { + Navigator.of(context).pop(); + location.value = picked; + }, + ), + ), + child: ListTile( + title: Text("Location"), + leading: Icon(Icons.location_on), + ), + ), ], icon: Icon(Icons.add), ), @@ -451,8 +481,12 @@ class const Composer( child: AnimatedSize( duration: Durations.short4, alignment: .bottomCenter, - child: switch ((sticker.value, poll.value)) { - (_, final draft?) => ComposerReplacement( + child: switch (( + sticker.value, + poll.value, + location.value, + )) { + (_, final draft?, _) => ComposerReplacement( removeTooltip: "Remove poll", onRemove: () { poll.value = null; @@ -474,7 +508,7 @@ class const Composer( ), ), ), - ((final shortcode, final image)?, _) => + ((final shortcode, final image)?, _, _) => ComposerReplacement( removeTooltip: "Remove sticker", onRemove: () { @@ -493,7 +527,35 @@ class const Composer( ), ), ), - (null, null) => FlutterTagger( + (_, _, final draft?) => ComposerReplacement( + removeTooltip: "Remove location", + onRemove: () { + location.value = null; + node?.requestFocus(); + }, + onKeyEvent: sendOnEnter, + ListTile( + leading: Icon( + draft.assetType == .pin + ? Icons.location_pin + : Icons.my_location, + color: theme.colorScheme.primary, + ), + title: Text( + draft.description ?? + (draft.assetType == .pin + ? "Location" + : "My location"), + ), + subtitle: Text( + draft.point?.formatted ?? + draft.geoUri.toString(), + maxLines: 1, + overflow: .ellipsis, + ), + ), + ), + (null, null, null) => FlutterTagger( triggerStrategy: .eager, // Characters that continue a search, for // e.g. :+1, :custom_emoji or @user:server diff --git a/lib/widgets/location_map.dart b/lib/widgets/location_map.dart new file mode 100644 index 0000000..c9fb4b3 --- /dev/null +++ b/lib/widgets/location_map.dart @@ -0,0 +1,83 @@ +import "package:flutter_map/flutter_map.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:latlong2/latlong.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/launch_helper.dart"; + +class const LocationMap( + final LatLng center, { + final bool showMarker = true, + final bool interactive = false, + final double zoom = 15, + final MapController? controller, + final PositionCallback? onPositionChanged, + super.key, +}) extends ConsumerWidget { + /// The marker's icon, which points at the location from its bottom. + static Widget markerOf(BuildContext context) => Icon( + Icons.location_pin, + size: 40, + color: Theme.of(context).colorScheme.primary, + ); + + @override + Widget build(BuildContext context, WidgetRef ref) => FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: center, + initialZoom: zoom, + minZoom: 2, + maxZoom: 19, + onPositionChanged: onPositionChanged, + interactionOptions: InteractionOptions( + flags: interactive + ? InteractiveFlag.all & ~InteractiveFlag.rotate + : InteractiveFlag.none, + ), + ), + children: [ + TileLayer( + urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + // Required by OpenStreetMap's tile usage policy + userAgentPackageName: "nexus.federated.nexus", + ), + if (showMarker) + MarkerLayer( + markers: [ + Marker( + point: center, + width: 40, + height: 40, + alignment: .topCenter, + child: markerOf(context), + ), + ], + ), + // Required by OpenStreetMap + Align( + alignment: .bottomRight, + child: Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + child: InkWell( + onTap: interactive + ? () => ref + .read(LaunchHelper.provider) + .launchUrl( + Uri.https("www.openstreetmap.org", "/copyright"), + ) + : null, + child: Padding( + padding: const .symmetric(horizontal: 4, vertical: 2), + child: Text( + "© OpenStreetMap", + style: Theme.of(context).textTheme.labelSmall, + maxLines: 1, + overflow: .ellipsis, + ), + ), + ), + ), + ), + ], + ); +} diff --git a/lib/widgets/location_message.dart b/lib/widgets/location_message.dart new file mode 100644 index 0000000..be39cb1 --- /dev/null +++ b/lib/widgets/location_message.dart @@ -0,0 +1,68 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/widgets/location_map.dart"; +import "package:nexus/widgets/location_viewer_sheet.dart"; + +class const LocationMessage(final LocationMessageContent content, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final point = content.point; + final description = content.description; + + if (point == null) { + return Wrap( + spacing: 8, + children: [ + Text( + "Invalid location:", + style: TextStyle(color: theme.colorScheme.error), + ), + Text(content.body), + ], + ); + } + + return Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + if (description != null) + Text(description, style: theme.textTheme.titleSmall), + ConstrainedBox( + constraints: const .new(maxWidth: 400), + child: AspectRatio( + aspectRatio: 3 / 2, + child: ClipRRect( + borderRadius: .circular(12), + child: Stack( + children: [ + LocationMap(point), + Positioned.fill( + child: Material( + type: .transparency, + child: InkWell( + onTap: () => context.showAdaptiveSheet( + enableDrag: false, + (_) => LocationViewerSheet( + point, + type: content.assetType, + description: description, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + Text(point.formatted, style: theme.textTheme.labelSmall), + ], + ); + } +} diff --git a/lib/widgets/location_picker_sheet.dart b/lib/widgets/location_picker_sheet.dart new file mode 100644 index 0000000..ace1d84 --- /dev/null +++ b/lib/widgets/location_picker_sheet.dart @@ -0,0 +1,157 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_map/flutter_map.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:latlong2/latlong.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/current_location_helper.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/widgets/location_map.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses a location to share, either the user's own or a pin dropped on the +/// map. +class const LocationPickerSheet({ + required final void Function(LocationMessageContent location) onPick, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final mapController = useMemoized(MapController.new); + useEffect(() => mapController.dispose, [mapController]); + + // Not state, as it changes every frame while the map moves + final center = useRef(const LatLng(0, 0)); + + // Until the map is moved or the user is located, it's an arbitrary spot + final placed = useState(false); + // Whether the map is on the user's location, and its accuracy in meters + final own = useState<({double? accuracy})?>(null); + final locating = useState(false); + final error = useState(null); + final descriptionController = useTextEditingController(); + + Future locate({bool askPermission = true}) async { + locating.value = true; + error.value = null; + try { + final found = await ref + .read(CurrentLocationHelper.provider) + .get(askPermission: askPermission); + // Doesn't move away from a spot the user chose while it was finding them + if (found == null || + !context.mounted || + (!askPermission && placed.value)) { + return; + } + + final (point, accuracy) = found; + center.value = point; + mapController.move(point, 16); + own.value = (accuracy: accuracy); + placed.value = true; + } catch (caught) { + if (context.mounted && askPermission) error.value = caught.toString(); + } finally { + if (context.mounted) locating.value = false; + } + } + + // Starts at the user's location if they've already allowed it, except on + // Linux, where checking would ask + useEffect(() { + locate(askPermission: false); + return null; + }, []); + + void share() { + final description = descriptionController.text.trim(); + final self = own.value != null; + final geoUri = GeoUri.fromLatLng( + center.value, + uncertainty: own.value?.accuracy, + ); + + onPick( + .new( + body: + "${description.isEmpty ? (self ? "My location" : "Location") : description}: $geoUri", + geoUri: geoUri, + location: .new( + uri: geoUri, + description: description.isEmpty ? null : description, + ), + asset: .new(type: self ? .self : .pin), + ), + ); + } + + return SheetLayout( + title: const Text("Share Location"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: placed.value ? share : null, + child: const Text("Share"), + ), + ], + children: [ + SizedBox( + height: 300, + child: ClipRRect( + borderRadius: .circular(12), + child: Stack( + children: [ + LocationMap( + center.value, + zoom: 2, + interactive: true, + showMarker: false, + controller: mapController, + onPositionChanged: (camera, hasGesture) { + center.value = camera.center; + if (hasGesture) { + own.value = null; + placed.value = true; + } + }, + ), + IgnorePointer( + child: Center( + child: Transform.translate( + offset: const .new(0, -20), + child: LocationMap.markerOf(context), + ), + ), + ), + ], + ), + ), + ), + OutlinedButton.icon( + onPressed: locating.value ? null : locate, + icon: locating.value + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location), + label: const Text("Use current location"), + ), + if (error.value case final error?) + Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + TextField( + controller: descriptionController, + textCapitalization: .sentences, + decoration: const .new(labelText: "Description (optional)"), + ), + ], + ); + } +} diff --git a/lib/widgets/location_viewer_sheet.dart b/lib/widgets/location_viewer_sheet.dart new file mode 100644 index 0000000..bfd37cf --- /dev/null +++ b/lib/widgets/location_viewer_sheet.dart @@ -0,0 +1,43 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:latlong2/latlong.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/widgets/location_map.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +class const LocationViewerSheet( + final LatLng point, { + final LocationAssetType type = .self, + final String? description, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SheetLayout( + title: Text( + description ?? (type == .self ? "Shared Location" : "Location"), + ), + actions: [ + TextButton( + onPressed: () => ref + .read(LaunchHelper.provider) + .launchUrl(GeoUri.openStreetMap(point)), + child: const Text("Open in OpenStreetMap"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Close"), + ), + ], + children: [ + SizedBox( + height: 400, + child: ClipRRect( + borderRadius: .circular(12), + child: LocationMap(point, interactive: true), + ), + ), + SelectableText(point.formatted), + ], + ); +} diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index d54756c..c5f6a28 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -8,6 +8,7 @@ import "package:nexus/helpers/extensions/get_filename.dart"; import "package:nexus/helpers/extensions/linkify_html.dart"; import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/message.dart"; +import "package:nexus/models/location.dart"; import "package:nexus/models/content/poll_start.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/event.dart"; @@ -16,6 +17,7 @@ import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/linkified_text.dart"; +import "package:nexus/widgets/location_message.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/message_image.dart"; import "package:nexus/widgets/reaction_row.dart"; @@ -64,9 +66,18 @@ class const MessageRenderer( overflow: .ellipsis, ) : PollRenderer(event, poll), - // TODO: Handle locations - // LocationMessageContent(:final body , :final geoUri) => MessageContent content => switch (content) { + final LocationMessageContent location => + textOnly + ? Text( + switch (location.description) { + final description? => "Location: $description", + null => "Location", + }, + maxLines: maxLines, + overflow: .ellipsis, + ) + : LocationMessage(location), TextMessageContent(:final body, :final formattedBody, :final format) || NoticeMessageContent( :final body, @@ -167,7 +178,6 @@ class const MessageRenderer( ], ], ), - LocationMessageContent(:final String? body) || UnknownMessageContent(:final body) => body == null ? Text("This message is redacted", style: errorStyle) diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index de12d2a..a08f7fc 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -350,6 +350,16 @@ final class const RoomChat({ repliedEventRowId, ) .onError(showError), + onSendLocation: + (location, {required shouldMention}) => + notifier + .sendLocation( + location, + shouldMention: shouldMention, + repliedEventRowId: + repliedEventRowId, + ) + .onError(showError), relationType: relationType.value, relatedEvent: relatedEvent.value, onDismiss: () { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f10315f..88181b0 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import app_links import dynamic_color import file_selector_macos import flutter_local_notifications +import geolocator_apple import media_kit_libs_macos_video import media_kit_video import package_info_plus @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 4e593b0..575757f 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -12,5 +12,7 @@ com.apple.security.files.user-selected.read-only + com.apple.security.personal-information.location + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index efc4e8d..d4f66d1 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -42,5 +42,9 @@ + NSLocationUsageDescription + Nexus uses your location only when you choose to share it in a message. + NSLocationWhenInUseUsageDescription + Nexus uses your location only when you choose to share it in a message. diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 741903e..14e2faf 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -8,5 +8,7 @@ com.apple.security.files.user-selected.read-only + com.apple.security.personal-information.location + diff --git a/pubspec.lock b/pubspec.lock index cb20bb6..63f90ea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -281,6 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dart_style: dependency: transitive description: @@ -532,6 +548,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "9bbe5472f66ae648d2ca2efbce97fcaa04e71261fabd3e062f26728222d7f767" + url: "https://pub.dev" + source: hosted + version: "8.3.2" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -599,6 +623,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "2b3bc0f7320d5a5fa02f8209ffcf1aaa7c2cabb7b2bedbc0cfeb5ff2778b8b09" + url: "https://pub.dev" + source: hosted + version: "14.1.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: e67583809a1fab5f4a2255902978e2bc095ace9451fde0d9624e54facd0a5c22 + url: "https://pub.dev" + source: hosted + version: "5.2.1" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: a1e177f3081089724f2095856aeb96eb8d7d1c59e425aed8dd4de7aa2c11ab66 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: e012af6b7e2674e7500237ab11e44ac9119e87fd0105e7bb55cde1750e8e5d80 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: @@ -615,6 +703,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: "direct main" description: @@ -775,6 +871,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.14.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc" + url: "https://pub.dev" + source: hosted + version: "0.10.1" leak_tracker: dependency: transitive description: @@ -969,6 +1073,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" mime: dependency: "direct main" description: @@ -1187,6 +1299,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" pub_semver: dependency: transitive description: @@ -1323,6 +1443,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -1448,6 +1576,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" unifiedpush: dependency: "direct main" description: @@ -1708,6 +1844,14 @@ packages: url: "https://github.com/leanflutter/window_manager" source: git version: "0.6.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_desktop_portal: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ca495db..b26cf4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,6 +122,9 @@ dependencies: flutter_file_dialog: 3.3.3 mime: 2.1.0 avif_native: 0.1.1 + flutter_map: 8.3.2 + latlong2: 0.10.1 + geolocator: 14.1.2 dev_dependencies: build_runner: 2.16.2 flutter_lints: 6.0.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7dc1c3c..8df32b4 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 68413e7..e8b1182 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links dynamic_color file_selector_windows + geolocator_windows media_kit_libs_windows_video media_kit_video pasteboard