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
24 changed files with 810 additions and 12 deletions

View file

@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />

View file

@ -76,5 +76,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Nexus uses your location only when you choose to share it in a message.</string>
</dict> </dict>
</plist> </plist>

View file

@ -20,6 +20,7 @@ import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.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/requests/send_message.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart"; import "package:nexus/models/room_chat.dart";
@ -415,6 +416,17 @@ class RoomChatController(final ChatParams info)
repliedEventRowId: repliedEventRowId, repliedEventRowId: repliedEventRowId,
); );
Future<void> sendLocation(
LocationMessageContent location, {
bool shouldMention = true,
int? repliedEventRowId,
}) => sendWithReply(
.message,
location,
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
);
/// Completes once the event has come down sync. /// Completes once the event has come down sync.
Future<void> sendPollReference( Future<void> sendPollReference(
Event poll, Event poll,

View file

@ -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>(
CurrentLocationHelper.new,
);
}

View file

@ -173,7 +173,9 @@ extension BuildEventOptions on Event {
), ),
), ),
if (content is MessageContent && sender == userId) if (content is MessageContent &&
content is! LocationMessageContent &&
sender == userId)
PopupMenuItem( PopupMenuItem(
onTap: () => onRelation(rowId, .edit), onTap: () => onRelation(rowId, .edit),
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")), child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),

View file

@ -4,7 +4,10 @@ import "package:nexus/helpers/extensions/is_desktop.dart";
extension ShowAdaptiveSheet on BuildContext { extension ShowAdaptiveSheet on BuildContext {
/// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet /// 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. /// on mobile. Use the context passed to [builder] to close it.
Future<T?> showAdaptiveSheet<T>(WidgetBuilder builder) => isDesktop Future<T?> showAdaptiveSheet<T>(
WidgetBuilder builder, {
bool enableDrag = true,
}) => isDesktop
? showDialog<T>( ? showDialog<T>(
context: this, context: this,
builder: (context) => Dialog( builder: (context) => Dialog(
@ -18,6 +21,7 @@ extension ShowAdaptiveSheet on BuildContext {
: showModalBottomSheet<T>( : showModalBottomSheet<T>(
context: this, context: this,
isScrollControlled: true, isScrollControlled: true,
enableDrag: enableDrag,
constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80), constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80),
builder: (context) => SafeArea(child: builder(context)), builder: (context) => SafeArea(child: builder(context)),
); );

View file

@ -4,6 +4,7 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/info/file.dart"; import "package:nexus/models/info/file.dart";
import "package:nexus/models/info/image.dart"; import "package:nexus/models/info/image.dart";
import "package:nexus/models/info/video.dart"; import "package:nexus/models/info/video.dart";
import "package:nexus/models/location.dart";
part "message.freezed.dart"; part "message.freezed.dart";
part "message.g.dart"; part "message.g.dart";
@ -90,8 +91,12 @@ sealed class MessageContent extends Content with _$MessageContent {
}) = VideoMessageContent; }) = VideoMessageContent;
@FreezedUnionValue("m.location") @FreezedUnionValue("m.location")
factory MessageContent.location({required String body, required Uri geoUri}) = factory MessageContent.location({
LocationMessageContent; 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<String, Object?> json) => factory MessageContent.fromJson(Map<String, Object?> json) =>
_$MessageContentFromJson(json); _$MessageContentFromJson(json);

84
lib/models/location.dart Normal file
View file

@ -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<String, Object?> toJson() => _$LocationInfoToJson(this);
factory LocationInfo.fromJson(Map<String, Object?> 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<String, Object?> toJson() => _$LocationAssetToJson(this);
factory LocationAsset.fromJson(Map<String, Object?> 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()}"}",
);
}

View file

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

View file

@ -25,6 +25,8 @@ import "package:nexus/models/image_pack.dart";
import "package:nexus/widgets/mxc_emoji.dart"; import "package:nexus/widgets/mxc_emoji.dart";
import "package:nexus/widgets/sticker_picker.dart"; import "package:nexus/widgets/sticker_picker.dart";
import "package:nexus/widgets/create_poll_sheet.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/models/relation_type.dart";
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
import "package:nexus/widgets/composer/paste_files_action.dart"; import "package:nexus/widgets/composer/paste_files_action.dart";
@ -57,6 +59,11 @@ class const Composer(
required bool shouldMention, required bool shouldMention,
}) })
onSendPoll, onSendPoll,
required final Future<void> Function(
LocationMessageContent location, {
required bool shouldMention,
})
onSendLocation,
final FocusNode? node, final FocusNode? node,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@ -110,13 +117,16 @@ class const Composer(
final sticker = useState<(String, PackImage)?>(null); final sticker = useState<(String, PackImage)?>(null);
final poll = useState<PollStart?>(null); final poll = useState<PollStart?>(null);
final location = useState<LocationMessageContent?>(null);
useEffect(() { useEffect(() {
sticker.value = null; sticker.value = null;
poll.value = null; poll.value = null;
location.value = null;
return null; return null;
}, [roomId]); }, [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 // The relation type stays as edit after cancelling an edit
final isEditing = relationType == .edit && relatedEvent != null; final isEditing = relationType == .edit && relatedEvent != null;
@ -126,6 +136,7 @@ class const Composer(
textBefore.value ??= controller.text; textBefore.value ??= controller.text;
sticker.value = null; sticker.value = null;
poll.value = null; poll.value = null;
location.value = null;
final text = final text =
relatedEvent.localContent?.editSource ?? relatedEvent.localContent?.editSource ??
@ -243,11 +254,14 @@ class const Composer(
onSendSticker(shortcode, image, shouldMention: shouldMention.value); onSendSticker(shortcode, image, shouldMention: shouldMention.value);
} else if (poll.value case final draft?) { } else if (poll.value case final draft?) {
onSendPoll(draft, shouldMention: shouldMention.value); onSendPoll(draft, shouldMention: shouldMention.value);
} else if (location.value case final draft?) {
onSendLocation(draft, shouldMention: shouldMention.value);
} }
onDismiss(); onDismiss();
// The draft is kept, so it can still be sent after the replacement // The draft is kept, so it can still be sent after the replacement
sticker.value = null; sticker.value = null;
poll.value = null; poll.value = null;
location.value = null;
node?.requestFocus(); node?.requestFocus();
return; return;
} }
@ -443,6 +457,22 @@ class const Composer(
leading: Icon(Icons.poll), 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), icon: Icon(Icons.add),
), ),
@ -451,8 +481,12 @@ class const Composer(
child: AnimatedSize( child: AnimatedSize(
duration: Durations.short4, duration: Durations.short4,
alignment: .bottomCenter, alignment: .bottomCenter,
child: switch ((sticker.value, poll.value)) { child: switch ((
(_, final draft?) => ComposerReplacement( sticker.value,
poll.value,
location.value,
)) {
(_, final draft?, _) => ComposerReplacement(
removeTooltip: "Remove poll", removeTooltip: "Remove poll",
onRemove: () { onRemove: () {
poll.value = null; poll.value = null;
@ -474,7 +508,7 @@ class const Composer(
), ),
), ),
), ),
((final shortcode, final image)?, _) => ((final shortcode, final image)?, _, _) =>
ComposerReplacement( ComposerReplacement(
removeTooltip: "Remove sticker", removeTooltip: "Remove sticker",
onRemove: () { 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, triggerStrategy: .eager,
// Characters that continue a search, for // Characters that continue a search, for
// e.g. :+1, :custom_emoji or @user:server // 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/helpers/extensions/linkify_html.dart";
import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/message.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/poll_start.dart";
import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/event.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_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/linkified_text.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/loading.dart";
import "package:nexus/widgets/message_image.dart"; import "package:nexus/widgets/message_image.dart";
import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/reaction_row.dart";
@ -64,9 +66,18 @@ class const MessageRenderer(
overflow: .ellipsis, overflow: .ellipsis,
) )
: PollRenderer(event, poll), : PollRenderer(event, poll),
// TODO: Handle locations
// LocationMessageContent(:final body , :final geoUri) =>
MessageContent content => switch (content) { 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) || TextMessageContent(:final body, :final formattedBody, :final format) ||
NoticeMessageContent( NoticeMessageContent(
:final body, :final body,
@ -167,7 +178,6 @@ class const MessageRenderer(
], ],
], ],
), ),
LocationMessageContent(:final String? body) ||
UnknownMessageContent(:final body) => UnknownMessageContent(:final body) =>
body == null body == null
? Text("This message is redacted", style: errorStyle) ? Text("This message is redacted", style: errorStyle)

View file

@ -350,6 +350,16 @@ final class const RoomChat({
repliedEventRowId, repliedEventRowId,
) )
.onError(showError), .onError(showError),
onSendLocation:
(location, {required shouldMention}) =>
notifier
.sendLocation(
location,
shouldMention: shouldMention,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
relationType: relationType.value, relationType: relationType.value,
relatedEvent: relatedEvent.value, relatedEvent: relatedEvent.value,
onDismiss: () { onDismiss: () {

View file

@ -9,6 +9,7 @@ import app_links
import dynamic_color import dynamic_color
import file_selector_macos import file_selector_macos
import flutter_local_notifications import flutter_local_notifications
import geolocator_apple
import media_kit_libs_macos_video import media_kit_libs_macos_video
import media_kit_video import media_kit_video
import package_info_plus import package_info_plus
@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))

View file

@ -12,5 +12,7 @@
<true/> <true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>
<true/> <true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict> </dict>
</plist> </plist>

View file

@ -42,5 +42,9 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>NSLocationUsageDescription</key>
<string>Nexus uses your location only when you choose to share it in a message.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Nexus uses your location only when you choose to share it in a message.</string>
</dict> </dict>
</plist> </plist>

View file

@ -8,5 +8,7 @@
<true/> <true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>
<true/> <true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict> </dict>
</plist> </plist>

Some files were not shown because too many files have changed in this diff Show more