// Singleton import 'dart:async'; import 'dart:convert'; import 'package:appwrite/appwrite.dart' as appwrite; import 'package:appwrite/models.dart' as models; import 'package:bus_infotainment/audio_cache.dart'; import 'package:bus_infotainment/auth/api_constants.dart'; import 'package:bus_infotainment/auth/auth_api.dart'; import 'package:bus_infotainment/backend/modules/announcement.dart'; import 'package:bus_infotainment/backend/modules/commands.dart'; import 'package:bus_infotainment/backend/modules/synced_time.dart'; import 'package:bus_infotainment/backend/modules/tracker.dart'; import 'package:bus_infotainment/backend/modules/tube_info.dart'; import 'package:bus_infotainment/tfl_datasets.dart'; import 'package:bus_infotainment/utils/audio%20wrapper.dart'; import 'package:bus_infotainment/utils/delegates.dart'; import 'package:bus_infotainment/workaround/keepalive_realtime.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:ntp/ntp.dart'; import 'package:permission_handler/permission_handler.dart'; class LiveInformation { static final LiveInformation _singleton = LiveInformation._internal(); factory LiveInformation() { return _singleton; } LiveInformation._internal(); Future initialize() async { { // By default, load the bus sequences from the assets print("Loading bus sequences from assets"); busSequences = BusSequences.fromCSV( await rootBundle.loadString("assets/datasets/bus-blinds.csv"), await rootBundle.loadString("assets/datasets/bus-sequences.csv") ); print("Loaded bus sequences from assets"); try { http.Response response = await http.get(Uri.parse('https://tfl.gov.uk/bus-sequences.csv')); busSequences = BusSequences.fromCSV( await rootBundle.loadString("assets/datasets/bus-blinds.csv"), response.body ); print("Loaded bus sequences from TFL"); } catch (e) { print("Failed to load bus sequences from TFL. Using local copy."); } // Load tube stations print("Loading tube stations from assets"); tubeStations = TubeStations.fromJson(json.decode(await rootBundle.loadString("assets/datasets/tube_stations.json"))); print("Loaded tube stations from assets"); } // Initialise modules syncedTimeModule = SyncedTimeModule(); announcementModule = AnnouncementModule(); initTrackerModule(); print("Initialised LiveInformation"); if (!auth.isAuthenticated()) { auth.loadAnonymousUser(); } } Future initTrackerModule() async { if (await Permission.location.isGranted) { trackerModule = TrackerModule(); } } // Auth AuthAPI auth = AuthAPI( autoLoad: false, ); String? roomCode; String? roomDocumentID; bool isHost = false; appwrite.RealtimeSubscription? _subscription; RealtimeKeepAliveConnection? _keepAliveConnection; // This is a workaround for a bug in the appwrite SDK // Modules // late CommandModule commandModule; This needs to be deprecated late BusSequences busSequences; late AnnouncementModule announcementModule; late SyncedTimeModule syncedTimeModule; late TrackerModule trackerModule; late TubeStations tubeStations; // Important variables BusRouteVariant? _currentRouteVariant; // Events EventDelegate routeVariantDelegate = EventDelegate(); // Internal methods Future setRouteVariant(BusRouteVariant? routeVariant) async { if (routeVariant == null) { _currentRouteVariant = null; await announcementModule.queueAnnounceByAudioName( displayText: "*** NO MESSAGE ***", ); routeVariantDelegate.trigger(null); return; } if (roomCode != null) { try { final client = auth.client; final databases = appwrite.Databases(client); final response = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode!) ] ); final document = response.documents.first; // Check if the route is not the same if (document.data["CurrentRoute"] != routeVariant.busRoute.routeNumber || document.data["CurrentRouteVariant"] != routeVariant.busRoute.routeVariants.values.toList().indexOf(routeVariant)) { final updatedDocument = await databases.updateDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: document.$id, data: { "CurrentRoute": routeVariant.busRoute.routeNumber, "CurrentRouteVariant": routeVariant.busRoute.routeVariants.values.toList().indexOf(routeVariant), "LastUpdater": auth.userID, } ); print("Updated route on server"); } } catch (e) { print("Failed to update route on server"); } } Continuation: // Set the current route variant _currentRouteVariant = routeVariant; // Let everyone know that the route variant has been set/changed routeVariantDelegate.trigger(routeVariant); // Get all of the files that need to be cached List audioFiles = []; for (BusRouteStop stop in routeVariant.busStops) { audioFiles.add(stop.getAudioFileName()); } // Cache/Load the audio files await announcementModule .announcementCache .loadAnnouncementsFromBytes(await LiveInformation().announcementModule.getBundleBytes(), [ ...audioFiles, if (!routeVariant.busRoute.routeNumber.toLowerCase().startsWith("ul")) "R_${routeVariant.busRoute.routeNumber}_001.mp3" else "R_RAIL_REPLACEMENT_SERVICE_001.mp3", ] ); // Display the route variant announcementModule.queueAnnouncementByRouteVariant(routeVariant: routeVariant); } // Public methods BusRouteVariant? getRouteVariant() { return _currentRouteVariant; } Future setRouteVariantQuery(String routeNumber, int routeVariantIndex) async { BusRoute route = busSequences.routes[routeNumber]!; BusRouteVariant routeVariant = route.routeVariants.values.toList()[routeVariantIndex]; await setRouteVariant( routeVariant ); } // Multi device support Future createRoom(String roomCode) async { print("Creating room with code $roomCode"); // Update the room code this.roomCode = roomCode; // Enable host mode isHost = true; // Access the database final client = auth.client; final databases = appwrite.Databases(client); // Remove any existing documents final existingDocuments = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode) ] ); for (var document in existingDocuments.documents) { await databases.deleteDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: document.$id ); } // Create the document final document = await databases.createDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: appwrite.ID.unique(), data: { "SessionID": roomCode, "LastUpdater": auth.userID, } ); // Listen for changes // { Breaks due to bug in appwrite // final realtime = appwrite.Realtime(client); // // if (_subscription != null) { // _subscription!.close(); // } // // _subscription = realtime.subscribe( // ['databases.6633e85400036415ab0f.collections.6633e85d0020f52f3771.documents.${document.$id}'] // ); // _subscription!.stream.listen(ServerListener); // } // Listen for changes if (_keepAliveConnection != null) { try { _keepAliveConnection!.close(); } catch (e) { print("Failed to close connection... oh well"); } } String APPWRITE_ENDPOINT_URL = "https://cloud.appwrite.io/v1"; String domain = APPWRITE_ENDPOINT_URL.replaceAll("https://", "").trim(); _keepAliveConnection = RealtimeKeepAliveConnection( channels: ['databases.6633e85400036415ab0f.collections.6633e85d0020f52f3771.documents.${document.$id}'], onData: ServerListener, domain: domain, client: auth.client, onError: (e) { print("Workarround Error: $e"); }, ); _keepAliveConnection!.initialize(); // Update the room document ID roomDocumentID = document.$id; print("Created room with code $roomCode"); } Future JoinRoom(String roomCode) async { print("Joining room with code $roomCode"); // Disable host mode isHost = false; // Update the room code this.roomCode = roomCode; // Access the database final client = auth.client; final databases = appwrite.Databases(client); // Get the document final response = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode) ] ); if (response.documents.isEmpty) { throw Exception("Room not found"); } final document = response.documents.first; // Listen for changes // { // final realtime = appwrite.Realtime(client); // // if (_subscription != null) { // _subscription!.close(); // } // // _subscription = realtime.subscribe([ // 'databases.6633e85400036415ab0f.collections.6633e85d0020f52f3771.documents.${document.$id}' // ]); // // _subscription!.stream.listen(ServerListener); // } // Listen for changes if (_keepAliveConnection != null) { try { _keepAliveConnection!.close(); } catch (e) { print("Failed to close connection... oh well"); } } String APPWRITE_ENDPOINT_URL = "https://cloud.appwrite.io/v1"; String domain = APPWRITE_ENDPOINT_URL.replaceAll("https://", "").trim(); _keepAliveConnection = RealtimeKeepAliveConnection( channels: ['databases.6633e85400036415ab0f.collections.6633e85d0020f52f3771.documents.${document.$id}'], onData: ServerListener, domain: domain, client: auth.client, onError: (e) { print("Workarround Error: $e"); }, ); _keepAliveConnection!.initialize(); // Update the room document ID roomDocumentID = document.$id; // Get the current route try { String routeNumber = document.data["CurrentRoute"]; int routeVariantIndex = document.data["CurrentRouteVariant"]; await setRouteVariantQuery(routeNumber, routeVariantIndex); print("Set route to $routeNumber $routeVariantIndex"); } catch (e) { print("Failed to set route"); } print("Joined room with code $roomCode"); } String? lastCommand; Future ServerListener(appwrite.RealtimeMessage response) async { print("Session update"); // Only do something if the document was created or updated if (!(response.events.first.contains("create") || response.events.first.contains("update"))) { return; } // Get the user that caused the event String senderID = response.payload["LastUpdater"]; // If the sender is the same as the client, then ignore the event if (senderID == auth.userID) { print("Ignoring event"); return; } // Check to see if the commands are updated try { // Get the new route String routeNumber = response.payload["CurrentRoute"]; int routeVariantIndex = response.payload["CurrentRouteVariant"]; // If the route arent the same, then update the route if (routeNumber != _currentRouteVariant!.busRoute.routeNumber || routeVariantIndex != _currentRouteVariant!.busRoute.routeVariants.values.toList().indexOf(_currentRouteVariant!)) { // Set the route await setRouteVariantQuery(routeNumber, routeVariantIndex); // announce the route // announcementModule.queueAnnouncementByRouteVariant(routeVariant: _currentRouteVariant!); } } catch (e) { print("Failed to set route"); } // Execute the command List commands = response.payload["Commands"].cast(); String? command = commands.last; if (command == lastCommand) { return; } lastCommand = command; List commandParts = _splitCommand(command); String commandName = commandParts[0]; if (commandName == "announce") { print("Announce command received"); String mode = commandParts[1]; print ("Command: $command"); if (mode == "info") { print("Announce info command received"); announcementModule.queueAnnounementByInfoIndex( sendToServer: false, infoIndex: int.parse(commandParts[2]), scheduledTime: DateTime.fromMillisecondsSinceEpoch(int.parse(commandParts[3])), ); } else if (mode == "dest") { print("Announce dest command received"); String routeNumber = commandParts[2]; int routeVariantIndex = int.parse(commandParts[3]); announcementModule.queueAnnouncementByRouteVariant( sendToServer: false, routeVariant: busSequences.routes[routeNumber]!.routeVariants.values.toList()[routeVariantIndex], scheduledTime: DateTime.fromMillisecondsSinceEpoch(int.parse(commandParts[4])), ); } else if (mode == "manual") { print("Announce manual command received"); final displayText = commandParts[2]; List audioFileNames = commandParts.sublist(3); try { if (int.parse(audioFileNames.last) != null) { audioFileNames.removeLast(); } } catch (e) {} DateTime scheduledTime = LiveInformation().syncedTimeModule.Now().add(Duration(seconds: 1)); try { if (int.parse(commandParts.last) != null) { scheduledTime = DateTime.fromMillisecondsSinceEpoch(int.parse(commandParts.last)); } } catch (e) {} announcementModule.queueAnnounceByAudioName( displayText: displayText, audioNames: audioFileNames, scheduledTime: scheduledTime, sendToServer: false ); } } } String _extractId(String input) { RegExp regExp = RegExp(r'\("user:(.*)"\)'); Match match = regExp.firstMatch(input)!; return match.group(1)!; } Future SendCommand(String command) async { final client = auth.client; final databases = appwrite.Databases(client); final response = await databases.listDocuments( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", queries: [ appwrite.Query.search("SessionID", roomCode!) ] ); List pastCommands = []; response.documents.first.data["Commands"].forEach((element) { pastCommands.add(element); }); pastCommands.add(command); final document = await databases.updateDocument( databaseId: "6633e85400036415ab0f", collectionId: "6633e85d0020f52f3771", documentId: roomDocumentID!, data: { "Commands": pastCommands, "LastUpdater": auth.userID, } ); } List _splitCommand(String command) { var regex = RegExp(r'([^\s"]+)|"([^"]*)"'); var matches = regex.allMatches(command); return matches.map((match) => match.group(0)!.replaceAll('"', '')).toList(); } /* Everything under this will be considered legacy code */ } class AnnouncementQueueEntry { final String displayText; final List audioSources; bool sendToServer = true; DateTime? scheduledTime; DateTime? timestamp; AnnouncementQueueEntry({required this.displayText, required this.audioSources, this.sendToServer = true, this.scheduledTime, this.timestamp}); } class NamedAnnouncementQueueEntry extends AnnouncementQueueEntry { final String shortName; NamedAnnouncementQueueEntry({ required this.shortName, required String displayText, required List audioSources, DateTime? scheduledTime, DateTime? timestamp, bool sendToServer = true, }) : super( displayText: displayText, audioSources: audioSources, sendToServer: sendToServer, scheduledTime: scheduledTime, timestamp: timestamp, ); } var abs = (int value) => value < 0 ? -value : value;