Location Sharing

Introduction

Location sharing is a powerful feature that allows users to share their current position or real-time location with other participants in a channel. Stream Chat supports both static and live location sharing through location attachments.

There are two types of location sharing:

  • Static Location: A one-time location share that does not update over time.
  • Live Location: A real-time location share that updates over time for a specified duration.

The SDK handles location message creation and updates, but location tracking must be implemented by the application using device location services.

Adding Location Sharing into a Flutter Chat App

In this guide, we will be adding location sharing functionality to a Flutter chat app, similar to WhatsApp, iMessage, or Telegram. Other location sharing use cases, like delivery tracking, can be implemented in a similar way. The Stream Chat SDK provides the underlying functionality for location sharing, but, at the moment, does not include default UI components. However, we provide a complete example implementation that you can reference and adapt for your needs.

After completing the guide, you should have a working location sharing functionality in your chat app, like in the pictures below:

Location PickerLocation Attachment
Location Picker
Location Attachment

Setting up location services

For location sharing functionality, it’s important that you ask for permissions to use the device location services. Therefore, you should add the following entries in your platform configuration files:

Android - Add to android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

iOS - Add to ios/Runner/Info.plist:

<key>NSLocationWhenInUseUsageDescription</key>
<string>This app requires location access to share your location with other users</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app requires location access to share your location with other users</string>

Add the following dependencies to your pubspec.yaml:

dependencies:
  stream_chat_flutter: ^10.0.0-beta.4
  geolocator: ^13.0.0
  rxdart: ^0.27.7

For map functionality, you can optionally add:

  • flutter_map: OpenStreetMap-based maps (free, no API key required)
  • google_maps_flutter: Google Maps (requires API key)
  • mapbox_gl: Mapbox maps (requires API key)

Once you have added the entries, you will need to create a LocationProvider class to handle the location monitoring as well as the permission requests. Create lib/utils/location_provider.dart:

import 'dart:async';
import 'package:geolocator/geolocator.dart';

class LocationProvider {
  factory LocationProvider() => _instance;
  LocationProvider._();
  static final LocationProvider _instance = LocationProvider._();

  Stream<Position> get positionStream => _positionStreamController.stream;
  final _positionStreamController = StreamController<Position>.broadcast();
  StreamSubscription<Position>? _positionSubscription;

  Future<Position?> getCurrentLocation() async {
    final hasPermission = await _handlePermission();
    if (!hasPermission) return null;
    return Geolocator.getCurrentPosition();
  }

  Future<void> startTracking() async {
    final hasPermission = await _handlePermission();
    if (!hasPermission) return;

    final settings = LocationSettings(
      accuracy: LocationAccuracy.high,
      distanceFilter: 10,
    );

    _positionSubscription?.cancel();
    _positionSubscription = Geolocator.getPositionStream(
      locationSettings: settings,
    ).listen(
      _positionStreamController.safeAdd,
      onError: _positionStreamController.safeAddError,
    );
  }

  void stopTracking() {
    _positionSubscription?.cancel();
    _positionSubscription = null;
  }

  Future<bool> _handlePermission() async {
    final serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) return false;

    var permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
    }

    return switch (permission) {
      LocationPermission.denied || LocationPermission.deniedForever => false,
      _ => true,
    };
  }
}

Setting up location tracking

In order to update the location of the user, you need to set up a shared location service and implement location tracking. The service makes it easy to start and stop tracking location updates, as well as observing the active live location messages.

Create lib/services/shared_location_service.dart:

import 'dart:async';
import 'package:geolocator/geolocator.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

import '../utils/location_provider.dart';

class SharedLocationService {
  SharedLocationService({
    required StreamChatClient client,
    LocationProvider? locationProvider,
  })  : _client = client,
        _locationProvider = locationProvider ?? LocationProvider();

  final StreamChatClient _client;
  final LocationProvider _locationProvider;

  StreamSubscription<Position>? _positionSubscription;
  StreamSubscription<List<Location>>? _activeLiveLocationsSubscription;

  Future<void> initialize() async {
    _activeLiveLocationsSubscription?.cancel();
    _activeLiveLocationsSubscription = _client.state.activeLiveLocationsStream
        .distinct((prev, curr) => prev.length == curr.length)
        .listen((locations) async {
      // If there are no more active locations to update, stop tracking.
      if (locations.isEmpty) return _stopTrackingLocation();

      // Otherwise, start tracking the user's location.
      return _startTrackingLocation();
    });

    return _client.getActiveLiveLocations().ignore();
  }

  Future<void> _startTrackingLocation() async {
    if (_positionSubscription != null) return;

    // Start listening to the position stream.
    _positionSubscription = _locationProvider.positionStream
        .throttleTime(const Duration(seconds: 3))
        .listen(_onPositionUpdate);

    return _locationProvider.startTracking();
  }

  void _stopTrackingLocation() {
    _locationProvider.stopTracking();

    // Stop tracking the user's location
    _positionSubscription?.cancel();
    _positionSubscription = null;
  }

  void _onPositionUpdate(Position position) {
    // Handle location updates, e.g., update the UI or send to server
    final activeLiveLocations = _client.state.activeLiveLocations;
    if (activeLiveLocations.isEmpty) return _stopTrackingLocation();

    // Update all active live locations
    for (final location in activeLiveLocations) {
      // Skip if the location is not live or has expired
      if (location.isLive && location.isExpired) continue;

      // Skip if the location does not have a messageId
      final messageId = location.messageId;
      if (messageId == null) continue;

      // Update the live location with the new position
      _client.updateLiveLocation(
        messageId: messageId,
        createdByDeviceId: location.createdByDeviceId,
        location: LocationCoordinates(
          latitude: position.latitude,
          longitude: position.longitude,
        ),
      );
    }
  }

  /// Clean up resources
  Future<void> dispose() async {
    _stopTrackingLocation();

    _activeLiveLocationsSubscription?.cancel();
    _activeLiveLocationsSubscription = null;
  }
}

We recommend setting up the location tracking as soon as you connect the user. In the example below, we set up the location tracking in the main app widget:

class MyChatApp extends StatefulWidget {
  @override
  State<MyChatApp> createState() => _MyChatAppState();
}

class _MyChatAppState extends State<MyChatApp> {
  late SharedLocationService _sharedLocationService;

  @override
  void initState() {
    super.initState();
    _sharedLocationService = SharedLocationService(client: client);
    _sharedLocationService.initialize();
  }

  @override
  void dispose() {
    _sharedLocationService.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return StreamChat(
      client: client,
      child: StreamChannel(
        channel: channel,
        child: StreamMessageListView(),
      ),
    );
  }
}

Adding location picker

For sending location messages, we first need to customize the message input.

For this, we’ll need to:

  1. Create custom attachment picker types
  2. Add a location button to the message input’s attachment picker
  3. Present a location picker widget to select the location type (static or live)

Create the custom attachment picker types. Create lib/widgets/location_picker_types.dart:

import 'package:stream_chat_flutter/stream_chat_flutter.dart';

final class LocationPickerType extends CustomAttachmentPickerType {
  const LocationPickerType();
}

final class LocationPicked extends CustomAttachmentPickerResult {
  const LocationPicked({required this.location});
  final LocationPickerResult location;
}

class LocationPickerResult {
  const LocationPickerResult({
    this.endSharingAt,
    required this.coordinates,
  });

  final DateTime? endSharingAt;
  final LocationCoordinates coordinates;
}

In order to add a new location action button to the message input, we need to add the location picker type to the allowed attachment picker types. When the button is tapped, we will present a location picker widget to select the location type (static or live).

StreamMessageInput(
  // ... other properties
  allowedAttachmentPickerTypes: [
    ...AttachmentPickerType.values,
    if (config?.sharedLocations == true && channel.canShareLocation)
      const LocationPickerType(),
  ],
  onCustomAttachmentPickerResult: (result) {
    return _onCustomAttachmentPickerResult(channel, result).ignore();
  },
  customAttachmentPickerOptions: [
    TabbedAttachmentPickerOption(
      key: 'location-picker',
      icon: const Icon(Icons.near_me_rounded),
      supportedTypes: [const LocationPickerType()],
      optionViewBuilder: (context, controller) => LocationPicker(
        onLocationPicked: (locationResult) {
          if (locationResult == null) return Navigator.pop(context);
          
          final result = LocationPicked(location: locationResult);
          return Navigator.pop(context, result);
        },
      ),
    ),
  ],
),


Future<void> _onCustomAttachmentPickerResult(
  Channel channel,
  CustomAttachmentPickerResult result,
) async {
  final response = switch (result) {
    LocationPicked() => _onShareLocationPicked(channel, result.location),
    _ => null,
  };

  return response?.ignore();
}

Future<SendMessageResponse> _onShareLocationPicked(
  Channel channel,
  LocationPickerResult result,
) async {
  if (result.endSharingAt case final endSharingAt?) {
    return channel.startLiveLocationSharing(
      endSharingAt: endSharingAt,
      location: result.coordinates,
    );
  }

  return channel.sendStaticLocation(location: result.coordinates);
}

As you can see, we are checking if location sharing is enabled for the channel and conditionally adding the location picker to the allowed attachment picker types, and handling the results to send either static or live location sharing.

Then, we are creating a new LocationPicker instance and presenting it. This widget will be responsible for requesting the location permission and selecting whether the user wants to send a static or live location message.

class LocationPicker extends StatefulWidget {
  const LocationPicker({
    super.key,
    required this.onLocationPicked,
  });

  final ValueSetter<LocationPickerResult?> onLocationPicked;

  @override
  State<LocationPicker> createState() => _LocationPickerState();
}

class _LocationPickerState extends State<LocationPicker> {
  LocationCoordinates? _currentLocation;

  @override
  Widget build(BuildContext context) {
    final theme = StreamChatTheme.of(context);
    final colorTheme = theme.colorTheme;

    return Scaffold(
      backgroundColor: colorTheme.appBg,
      appBar: AppBar(
        backgroundColor: colorTheme.barsBg,
        title: const Text('Share Location'),
      ),
      body: Stack(
        alignment: AlignmentDirectional.bottomCenter,
        children: [
          FutureBuilder(
            future: LocationProvider().getCurrentLocation(),
            builder: (context, snapshot) {
              if (snapshot.connectionState != ConnectionState.done) {
                return const Center(
                  child: CircularProgressIndicator.adaptive(),
                );
              }

              final position = snapshot.data;
              if (snapshot.hasError || position == null) {
                return const Center(child: LocationNotFound());
              }

              final coordinates = _currentLocation = LocationCoordinates(
                latitude: position.latitude,
                longitude: position.longitude,
              );

              return Container(
                width: double.infinity,
                height: 300,
                child: Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(
                        Icons.location_on,
                        size: 64,
                        color: colorTheme.accentPrimary,
                      ),
                      const SizedBox(height: 16),
                      Text(
                        'Current Location',
                        style: theme.textTheme.headline,
                      ),
                      Text(
                        '${coordinates.latitude.toStringAsFixed(4)}, ${coordinates.longitude.toStringAsFixed(4)}',
                        style: theme.textTheme.body.copyWith(
                          color: colorTheme.textLowEmphasis,
                        ),
                      ),
                    ],
                  ),
                ),
              );
            },
          ),
          LocationPickerOptionList(
            onOptionSelected: (option) {
              final currentLocation = _currentLocation;
              if (currentLocation == null) return Navigator.pop(context);

              final result = LocationPickerResult(
                endSharingAt: switch (option) {
                  ShareStaticLocation() => null,
                  ShareLiveLocation() => option.endSharingAt,
                },
                coordinates: currentLocation,
              );

              return Navigator.pop(context, result);
            },
          ),
        ],
      ),
    );
  }
}

We use the LocationProvider class to get the current location of the user, which will first ask for permissions if the user has not granted them yet. Then, we are setting up the widget so that the user can see their current location and the two buttons to select the location type. If the user taps the static location button, we will send a static location message to the channel by calling channel.sendStaticLocation(location). If the user taps the live location button, we will present a dialog to select the duration for the live location sharing, and call channel.startLiveLocationSharing(location, endDate: endDate).

This example shows a simple location display. For a better user experience, you can integrate map packages like flutter_map or google_maps_flutter to show an interactive map with the user’s current location and allow them to select a different location if needed.

Adding message location widget

Now that we have a way to send location messages, we need to render them in the message list. The Stream Chat SDK uses an attachment system to customize how different types of content are displayed. For location messages, we need to:

  1. Create a custom attachment builder
  2. Create a location attachment widget
  3. Handle location interactions

Setting up the attachment builder

First, create a custom attachment builder that detects location messages and uses the custom widget:

class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder {
  const LocationAttachmentBuilder({
    this.onAttachmentTap,
    this.onStopLiveLocation,
  });

  final ValueSetter<Location>? onAttachmentTap;
  final ValueSetter<Location>? onStopLiveLocation;

  @override
  bool canHandle(Message message, _) => message.sharedLocation != null;

  @override
  Widget build(BuildContext context, Message message, _) {
    final location = message.sharedLocation!;
    return LocationAttachment(
      sharedLocation: location,
      onLocationTap: switch (onAttachmentTap) {
        final onTap? => () => onTap(location),
        _ => null,
      },
      onStopLiveLocation: onStopLiveLocation,
    );
  }
}

Creating the location attachment widget

The location attachment widget displays a preview of the location. Here’s the implementation:

This example shows a simple location preview. For a more realistic implementation, you can use map packages like flutter_map or google_maps_flutter to display an actual map snapshot or interactive map widget in the message list.

class LocationAttachment extends StatelessWidget {
  const LocationAttachment({
    super.key,
    required this.sharedLocation,
    this.onLocationTap,
    this.onStopLiveLocation,
  });

  final Location sharedLocation;
  final ValueSetter<Location>? onLocationTap;
  final ValueSetter<Location>? onStopLiveLocation;

  @override
  Widget build(BuildContext context) {
    final currentUser = StreamChat.of(context).currentUser;
    final isFromCurrentUser = sharedLocation.userId == currentUser?.id;

    return GestureDetector(
      onTap: onLocationTap,
      child: Container(
        width: 270,
        height: 180,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(14),
          color: Colors.grey[200],
        ),
        child: Stack(
          children: [
            Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(
                    isLive ? Icons.near_me : Icons.pin_drop,
                    size: 48,
                    color: Colors.blue,
                  ),
                  const SizedBox(height: 8),
                  Text(
                    isLive ? 'Live Location' : 'Shared Location',
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    '${sharedLocation.coordinates.latitude.toStringAsFixed(4)}, ${sharedLocation.coordinates.longitude.toStringAsFixed(4)}',
                    style: const TextStyle(
                      fontSize: 12,
                      color: Colors.grey,
                    ),
                  ),
                ],
              ),
            ),
            if (sharedLocation.isLive && isFromCurrentUser)
              Positioned(
                top: 8,
                right: 8,
                child: GestureDetector(
                  onTap: switch (onStopLiveLocation) {
                    final onStop? => () => onStop(sharedLocation),
                    _ => null,
                  },
                  child: Container(
                    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                    decoration: BoxDecoration(
                      color: Colors.red,
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: const Text(
                      'Stop Sharing',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 12,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

Handling location interactions

We need to handle the location interactions that were forwarded by the attachment builder. We do this by extending the message list with a custom message builder.

StreamMessageListView(
  messageBuilder: customMessageBuilder,
),

Widget customMessageBuilder(
  BuildContext context,
  MessageDetails details,
  List<Message> messages,
  StreamMessageWidget defaultMessageWidget,
) {
  final locationAttachmentBuilder = LocationAttachmentBuilder(
    onAttachmentTap: (location) {
      // Handle location tap - you can navigate to a detail screen
      // or show a full-screen map widget here
    },
    onStopLiveLocation: (location) {
      // Handle stop live location - this will be called when user taps stop
      final client = StreamChat.of(context).client;
      final messageId = location.messageId;
      
      if (messageId != null) {
        client.stopLiveLocation(
          messageId: messageId,
          createdByDeviceId: location.createdByDeviceId,
        );
      }
    },
  );

  return defaultMessageWidget.copyWith(
    attachmentBuilders: [locationAttachmentBuilder],
  );
}

For the most part, this implementation will suit most use cases. However, you can customize it to your needs, for example, you can also show all active live location messages in the same map. For this you will need to use the channel.activeLiveLocations property and use the EventsController to listen for message events and update the location messages accordingly.

Conclusion

In this guide, we’ve covered how to integrate location sharing functionality into a Flutter-based chat application using the Stream Chat SDK. The SDK provides the core functionality for sending, receiving, and managing location messages, while giving you the flexibility to create custom UI components that match your app’s design.

Key points to remember:

  • The SDK handles location message lifecycle management
  • Your app is responsible for location tracking and UI components
  • Both static and live location sharing are supported
  • Events allow real-time updates of location messages
© Getstream.io, Inc. All Rights Reserved.