Flutter chat with multiple users
This document presents an advanced chat example that allows multi-user chat. It describes the example_advanced_chat.dart available in the ./lib/ folder.
It assumes that you have followed the Minimalist Flutter Chat tutorial, and will only describes function that uses new parts of the Discret API.
As a starting point:
- replace the ./lib/main.dart by the new example file ./lib/example_advanced_chat.dart.
- compile the application. This can take a few minutes to complete.
rm ./lib/main.dart
cp ./lib/example_advanced_chat.dart ./lib/main.dart
flutter build linux
Once compiled, do to folder do to the folder indicated at the end of the compilation. On a Linux device, the folder is the following:
cd build/linux/x64/release/bundle/
- launch the application a first time and create a new account. Compared to the previous example, you will notice that there is a new field named displayed name. It is the name that other peers will see.
- keep the first instance open.
- launch the application a second time, and create another account.
- On the first instance, click on the Invite button, copy the invite string using the Copy to Clipboard and then click the Ok button.
- On the second instance, click on the Accept Invite button, paste the invitation in the field and click on the Ok
- on both instances, you should see the name of the other peer appearing in the left menu.
- you should now be able to discuss between the two accounts.
If you copy the program on another local device and re-create one of the account, you should be able to discuss between the two devices.
With three different accounts, the screen should look like that:
Preparing the API
The Api initialization does not change much compared to the previous example.
The folder to store data is changed to support Android devices (and probably IOS).
18 //...
19 void main() async {
20 WidgetsFlutterBinding.ensureInitialized();
21 final path = await _localPath;
22 String dataPath = '$path/.discret_data';
We also set the log level to info. Log levels can be changed anytime with the values:
- error
- warn
- info
- debug
- trace
27 await Discret().setLogLevel("info");
Architecture
To manage several discussion groups, the ChatState provides a list of ChatRoom and all the functions required to
- connect
- manage invite
- manage the discussion group list
class ChatState extends ChangeNotifier {
final List<ChatRoom> chatRooms = [];
//..
}
The ChatRoom the fields and function required to send and receive messages.
class ChatRoom {
final List<ChatEntry> chat = [];
String roomId;
String name;
int mdate;
}
Initialization
The ChatState initialization is more complex that the previous one.
During the ChatSate creation, a log listener is created. Those logs are primarily used to send error message that are not directly related to an API call.
The code only push the logs in the Flutter log Flutter log for debugging purposes.
50 final StreamSubscription<LogMsg> loglistener =
51 Discret().logStream().listen((event) async {
52 var message = event.data;
53 var level = event.level;
54 logger.log('$level: $message');
55 });
--
Line 68 retrieve the user's verifyingKey. It represents the public identity of this user.
Data created by this user are signed with the associated signature key and other peers will verify the signature during data synchronization.
msg = await Discret().verifyingKey();
myVerifyingKey = msg.data;
The event stream is also more complex and manage more events.
eventlistener = Discret().eventStream().listen((event) async {
switch (event.type) {
case EventType.dataChanged:
{
DataModification modif = event.data as DataModification;
modif.rooms?.forEach((key, value) {
if (chatMap.containsKey(key)) {
ChatRoom chatRoom = chatMap[key]!;
chatRoom.refresh();
}
});
chatRooms.sort((a, b) => b.mdate.compareTo(a.mdate));
notifyListeners();
}
case EventType.roomModified:
{
String roomId = event.data as String;
await loadRoom(roomId);
notifyListeners();
}
case EventType.peerConnected:
{
PeerConnection peer = event.data as PeerConnection;
var key = peer.verifyingKey;
logger.log('Peer connected: $key');
}
case EventType.peerDisconnected:
{
PeerConnection peer = event.data as PeerConnection;
var key = peer.verifyingKey;
logger.log('Peer disconnected: $key');
}
default:
}
}, onDone: () {}, onError: (error) {});
The EventType.dataChanged event have take into account the different discussion groups and will only refresh the Rooms that have new data.
A new event is managed: EventType.roomModified. This event is triggered by the invitation functions when creating new invites and accepting invites. It detects that Rooms have been created or modified.
The events EventType.peerConnected and EventType.peerDisconnected are triggered when a peer connect or disconnect. It is not directly used by the code but put in the flutter log for debugging purposes.
Creating an Account
The login function does not change much, but the account creation is more complex because it has to manage the new Display Name field.
This field is used to associate a name to your public identity (your verifying_key). Your identity and name will be sent to other peers when accepting invitations.
This association is done in the sys.Peer that contains all known peers, including you.
128 Future<ResultMsg> createAccount(
129 String userName, String password, String displayName) async {
130 ResultMsg msg = await Discret().login(userName, password, true);
131 if (msg.successful) {
132 await initialise();
133 String query = """query{
134 sys.Peer(verifying_key=\$verifyingKey){
135 id
136 }
137 }""";
138
139 ResultMsg res =
140 await Discret().query(query, {"verifyingKey": myVerifyingKey});
141 if (!res.successful) {
142 logger.log(res.error);
143 return res;
144 }
145
146 final json = jsonDecode(res.data) as Map<String, dynamic>;
147 final msgArray = json["sys.Peer"] as List<dynamic>;
148 var result = msgArray[0] as Map<String, dynamic>;
149 var id = result["id"];
150 String mutate = """mutate {
151 sys.Peer{
152 id:\$id
153 name:\$name
154 }
155 }""";
156 res = await Discret().mutate(mutate, {"id": id, "name": displayName});
157 if (!res.successful) {
158 logger.log(res.error);
159 return res;
160 }
161 return res;
162 }
163 return msg;
164 }
The first query line 133 retrieve the unique identifier id of the tuple that stores your identity.
Every tuple in the Discret database have a unique id generated during the insertion.
String query = """query{
sys.Peer(verifying_key=\$verifyingKey){
id
}
}""";
The second query line 150 updates the name field with the Display Name value. Every mutation query that provides an id is an update query. If the id is not found, an error will be returned.
String mutate = """mutate {
sys.Peer{
id:\$id
name:\$name
}
}""";
Creating an invite
Every new invites needs to create a new Room to limit access to to discussion group to you and the invited peer.
273 Future<String> createInvite() async {
274 String query = """mutate {
275 sys.Room{
276 admin: [{
277 verif_key:\$verifyingKey
278 }]
279 authorisations:[{
280 name:"chat"
281 rights:[{
282 entity:"chat.Message"
283 mutate_self:true
284 mutate_all:false
285 }]
286 users:[{
287 verif_key:\$verifyingKey
288 }]
289 }]
290 }
291 }""";
292
293 ResultMsg res =
294 await Discret().mutate(query, {"verifyingKey": myVerifyingKey});
295
296 if (!res.successful) {
297 logger.log(res.error);
298 return "";
299 }
300
301 final json = jsonDecode(res.data) as Map<String, dynamic>;
302 final msgArray = json["sys.Room"] as Map<String, dynamic>;
303 String roomId = msgArray["id"];
304
305 final auths = msgArray["authorisations"] as List<dynamic>;
306 final authorisation = auths[0] as Map<String, dynamic>;
307 final authId = authorisation["id"];
308
309 ResultMsg invite = await Discret().invite(roomId, authId);
310 if (!res.successful) {
311 logger.log(res.error);
312 return "";
313 }
314 return invite.data;
315 }
The query ligne 274 creates a new room for the invited peer.
String query = """mutate {
sys.Room{
admin: [{
verif_key:\$verifyingKey
}]
authorisations:[{
name:"chat"
rights:[{
entity:"chat.Message"
mutate_self:true
mutate_all:false
}]
users:[{
verif_key:\$verifyingKey
}]
}]
}
}""";
When creating the Room, the invited peer identity (its verifying_key) is not yet known, so you just insert your identity.
You can notice that your identity is used twice:
- once in the admin field, giving you administrator rights for this Room
- once in the user field of the autorisation. It is not strictly necessary because administrator have every rights, but it simplifies the code in this example. In the list of available group, we will only display groups that have two user, indicating that the invite has been accepted.
The rights field, indicate that only tuples of the chat.Message entity are allowed in this room. Trying to insert other things will return an error.
The API to create an invite requires:
- The Room id de la Room,
- The id of an autorisation that belongs to that Room.
Those identifiers are retrieved by parsing the result of the query (lines 293 to 307) that contains every ids generated during insertion.
Once the invite accepted, the new peer identity will be inserted in the user field of the provided autorisation.
Calling the API will retur a large invitation String that you will need to manually transmit to the invited peer
ResultMsg invite = await Discret().invite(roomId, authId);
//..
return invite.data;
Accepting an invitation
Accepting an invitation is just an API call with the invitation string.
An invitation can only be used once.
320 Future<void> acceptInvite(String invite) async {
321 ResultMsg msg = await Discret().acceptInvite(invite);
322 if (!msg.successful) {
323 logger.log(msg.error);
324 }
325 }