A stand-out feature of the most popular messaging applications is the ability to share a user's location quickly and conveniently with trusted peers. Using Stream Chat and Flutter, we can implement a similar feature in very little time.
In this article, we will build a small location-sharing chat feature using Flutter, Stream Chat, and the location package found on pub.dev. By the end of this post, readers will be able to listen and respond to location changes in real-time and transmit those changes to contacts using Custom Events.
Getting Started
Before we dive into code, there are a few things we are going to need:
- A free Stream account
- Access to Google's Map API
Setting up Stream 💬
First, go to the trial registration page and fill out the required details. (Note that your organization name can't have spaces in it.)
The chat trial lets you play around with Stream Chat APIs for 30 days, for free.
💡 If you're a small business or you're building a hobby project, you can also apply for a Stream Maker Account to keep using Stream Chat for free indefinitely!
After clicking Get Started, you'll land on the following success page, which contains important details for your new account.
Your organization bears the name you've provided on the previous page. You also get an app created for you by default within the organization, which has the same name initially.
Apps at Stream have API key / secret pairs that can be used to access them via clients and API calls. Your app will get a pair of these created automatically as well. (You can add more or remove existing ones later on.)
- The API key is simply an identifier for your app. This will be used by the clients to connect to the app. Since these are used on the client side, they are public and safe to share around.
- The secret, however, should always be kept private. This provides admin access to your app, and can be used to create authenticated user tokens.
From this screen, click the app name to proceed to the dashboard.
Exploring the dashboard
Here, you'll see an overview of your organization, including high-level usage information and a list of your apps with their details (just one, for now).
As you see here, the starter app is created in the us-east
region by default. You can choose the region to use when an app is created. For the list of available regions and more details about regions in general, see Multi-region Support.
If you don't like the default generated app name, you can rename the app by clicking Options → Edit.
You can also select whether the app should be in Production or Development mode. It's in Production mode by default, where certain destructive actions on the dashboard are disabled so that you can't accidentally delete user data, disable permissions, or remove important configurations.
Make a note of your application's secret and key since we will be using this later in the tutorial.
Configuring Google Maps 🗾
Since we will be using Google Maps to display the user's location, we need to generate an API key from the Google Cloud Dashboard.
I will be using the iOS simulator for this tutorial, so under the Maps API, I will enable "Maps SDK for iOS" and "Maps Static API". If you are following along on Android, please enable the "Maps SDK for Android" in addition to the "Maps Static API".
Once both APIs are enabled, you can view the keys by selecting the credentials tab under the Maps Platform menu.
Code setup ⚙️
Let's start by creating a new Flutter project. For this tutorial, I will be using the latest version of Flutter on the stable channel (v 2.2.0 at the time of writing).
Once the project is created, add the following to the project's pubspec.yaml
:
1234567891011121314environment: sdk: ">=2.12.0 <3.0.0" dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.3 stream_chat_flutter: ^2.0.0-nullsafety.6 #new location: ^4.3.0 #new google_maps_flutter: ^2.0.6 #new dev_dependencies: flutter_test: sdk: flutter
In main.dart
, we can update the code with the following:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() async { const STREAM_KEY = String.fromEnvironment('api'); const USER_TOKEN = String.fromEnvironment('token'); final client = StreamChatClient( STREAM_KEY, logLevel: Level.OFF, ); await client.connectUser( User( id: 'YOUR-USER-ID', extraData: { 'image': 'https://local.getstream.io:9000/random_png/?id=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZGVsaWNhdGUtZmlyZS02In0.Yfdnsfkt48g1xv3I77mBjlVISnLwMyVUFobBynTf6Jc&name=delicate-fire-6', }, ), USER_TOKEN, ); final channel = client.channel('messaging', id: 'sample-app-channel-1'); channel.watch(); runApp(MyApp(client: client)); } class MyApp extends StatelessWidget { const MyApp({Key? key, required this.client}) : super(key: key); final StreamChatClient client; Widget build(BuildContext context) { return MaterialApp( builder: (context, widget) { return StreamChat( child: widget!, client: client, ); }, home: ChannelListPage(), ); } } class ChannelListPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Stream Playground'), ), body: ChannelsBloc( child: ChannelListView( filter: Filter.in_('members', [StreamChat.of(context).user!.id]), sort: [SortOption('last_message_at')], pagination: PaginationParams( limit: 30, ), channelWidget: Builder(builder: (context) => ChannelPage()), ), ), ); } } class ChannelPage extends StatelessWidget { const ChannelPage({ Key? key, }) : super(key: key); Widget build(BuildContext context) { return Scaffold( appBar: ChannelHeader(), body: Column( children: <Widget>[ Expanded( child: MessageListView(), ), MessageInput(), ], ), ); } }
Even if you haven't used Stream's Flutter SDK before, the code above should be relatively straightforward. Using Stream's UI SDK, we construct a simple chat application consisting of a scrolling list of channels and a message list view.
The API key obtained from the Stream dashboard is stored in the variable STREAM_KEY
while USER_TOKEN
is generated using Stream's token generator.
Note: The User.id and id passed in the user token must match.
Running the above code results in a UI that looks like the following:
Customizing the UI 💄
Before we can start sending our location in chat, we need to update our UI with options users can interact with. By default, Stream's pre-made widgets allow users to send attachments such as images, videos, and gifs, but we can tap into this system to provide custom attachments in our chat.
To get started, let's take a look at the existing chat UI and the modifications we will make to the message input.
Message default message input:
Customized message input:
Adding this to our application is quite easy. If we go back out our main.dart
file, we can make a few modifications to the MessageInput
widget in our ChannelPage
:
123456789101112131415161718192021Widget build(BuildContext context) { return Scaffold( appBar: ChannelHeader(), body: Column( children: <Widget>[ Expanded( child: MessageListView(), ), MessageInput( actions: [ IconButton( icon: Icon(Icons.location_history), onPressed: () {}, ), ], ), ], ), ); }
Stream allows us to pass custom actions to our message input. In the above code snippet, we pass an IconButton
containing a location icon and a blank onPressed
function to our widget.
Executing the code and refreshing our device should yield a UI similar to what's shown above.
Great! We are well on our way to adding location support in our chat. While our message input looks great and now gives users the option to send their location, the current implementation is not very useful since clicking the button does not do anything.
Let's outline the sequence of events that should occur when a user taps the button:
💡 User taps → Request permission → Fetches current location → Builds location preview → Show user location as an image in chat
With our use case defined, it's time to implement!
First, let's convert our ChannelPage
to a stateful widget using Dart's built-in plugin. This is necessary since we will be working with platform services and streams. It's always a good idea to allocate and deallocate these services in a widget's initState
and dispose
methods to avoid memory leaks and performance bottlenecks.
In our state class, we can declare a nullable variable to hold our active instance of Location
. Since we will be interacting with native code, we can use Flutter's location package to simplify our platform interaction. Once created, we can write a simple function in our state class to set up the plugin and permission.
123456789101112131415161718192021Future<bool> setupLocation() async { if (location == null) { location = Location(); } var _serviceEnabled = await location!.serviceEnabled(); if (!_serviceEnabled) { _serviceEnabled = await location!.requestService(); if (!_serviceEnabled) { return false; } } var _permissionGranted = await location!.hasPermission(); if (_permissionGranted == PermissionStatus.denied) { _permissionGranted = await location!.requestPermission(); if (_permissionGranted != PermissionStatus.granted) { return false; } } return true; }
Using the variable we declared, we can check if it is null before creating a new location
. Once an instance is created, we can then proceed to request permission from the OS. We can check if the location service is enabled on the user's device by calling serviceEnabled
, which returns a boolean value. Based on this value, we can either request the user to enable their location or request permission. The code for requesting permission closely resembles the call to serviceEnabled
. In this case, the hasPermission
method can be used instead.
Throughout the process, boolean values can be returned to indicate whether or not the user has agreed to grant location permission to our application. This will come in handy later on when we try using the location API to obtain the user's current position.
Next, we can implement a handler for our custom action. As you may recall, we passed a blank onPressed
to the RaisedButton
in the channel page. Let's change this using the function below.
123456789101112131415161718192021222324Future<void> onLocationRequestPressed() async { final canSendLocation = await setupLocation(); if (canSendLocation != true) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( "We can't access your location at this time. Did you allow location access?"), ), ); } final locationData = await location!.getLocation(); _messageInputKey.currentState?.addAttachment( Attachment( type: 'location', uploadState: UploadState.success(), extraData: { 'lat': locationData.latitude, 'long': locationData.longitude, }, ), ); return; }
onLocationRequestPressed
builds on the code we wrote earlier by delegating Location setup to setupLocation
. It stores the result of this function in the variable canSendLocation
which is then used to either fetch the user's current location or show a SnackBar indicating that permission was not granted to the application.
Notice how we are also adding the user's location as an Attachment
using a _messageInputKey
? This allows us to easily store the user's location as a custom object on Stream, containing latitude and longitude in a key-value pair. Additionally, by adding it to the message input via a GlobalKey
, we can use the widget's attachment preview, allowing the user to see a thumbnail of the location as they type a message.
The code for creating and adding a global key to our MessageInput
are as follows:
123class _ChannelPageState extends State<ChannelPage> { Location? location; GlobalKey<MessageInputState> _messageInputKey = GlobalKey(); // new
123456789MessageInput( key: _messageInputKey, actions: [ IconButton( icon: Icon(Icons.location_history), onPressed: onLocationRequestPressed, ), ], ),
Before we can save our code and hit send, we need to build a small preview for our location widget. After all, sharing your location is no good if your friends cannot see where you are 😝.
Custom Attachments 📍
To get started with a custom attachment preview, we can implement the attachmentThumbnailBuilders
parameter on MessageInput
. This parameter is a map containing location types and widget builders. It allows developers to build custom widgets and layouts for the different types of attachments supported by Stream. In our case, we are going to build an entirely new attachment layout for our location
attachment.
123456789101112131415MessageInput( key: _messageInputKey, attachmentThumbnailBuilders: { 'location': (context, attachment) => MapImageThumbnail( lat: attachment.extraData['lat'] as double, long: attachment.extraData['long'] as double, ) }, actions: [ IconButton( icon: Icon(Icons.location_history), onPressed: onLocationRequestPressed, ), ], ),
If you're following along and receive an error after copying and pasting the above code, fear not, as we will be implementing MapImageThumbnail
next.
Looking back at our original goals for this article, you can see we're getting very close to a finished product. Our next goal is to provide users with a way to view their current location while composing their message, and natively in chat.
💡
User Taps→Request permission→Fetches current location→ Builds location preview → Show user location as an image in chat.
Let's start by creating a new stateless widget named MapImageThumbnail
. The name of this widget is very self-explanatory. It will be used for constructing an image preview of the user's location. We will also be using Google's static map API, so be sure to get those API keys from earlier ready 😉.
1234567891011121314151617181920212223242526272829303132333435class MapImageThumbnail extends StatelessWidget { const MapImageThumbnail({ Key? key, required this.lat, required this.long, }) : super(key: key); final double lat; final double long; String get _constructUrl => Uri( scheme: 'https', host: 'maps.googleapis.com', port: 443, path: '/maps/api/staticmap', queryParameters: { 'center': '$lat,$long', 'zoom': '18', 'size': '700x500', 'maptype': 'roadmap', 'key': 'YOUR-API-KEY', 'markers': 'color:red|$lat,$long' }, ).toString(); Widget build(BuildContext context) { return Image.network( _constructUrl, height: 300.0, width: 600.0, fit: BoxFit.fill, ); } }
If we save and look at our application, you can see we are making great progress. Users can now type a message and select a location attachment. When our location button is pressed, the message input shows the user a thumbnail of their current location as they type.
The final step to our location master plan is displaying a similar preview natively in chat for all users to see. This can be done easily using a similar pattern to custom input attachments. Instead of modifying the MessageInput
, we can change our focus to MessageListView
.
Stream's message list view does a lot of heavy lifting out of the box, but we can extend its functionality even more but tapping into customAttachmentBuilders
. Like its younger sibling attachmentThumbnailBuilders
in MessageInput
, custom attachment builder allows developers to render custom UI for attachments natively among chat messages.
To get started, we can create a map using location
as the key and a function _buildLocationMessage
as the builder. The signature for this function differs slightly from the one used in message input. Let's take a look at the code:
1234567891011121314151617Widget _buildLocationMessage( BuildContext context, Message details, List<Attachment> _, ) { final lat = details.attachments.first.extraData['lat'] as double; final long = details.attachments.first.extraData['long'] as double; return wrapAttachmentWidget( context, MapImageThumbnail( lat: lat, long: long, ), RoundedRectangleBorder(), true, ); }
The code above is very boring. It extracts and stores the user's latitude and longitude in a variable passed to MapImageThumbnail
. Unlink our preview implementation. Our widget is wrapped in the helper function wrapAttachmentWidget
, which takes a few additional parameters for styling our message.
Congratulations! You've successfully implemented location sharing in your chat application using Google Maps and Stream chat! 🎉.
One more thing 🍎
While our current implementation ticks all of the boxes for location sharing, we can take things one step further by adding an interactive map and real-time location updates to our app. Let's update our goals and implement real-time sharing as a bonus 😃.
User taps location image → Opens Google Maps → Updates user location in real time
We can get started by importing the google maps package in our main.dart
file.
1import 'package:google_maps_flutter/google_maps_flutter.dart';
Next, we can modify our channel page and location functions. When the sender taps the location attachment, we can call a function to start tracking the user's current location and pass this to other participants in the chat.
Implementing this can be done using Stream's Custom Events. Custom events are useful for building complex user interactions in channels. They can be used to send real-time data to clients via Stream's web-sockets.
To get started, let's create a new function called startLocationTracking
in our _ChannelPageState
. We can use this function to handle listening to and reaction to location changes.
123456789101112131415161718192021222324252627282930Future<void> startLocationTracking( String messageId, String attachmentId, ) async { final canSendLocation = await setupLocation(); if (canSendLocation != true) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( "We can't access your location at this time. Did you allow location access?"), ), ); } locationSubscription = location!.onLocationChanged.listen( (LocationData event) { _channel.sendEvent( Event( type: 'location_update', extraData: { 'lat': event.latitude, 'long': event.longitude, }, ), ); }, ); return; }
Using the helper method setupLocation
we created before, we can check to ensure the user has granted the appropriate permissions before creating a StreamSubscription
. It's always a good idea to store subscriptions in a function in the state class since it makes it easy to cancel. In our case, we can do so by using the variable locationSubscription
.
123456789101112class _ChannelPageState extends State<ChannelPage> { Location? location; GlobalKey<MessageInputState> _messageInputKey = GlobalKey(); StreamSubscription<LocationData>? locationSubscription; //new late Channel _channel; //new void didChangeDependencies() { super.didChangeDependencies(); _channel = StreamChannel.of(context).channel; //new }
When our location changes, we can send a custom event using the current channel. Similar to custom attachments, we can give these events a type unique to our use case along with the user's longitude and latitude in a key-value pair.
1234567891011(LocationData event) { _channel.sendEvent( Event( type: 'location_update', extraData: { 'lat': event.latitude, 'long': event.longitude, }, ), ); },
Before we move on to creating the UI for our location updates, we can also declare a function for canceling our stream subscription:
1void cancelLocationSubscription() => locationSubscription?.cancel();
Google Maps UI
The final piece of our puzzle is an interactive Google map, which updates as the user location changes. We can use the building blocks created in the earlier sections to create this page.
First, we can create a new stateful widget in our application called GoogleMapsView
. This class will be responsible for displaying and updating our map as the location changes. In the constructor, let's pass a few parameters to the class:
1234567891011121314151617class GoogleMapsView extends StatefulWidget { const GoogleMapsView({ Key? key, required this.channelName, required this.message, required this.channel, required this.onBack, }) : super(key: key); final String channelName; final Message message; final Channel channel; final VoidCallback onBack; _GoogleMapsViewState createState() => _GoogleMapsViewState(); }
Now we can move on to implementing the business logic in the state class.
In the Google Maps View State, we can create the variables needed to implement our map. These variables store the user's lat and long coordinates, location stream subscription, and GoogleMaps
controller.
1234567late StreamSubscription _messageSubscription; late double lat; late double long; GoogleMapController? mapController; Attachment get _messageAttachment => widget.message.attachments.first;
💡 A private getter can be used to access our message attachment quickly. It allows us to reference the attachment without writing verbose reference code everywhere in our widget tree.
Next, we can set the initial lat and long coordinates for our user in the widget's initState
:
123456void initState() { super.initState(); lat = _messageAttachment.extraData['lat'] as double; long = _messageAttachment.extraData['long'] as double; }
While we are here, we can also set up a handler for listening and reacting to custom events sent via Stream.
12345678910void initState() { super.initState(); lat = _messageAttachment.extraData['lat'] as double; long = _messageAttachment.extraData['long'] as double; // New Line _messageSubscription = widget.channel.on('location_update').listen(_updateHandler); }
As with all stream subscriptions, we should cancel this in the widget's dispose method:
12345void dispose() { super.dispose(); _messageSubscription.cancel(); // New Line }
With the message subscription in place, we can implement the _updateHandler
handler to get rid of those pesky analyzer errors.
123456789101112131415161718void _updateHandler(Event event) { double _newLat = event.extraData['lat'] as double; double _newLong = event.extraData['long'] as double; setState(() { lat = _newLat; long = _newLong; }); mapController?.animateCamera( CameraUpdate.newLatLng( LatLng( _newLat, _newLong, ), ), ); }
The signature for listening to a Stream channel has a single parameter of type Event
. As you may recall earlier, as the user location changes, we sent a custom event to the channel using the type location_update
. Since we listen to changes of this type using the .on
method in our stream subscription, we can examine the extra data properties of events sent across the channel to receive the user's current latitude and longitude. With this data, setState
can be called along with mapController?.animateCamera
to update our map UI.
With the heavy lifting out of the way, we can implement our build method using the GoogleMap
widget, passing with a LatLong
based on the variables we created earlier. Since this widget takes some time to build, we can wrap the tree in an AnimatedCrossFade
with a placeholder to improve our application's responsiveness and display content to the user.
As the map becomes available and the onMapCreated
function is called, we can change the active child to the Google Map showing our user's location in all its glory.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152Widget build(BuildContext context) { var _pos = LatLng(lat, long); return WillPopScope( onWillPop: () async { widget.onBack(); return true; }, child: Scaffold( appBar: AppBar( title: Text( widget.channelName, style: TextStyle( color: Colors.black, ), ), backgroundColor: Colors.white, ), body: AnimatedCrossFade( duration: kThemeAnimationDuration, crossFadeState: mapController != null ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: ConstrainedBox( constraints: BoxConstraints.loose(MediaQuery.of(context).size), child: GoogleMap( initialCameraPosition: CameraPosition( target: _pos, zoom: 18, ), onMapCreated: (_controller) => setState(() => mapController = _controller), markers: { Marker( markerId: MarkerId("user-location-marker-id"), position: _pos, ) }, ), ), secondChild: Container( child: Center( child: Icon( Icons.location_history, color: Colors.red.withOpacity(0.76), ), ), ), ), ), ); }
Finally, we can modify _buildLocationMessage
in _ChannelPageState
with an InkWell. This will allow users to navigate and view our Google Maps screen when the attachment is pressed.
Widget _buildLocationMessage( BuildContext context, Message details, List<Attachment> _, ) { final username = details.user!.name; final lat = details.attachments.first.extraData['lat'] as double; final long = details.attachments.first.extraData['long'] as double; return InkWell( onTap: () { if (details.user!.id == StreamChat.of(context).user!.id) startLocationTracking(details.id, details.attachments.first.id); Navigator.of(context).push( MaterialPageRoute( builder: (context) => GoogleMapsView( onBack: cancelLocationSubscription, message: details, channelName: username, channel: _channel, ), ), ); }, child: wrapAttachmentWidget( context, MapImageThumbnail( lat: lat, long: long, ), RoundedRectangleBorder(), true, ), ); }
In the above code snippet, we only transmit real-time location updates to Google Maps if the current user matches the message sender. This means both the sender and receiver must be on the map's screen to view real-time location changes.
Please feel free to customize this behavior to your application's needs 🙂
If we save and run our application, the result should look similar to the video below:
Key takeaways
Woohoo 🎉 , we've come to the end. Today, we used the flexibility of Stream's Flutter SDK to add location sharing natively in chat. As a bonus, we were able to leverage the custom events to share the user's real time location with message recipients.
The code for this project can be found on my GitHub. If you are building a Flutter project or are interested in mobile and cross-platform technologies, consider following me on Twitter.
Stream's Flutter SDKs are all open source on GitHub, consider leaving a ⭐ if you enjoyed this project or you plan on using chat in your Flutter application soon.