add m.location support

Needs cross-platform testing. Currently only tested on Android.
This commit is contained in:
Henry Hiles 2026-11-02 15:02:49 -05:00
commit 3a8d977f11
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
7 changed files with 810 additions and 12 deletions

View file

@ -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<void> 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<PollStart?>(null);
final location = useState<LocationMessageContent?>(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

View file

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

View file

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

View file

@ -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<String?>(null);
final descriptionController = useTextEditingController();
Future<void> 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)"),
),
],
);
}
}

View file

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

View file

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

View file

@ -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: () {