diff --git a/lib/data/models/coag_contact.dart b/lib/data/models/coag_contact.dart index 85f7884..47166d1 100644 --- a/lib/data/models/coag_contact.dart +++ b/lib/data/models/coag_contact.dart @@ -149,10 +149,34 @@ class CoagContact extends Equatable { // TODO: Make this a proper type with toJson? final String? sharedProfile; - factory CoagContact.fromJson(Map json) => - _$CoagContactFromJson(json); - - Map toJson() => _$CoagContactToJson(this); + factory CoagContact.fromJson(Map json) { + // This is just a hack because somehow the pictures list representation + // screws with the autogenerated fromJson + if (json['system_contact'] != null && + json['system_contact']['thumbnail'] != null) { + json['system_contact']['thumbnail'] = null; + } + if (json['system_contact'] != null && + json['system_contact']['photo'] != null) { + json['system_contact']['photo'] = null; + } + return _$CoagContactFromJson(json); + } + + Map toJson() { + final json = _$CoagContactToJson(this); + // This is just a hack because somehow the pictures list representation + // screws with the autogenerated fromJson + if (json['system_contact'] != null && + json['system_contact']['thumbnail'] != null) { + json['system_contact']['thumbnail'] = null; + } + if (json['system_contact'] != null && + json['system_contact']['photo'] != null) { + json['system_contact']['photo'] = null; + } + return json; + } CoagContact copyWith( {Contact? systemContact, diff --git a/lib/data/providers/background.dart b/lib/data/providers/background.dart new file mode 100644 index 0000000..008617c --- /dev/null +++ b/lib/data/providers/background.dart @@ -0,0 +1,180 @@ +// Copyright 2024 The Coagulate Authors. All rights reserved. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../veilid_init.dart'; +import '../../veilid_processor/repository/processor_repository.dart'; +import '../providers/dht.dart'; +import '../providers/persistent_storage.dart'; +import '../providers/system_contacts.dart'; +import '../repositories/contacts.dart'; + +const String dhtRefreshBackgroundTaskName = 'social.coagulate.dht.refresh'; +const String refreshProfileContactTaskName = 'social.coagulate.profile.refresh'; +const String shareUpdatedProfileToDhtTaskName = 'social.coagulate.dht.profile'; + +// TODO: Can we refactor this to share more with the ContactsRepository? +/// If system contact information for profile contact is changed, update profile +Future refreshProfileContactDetails(String task, _) async { + try { + if (task != refreshProfileContactTaskName) { + return true; + } + + final appStorage = await getApplicationDocumentsDirectory(); + final persistentStorage = HivePersistentStorage(appStorage.path); + + final profileContactId = await persistentStorage.getProfileContactId(); + if (profileContactId == null) { + return false; + } + + final profileContact = await persistentStorage.getContact(profileContactId); + if (profileContact.systemContact == null) { + return false; + } + + final currentSystemContact = + await getSystemContact(profileContact.systemContact!.id); + + if (profileContact.systemContact != currentSystemContact) { + await persistentStorage.updateContact( + profileContact.copyWith(systemContact: currentSystemContact)); + } + return true; + } on Exception catch (e) { + return false; + } +} + +/// Write the current profile contact information to all contacts' DHT record +/// that have a different (outdated) version. +Future shareUpdatedProfileToDHT(String task, _) async { + // TODO: Do we need refreshProfileContactDetails as a separate task or do we just do it always here because it's fast? + if (task != shareUpdatedProfileToDhtTaskName) { + return true; + } + try { + final startTime = DateTime.now(); + + final appStorage = await getApplicationDocumentsDirectory(); + final persistentStorage = HivePersistentStorage(appStorage.path); + + final profileContactId = await persistentStorage.getProfileContactId(); + if (profileContactId == null) { + return false; + } + + final contacts = await persistentStorage.getAllContacts(); + if (!contacts.containsKey(profileContactId)) { + return false; + } + final profileContact = contacts[profileContactId]!; + + // Don't try to fetch things if not connected to the internet + final connectivity = await Connectivity().checkConnectivity(); + if (!connectivity.contains(ConnectivityResult.wifi) && + !connectivity.contains(ConnectivityResult.mobile) && + !connectivity.contains(ConnectivityResult.ethernet)) { + return true; + } + + // TODO: Check if Veilid is already running? + await VeilidChatGlobalInit.initialize(); + + var iContact = 0; + while (iContact < contacts.length && + startTime.add(const Duration(seconds: 25)).isBefore(DateTime.now())) { + // Wait for Veilid connectivity + // TODO: Are we too conservative here? + if (!ProcessorRepository.instance.startedUp || + !ProcessorRepository + .instance.processorConnectionState.isPublicInternetReady) { + sleep(const Duration(seconds: 1)); + continue; + } + + final contact = contacts.values.elementAt(iContact); + iContact++; + if (contact.dhtSettingsForSharing == null) { + continue; + } + + final sharedProfile = json.encode(removeNullOrEmptyValues( + filterAccordingToSharingProfile(profileContact).toJson())); + if (contact.sharedProfile == sharedProfile) { + continue; + } + + final updatedContact = contact.copyWith(sharedProfile: sharedProfile); + await updateContactSharingDHT(updatedContact); + + await persistentStorage.updateContact(updatedContact); + } + return true; + } on Exception catch (e) { + return false; + } +} + +Future refreshContactsFromDHT(String task, _) async { + if (task != dhtRefreshBackgroundTaskName) { + return Future.value(true); + } + final startTime = DateTime.now(); + + // Don't try to fetch things if not connected to the internet + final connectivity = await Connectivity().checkConnectivity(); + if (!connectivity.contains(ConnectivityResult.wifi) && + !connectivity.contains(ConnectivityResult.mobile) && + !connectivity.contains(ConnectivityResult.ethernet)) { + return Future.value(true); + } + + // TODO: Check if Veilid is already running? + await VeilidChatGlobalInit.initialize(); + + final appStorage = await getApplicationDocumentsDirectory(); + final persistentStorage = HivePersistentStorage(appStorage.path); + + // TODO: Update contacts from DHT records and persist; order by least recently updated or random? + // Shuffling might reduce the risk for re-trying on an unreachable / long running update and never getting to others? + final contacts = (await persistentStorage.getAllContacts()).values.toList() + ..shuffle(); + + var iContact = 0; + while (iContact < contacts.length && + startTime.add(const Duration(seconds: 25)).isBefore(DateTime.now())) { + // Wait for Veilid connectivity + // TODO: Are we too conservative here? + if (!ProcessorRepository.instance.startedUp || + !ProcessorRepository + .instance.processorConnectionState.isPublicInternetReady) { + sleep(const Duration(seconds: 1)); + continue; + } + + final contact = contacts[iContact]; + iContact++; + + // TODO: set last checked timestamp inside this function? + final updatedContact = await updateContactReceivingDHT(contact); + if (updatedContact == contact) { + continue; + } + + // system contact update? + // persiste to disk + + // If changed, update system contact according to management profile + // Update coagContact & persist + } + + return Future.value(true); +} diff --git a/lib/data/repositories/contacts.dart b/lib/data/repositories/contacts.dart index dd96309..c010c3e 100644 --- a/lib/data/repositories/contacts.dart +++ b/lib/data/repositories/contacts.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:rxdart/subjects.dart'; @@ -11,7 +10,6 @@ import 'package:uuid/uuid.dart'; import '../../veilid_processor/repository/processor_repository.dart'; import '../models/coag_contact.dart'; -import '../models/contact_location.dart'; import '../models/contact_update.dart'; import '../providers/dht.dart'; import '../providers/persistent_storage.dart'; @@ -19,17 +17,6 @@ import '../providers/system_contacts.dart'; // TODO: Persist all changes to any contact by never accessing coagContacts directly, only via getter and setter -// Just for testing purposes while no contacts share their locations -CoagContact _populateWithDummyLocations(CoagContact contact) => - contact.copyWith( - locations: contact.details!.addresses - .map((a) => AddressLocation( - coagContactId: contact.coagContactId, - longitude: Random().nextDouble() / 2 * 50, - latitude: Random().nextDouble() / 2 * 50, - name: a.label.name)) - .toList()); - // TODO: Add sharing profile and filter CoagContactDHTSchemaV1 filterAccordingToSharingProfile(CoagContact contact) => CoagContactDHTSchemaV1( @@ -50,7 +37,6 @@ Map removeNullOrEmptyValues(Map json) { /// Entrypoint for application layer when it comes to [CoagContact] class ContactsRepository { ContactsRepository(this._persistentStoragePath) { - FlutterContacts.addListener(_updateFromSystemContacts); unawaited(_init()); } @@ -58,65 +44,40 @@ class ContactsRepository { late final HivePersistentStorage _persistentStorage = HivePersistentStorage(_persistentStoragePath); - String? profileContactId = null; + String? profileContactId; - Map coagContacts = {}; + Map _contacts = {}; // TODO: Persist; maybe just proxy read and writes to // persistent storage directly instead of having additional state here List updates = []; - final _updateStatusStreamController = - BehaviorSubject.seeded('NO-UPDATES'); + final _contactsStreamController = BehaviorSubject(); final _systemContactAccessGrantedStreamController = BehaviorSubject.seeded(false); - // TODO: Ensure that: - // persistent storage is loaded, - // new changes from system contacts come in, - // new changes from dht come in - // changes in profile contact go out via dht Future _init() async { // Load profile contact ID from persistent storage profileContactId = await _persistentStorage.getProfileContactId(); // Load coagulate contacts from persistent storage - coagContacts = await _persistentStorage.getAllContacts(); - - coagContacts = coagContacts - .map((key, value) => MapEntry(key, _populateWithDummyLocations(value))); + _contacts = await _persistentStorage.getAllContacts(); + for (final c in _contacts.values) { + _contactsStreamController.add(c); + } - // Update contacts wrt the system contacts - unawaited(_updateFromSystemContacts()); + await _updateFromSystemContacts(); + FlutterContacts.addListener(_updateFromSystemContacts); // Update the contacts wrt the DHT // TODO: Only do this when online - unawaited(updateAndWatchReceivingDHT()); + await updateAndWatchReceivingDHT(); } - Future updateAndWatchReceivingDHT() async { - // TODO: Only do this when online; FIXME: This is a hack - if (!ProcessorRepository - .instance.processorConnectionState.attachment.publicInternetReady) { - return; - } - for (final contact in coagContacts.values) { - // Check for incoming updates - if (contact.dhtSettingsForReceiving != null) { - print('checking ${contact.coagContactId}'); - final updatedContact = await updateContactReceivingDHT(contact); - if (updatedContact != contact) { - // TODO: Use update time from when the update was sent not received - updates.add(ContactUpdate( - message: 'News from ${contact.details?.displayName}', - timestamp: DateTime.now())); - await updateContact(updatedContact); - } - // TODO: Check how long this takes and what could go wrong with not awaiting instead - // FIXME: actually start to watch, but canceling watch seems to require opening the record? - // await watchDHTRecord(contact.dhtSettingsForReceiving!.key); - } - } + Future _saveContact(CoagContact coagContact) async { + _contacts[coagContact.coagContactId] = coagContact; + _contactsStreamController.add(coagContact); + await _persistentStorage.updateContact(coagContact); } Future _updateFromSystemContact(CoagContact contact) async { @@ -127,11 +88,11 @@ class ContactsRepository { // Try if permissions are granted try { final systemContact = await getSystemContact(contact.systemContact!.id); + _systemContactAccessGrantedStreamController.add(true); + if (systemContact != contact.systemContact!) { - coagContacts[contact.coagContactId] = - contact.copyWith(systemContact: systemContact); - _updateStatusStreamController - .add('UPDATE-AVAILABLE:${contact.coagContactId}'); + final updatedContact = contact.copyWith(systemContact: systemContact); + await _saveContact(updatedContact); } } on MissingSystemContactsPermissionError { _systemContactAccessGrantedStreamController.add(false); @@ -145,34 +106,32 @@ class ContactsRepository { try { var systemContacts = await getSystemContacts(); _systemContactAccessGrantedStreamController.add(true); - for (final coagContact in coagContacts.values) { + for (final coagContact in _contacts.values) { // Skip coagulate contacts that are not associated with a system contact if (coagContact.systemContact == null) { continue; } // Remove contacts that did not change - if (systemContacts.remove(coagContact.details)) { + // TODO: This could be coagContact.getSystemContactBasedOnSyncSettings + // in case we want to keep the original system contact for reference + if (systemContacts.remove(coagContact.systemContact)) { continue; } // The remaining matches based on system contact ID need to be updated final iChangedContact = systemContacts.indexWhere((systemContact) => systemContact.id == coagContact.systemContact!.id); - coagContacts[coagContact.coagContactId] = coagContact.copyWith( + final updatedContact = coagContact.copyWith( systemContact: systemContacts[iChangedContact]); systemContacts.removeAt(iChangedContact); - _updateStatusStreamController - .add('UPDATE-AVAILABLE:${coagContact.coagContactId}'); + await _saveContact(updatedContact); } // The remaining system contacts are new for (final systemContact in systemContacts) { - final coagContact = _populateWithDummyLocations(CoagContact( - coagContactId: Uuid().v4().toString(), + final coagContact = CoagContact( + coagContactId: const Uuid().v4(), systemContact: systemContact, - details: ContactDetails.fromSystemContact(systemContact))); - coagContacts[coagContact.coagContactId] = coagContact; - // TODO: Also signal that it's a new one? - _updateStatusStreamController - .add('UPDATE-AVAILABLE:${coagContact.coagContactId}'); + details: ContactDetails.fromSystemContact(systemContact)); + await _saveContact(coagContact); } } on MissingSystemContactsPermissionError { _systemContactAccessGrantedStreamController.add(false); @@ -182,14 +141,11 @@ class ContactsRepository { Future _onDHTContactUpdateReceived() async { // TODO: check DHT for updates // TODO: trigger persistent storage update - _updateStatusStreamController.add('UPDATE-AVAILABLE:'); + // _contactsStreamController.add(coagContact); } - // Signal "update" or "contactID" in case a specific contact was updated - // TODO: create enum or custom type for it instead of string - // TODO: subscribe to updates in bloc/cubit - Stream getUpdateStatus() => - _updateStatusStreamController.asBroadcastStream(); + Stream getContactUpdates() => + _contactsStreamController.asBroadcastStream(); // TODO: subscribe to this from a settings cubit to show the appropriate button in UI Stream isSystemContactAccessGranted() => @@ -199,21 +155,18 @@ class ContactsRepository { // Or maybe separate depending on what part is updated (details, locations, dht stuff) Future updateContact(CoagContact contact) async { // Skip in case already up to date - if (coagContacts[contact.coagContactId] == contact) { + if (_contacts[contact.coagContactId] == contact) { return; } - // Update persistent storage - unawaited(_persistentStorage.updateContact(contact)); - // TODO: Allow creation of a new system contact via update contact as well; might require custom contact details schema // Update system contact if linked and contact details changed if (contact.systemContact != null && - coagContacts[contact.coagContactId]!.systemContact != + _contacts[contact.coagContactId]!.systemContact != contact.systemContact) { // TODO: How to reconsile system contacts if permission was removed intermittently and is then granted again? try { - unawaited(updateSystemContact(contact.systemContact!)); + await updateSystemContact(contact.systemContact!); } on MissingSystemContactsPermissionError { _systemContactAccessGrantedStreamController.add(false); } @@ -230,41 +183,66 @@ class ContactsRepository { } } - coagContacts[contact.coagContactId] = contact; - _updateStatusStreamController - .add('UPDATE-AVAILABLE:${contact.coagContactId}'); + await _saveContact(contact); } - // TODO: This seems unused, remove? - Future setProfileContactId(String id) async { - profileContactId = id; - await _persistentStorage.setProfileContactId(id); - // TODO: Notify about update + Future updateAndWatchReceivingDHT({bool shuffle = false}) async { + // TODO: Only do this when online; FIXME: This is a hack + if (!ProcessorRepository + .instance.processorConnectionState.attachment.publicInternetReady) { + return; + } + final contacts = _contacts.values.toList(); + if (shuffle) { + contacts.shuffle(); + } + for (final contact in contacts) { + // Check for incoming updates + if (contact.dhtSettingsForReceiving != null) { + print('checking ${contact.coagContactId}'); + final updatedContact = await updateContactReceivingDHT(contact); + if (updatedContact != contact) { + // TODO: Use update time from when the update was sent not received + updates.add(ContactUpdate( + message: 'News from ${contact.details?.displayName}', + timestamp: DateTime.now())); + await updateContact(updatedContact); + } + // TODO: Check how long this takes and what could go wrong with not awaiting instead + // FIXME: actually start to watch, but canceling watch seems to require opening the record? + // await watchDHTRecord(contact.dhtSettingsForReceiving!.key); + } + } } Future updateProfileContact(String coagContactId) async { - if (!coagContacts.containsKey(coagContactId)) { + if (!_contacts.containsKey(coagContactId)) { // TODO: Log / raise error return; } + // TODO: Do we need to enforce writing to disk to make it available to background straight away? + await _persistentStorage.setProfileContactId(coagContactId); + // Ensure all system contacts changes are in - await _updateFromSystemContact(coagContacts[coagContactId]!); + await _updateFromSystemContact(_contacts[coagContactId]!); - for (final contact in coagContacts.values) { + for (final contact in _contacts.values) { if (contact.dhtSettingsForSharing?.psk == null) { continue; } await updateContact(contact.copyWith( sharedProfile: json.encode(removeNullOrEmptyValues( - filterAccordingToSharingProfile(coagContacts[coagContactId]!) + filterAccordingToSharingProfile(_contacts[coagContactId]!) .toJson())))); } } String? getCoagContactIdForSystemContactId(String systemContactId) => - coagContacts.values + _contacts.values .firstWhere((c) => c.systemContact != null && c.systemContact!.id == systemContactId) .coagContactId; + + Map getContacts() => _contacts; } diff --git a/lib/main.dart b/lib/main.dart index 141bf2e..cef7ce5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,6 @@ // SPDX-License-Identifier: MPL-2.0 import 'dart:async'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -10,76 +9,11 @@ import 'package:flutter_translate/flutter_translate.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:workmanager/workmanager.dart'; import 'bloc_observer.dart'; import 'tools/loggy.dart'; import 'ui/app.dart'; -const String dhtRefreshBackgroundTaskName = 'social.coagulate.dht.refresh'; - -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((task, _) async { - if (task == dhtRefreshBackgroundTaskName) { - // TODO: Ensure veilid is running - // TODO: Wait for a reasonable amount of seconds if not connected to enough nodes - // TODO: If still not connected enough, cancel - // TODO: Update contacts from DHT records and persist; order by least recently updated or random? - // TODO: After 30 seconds, stop - } - return Future.value(true); - }); -} - -void _showNoPermission( - BuildContext context, BackgroundRefreshPermissionState hasPermission) { - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('No permission'), - content: - Text('Background app refresh is disabled, please enable in ' - 'App settings. Status ${hasPermission.name}'), - actions: [ - TextButton( - child: const Text('OK'), - onPressed: () => Navigator.of(context).pop()), - ])); -} - -Future _initWorkManager(BuildContext context) async { - if (Platform.isIOS) { - final hasPermission = - await Workmanager().checkBackgroundRefreshPermission(); - if (hasPermission != BackgroundRefreshPermissionState.available) { - return _showNoPermission(context, hasPermission); - } - } - // TODO: Check if already initialized? - Workmanager().initialize( - callbackDispatcher, - isInDebugMode: kDebugMode, - ); -} - -Future _registerBackgroundDHTRefreshTask() async { - await Workmanager().registerPeriodicTask( - dhtRefreshBackgroundTaskName, - dhtRefreshBackgroundTaskName, - initialDelay: const Duration(seconds: 10), - frequency: const Duration(minutes: 15), - ); - // For updates running longer than 30s, choose this alternative for iOS - // if (Platform.isIOS) { - // await Workmanager().registerProcessingTask( - // dhtRefreshBackgroundTaskName, - // dhtRefreshBackgroundTaskName, - // initialDelay: const Duration(seconds: 20), - // ); - // } -} - void main() async { Future mainFunc() async { // Initialize Veilid logging @@ -103,7 +37,6 @@ void main() async { await HydratedStorage.build(storageDirectory: appStorage); // Let's coagulate :) - // Hot reloads should only restart this part, not Veilid runApp(LocalizedApp(localizationDelegate, CoagulateApp(contactsRepositoryPath: appStorage.path))); } diff --git a/lib/ui/app.dart b/lib/ui/app.dart index f8b0ff0..be9e5bd 100644 --- a/lib/ui/app.dart +++ b/lib/ui/app.dart @@ -1,13 +1,17 @@ // Copyright 2024 The Coagulate Authors. All rights reserved. // SPDX-License-Identifier: MPL-2.0 +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:provider/provider.dart'; import 'package:radix_colors/radix_colors.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:workmanager/workmanager.dart'; +import '../data/providers/background.dart'; import '../data/repositories/contacts.dart'; import '../tick.dart'; import '../veilid_init.dart'; @@ -17,6 +21,41 @@ import 'profile/page.dart'; import 'settings/page.dart'; import 'updates/page.dart'; +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask(refreshProfileContactDetails); + Workmanager().executeTask(shareUpdatedProfileToDHT); + // Workmanager().executeTask(refreshContactsFromDHT); +} + +Future _registerDhtRefreshBackgroundTask() async { + await Workmanager().cancelAll(); + await Workmanager().registerPeriodicTask( + refreshProfileContactTaskName, + refreshProfileContactTaskName, + initialDelay: const Duration(seconds: 20), + frequency: const Duration(minutes: 15), + existingWorkPolicy: ExistingWorkPolicy.keep, + ); + await Workmanager().registerPeriodicTask( + shareUpdatedProfileToDhtTaskName, + shareUpdatedProfileToDhtTaskName, + initialDelay: const Duration(seconds: 40), + frequency: const Duration(minutes: 15), + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.keep, + ); + + // For updates running longer than 30s, choose this alternative for iOS + // if (Platform.isIOS) { + // await Workmanager().registerProcessingTask( + // dhtRefreshBackgroundTaskName, + // dhtRefreshBackgroundTaskName, + // initialDelay: const Duration(seconds: 20), + // ); + // } +} + class Splash extends StatefulWidget { const Splash({super.key}); @@ -75,21 +114,26 @@ class CoagulateApp extends StatelessWidget { @override Widget build(BuildContext context) => FutureProvider( initialData: null, - create: (context) async => VeilidChatGlobalInit.initialize(), + create: (context) async { + // TODO: Is this the right place to initialize the workmanager? + await Workmanager() + .initialize(callbackDispatcher, isInDebugMode: kDebugMode); + await _registerDhtRefreshBackgroundTask(); + return VeilidChatGlobalInit.initialize(); + }, builder: (context, child) { final globalInit = context.watch(); + // Splash screen until we're done with init if (globalInit == null) { - // Splash screen until we're done with init - return Splash(); + return const Splash(); } // Once init is done, we proceed with the app final localizationDelegate = LocalizedApp.of(context).delegate; - - // TODO: Add again: return LocalizationProvider( state: LocalizationProvider.of(context).state, child: BackgroundTicker( child: RepositoryProvider.value( + // TODO: Where to async initialize instead? value: ContactsRepository(contactsRepositoryPath), child: MaterialApp( title: 'Coagulate', diff --git a/lib/ui/contact_details/cubit.dart b/lib/ui/contact_details/cubit.dart index 4ca9b25..8f15f48 100644 --- a/lib/ui/contact_details/cubit.dart +++ b/lib/ui/contact_details/cubit.dart @@ -15,24 +15,20 @@ part 'cubit.g.dart'; part 'state.dart'; class ContactDetailsCubit extends HydratedCubit { - ContactDetailsCubit(this.contactsRepository, String coagContactId) - : super( - ContactDetailsState(coagContactId, ContactDetailsStatus.initial)) { - // TODO: Is there an emit.forEach in Cubits like with Blocs? - _contactUpdatesSubscription = - contactsRepository.getUpdateStatus().listen((event) { - if (event.contains(coagContactId)) { - emit(ContactDetailsState(coagContactId, ContactDetailsStatus.success, - contact: contactsRepository.coagContacts[coagContactId])); + ContactDetailsCubit(this.contactsRepository, CoagContact contact) + : super(ContactDetailsState( + contact.coagContactId, ContactDetailsStatus.success, + contact: contact)) { + _contactsSuscription = contactsRepository.getContactUpdates().listen((c) { + if (c.coagContactId == contact.coagContactId) { + emit(ContactDetailsState(c.coagContactId, ContactDetailsStatus.success, + contact: contact)); } }); - - emit(ContactDetailsState(coagContactId, ContactDetailsStatus.success, - contact: contactsRepository.coagContacts[coagContactId])); } final ContactsRepository contactsRepository; - late final StreamSubscription _contactUpdatesSubscription; + late final StreamSubscription _contactsSuscription; @override ContactDetailsState fromJson(Map json) => @@ -58,12 +54,12 @@ class ContactDetailsCubit extends HydratedCubit { void delete(String coagContactId) { // FIXME: This is hacky and should be in the repo - contactsRepository.coagContacts.remove(coagContactId); + // contactsRepository.coagContacts.remove(coagContactId); } @override Future close() { - _contactUpdatesSubscription.cancel(); + _contactsSuscription.cancel(); return super.close(); } } diff --git a/lib/ui/contact_details/cubit.g.dart b/lib/ui/contact_details/cubit.g.dart index f646725..be0007c 100644 --- a/lib/ui/contact_details/cubit.g.dart +++ b/lib/ui/contact_details/cubit.g.dart @@ -13,6 +13,10 @@ ContactDetailsState _$ContactDetailsStateFromJson(Map json) => contact: json['contact'] == null ? null : CoagContact.fromJson(json['contact'] as Map), + sharedProfile: json['shared_profile'] == null + ? null + : CoagContact.fromJson( + json['shared_profile'] as Map), ); Map _$ContactDetailsStateToJson( @@ -21,6 +25,7 @@ Map _$ContactDetailsStateToJson( 'coag_contact_id': instance.coagContactId, 'contact': instance.contact?.toJson(), 'status': _$ContactDetailsStatusEnumMap[instance.status]!, + 'shared_profile': instance.sharedProfile?.toJson(), }; const _$ContactDetailsStatusEnumMap = { diff --git a/lib/ui/contact_details/page.dart b/lib/ui/contact_details/page.dart index 4fa88f6..6c4c782 100644 --- a/lib/ui/contact_details/page.dart +++ b/lib/ui/contact_details/page.dart @@ -74,13 +74,13 @@ Widget _coagulateButton( class ContactPage extends StatelessWidget { const ContactPage({super.key}); - static Route route(String coagContactId) => MaterialPageRoute( + static Route route(CoagContact contact) => MaterialPageRoute( fullscreenDialog: true, builder: (context) => MultiBlocProvider( providers: [ BlocProvider( create: (context) => ContactDetailsCubit( - context.read(), coagContactId)), + context.read(), contact)), BlocProvider( create: (context) => ProfileCubit(context.read())), diff --git a/lib/ui/contact_details/state.dart b/lib/ui/contact_details/state.dart index 72e98f9..1925f16 100644 --- a/lib/ui/contact_details/state.dart +++ b/lib/ui/contact_details/state.dart @@ -21,6 +21,7 @@ final class ContactDetailsState extends Equatable { factory ContactDetailsState.fromJson(Map json) => _$ContactDetailsStateFromJson(json); + // TODO: We only need to contact not also the id, right? final String coagContactId; final CoagContact? contact; final ContactDetailsStatus status; diff --git a/lib/ui/contact_list/cubit.dart b/lib/ui/contact_list/cubit.dart index 8788b28..d0a365d 100644 --- a/lib/ui/contact_list/cubit.dart +++ b/lib/ui/contact_list/cubit.dart @@ -14,43 +14,49 @@ import '../../data/repositories/contacts.dart'; part 'cubit.g.dart'; part 'state.dart'; +// TODO: Refine filtering by only searching through all values (not like now, also the field names) +Iterable _filterAndSort(Iterable contacts, + {String filter = ''}) => + contacts + .where((c) => + (c.details != null && + c.details! + .toString() + .toLowerCase() + .contains(filter.toLowerCase())) || + (c.systemContact != null && + c.systemContact! + .toString() + .toLowerCase() + .contains(filter.toLowerCase()))) + .toList() + ..sort((a, b) => + compareNatural(a.details!.displayName, b.details!.displayName)); + // TODO: Figure out sorting of the contacts class ContactListCubit extends HydratedCubit { ContactListCubit(this.contactsRepository) : super(const ContactListState(ContactListStatus.initial)) { - _contactUpdatesSubscription = - contactsRepository.getUpdateStatus().listen((event) { - // TODO: Is there something smarter than always replacing the full state? + _contactsSuscription = + contactsRepository.getContactUpdates().listen((contact) { if (!isClosed) { - filter(''); + emit(ContactListState(ContactListStatus.success, + contacts: _filterAndSort([ + ...state.contacts + .where((c) => c.coagContactId != contact.coagContactId), + contact + ]))); } }); - - if (!isClosed) { - filter(''); - } + emit(ContactListState(ContactListStatus.success, + contacts: _filterAndSort(contactsRepository.getContacts().values))); } final ContactsRepository contactsRepository; - late final StreamSubscription _contactUpdatesSubscription; + late final StreamSubscription _contactsSuscription; - // TODO: Refine filtering by only searching through all values (not like now, also the field names) void filter(String filter) => emit(ContactListState(ContactListStatus.success, - contacts: contactsRepository.coagContacts.values - .where((c) => - (c.details != null && - c.details! - .toString() - .toLowerCase() - .contains(filter.toLowerCase())) || - (c.systemContact != null && - c.systemContact! - .toString() - .toLowerCase() - .contains(filter.toLowerCase()))) - .toList() - ..sort((a, b) => - compareNatural(a.details!.displayName, b.details!.displayName)))); + contacts: _filterAndSort(state.contacts, filter: filter))); @override ContactListState fromJson(Map json) => @@ -61,7 +67,7 @@ class ContactListCubit extends HydratedCubit { @override Future close() { - _contactUpdatesSubscription.cancel(); + _contactsSuscription.cancel(); return super.close(); } } diff --git a/lib/ui/contact_list/page.dart b/lib/ui/contact_list/page.dart index 4cc55eb..06702b3 100644 --- a/lib/ui/contact_list/page.dart +++ b/lib/ui/contact_list/page.dart @@ -59,8 +59,10 @@ class _ContactListPageState extends State { listener: (context, state) async {}, builder: (context, state) { switch (state.status) { + // TODO: This is barely ever shown, remove case ContactListStatus.initial: return const Center(child: CircularProgressIndicator()); + // TODO: This is never shown; but we want to see it at least when e.g. the contact list is empty case ContactListStatus.denied: return const Center( child: TextButton( @@ -108,8 +110,8 @@ class _ContactListPageState extends State { : avatar(contact.systemContact!, 18), title: Text(contact.details!.displayName), trailing: Text(_contactSyncStatus(contact)), - onTap: () => Navigator.of(context) - .push(ContactPage.route(contact.coagContactId))); + onTap: () => + Navigator.of(context).push(ContactPage.route(contact))); }); } diff --git a/lib/ui/map/cubit.dart b/lib/ui/map/cubit.dart index 773dd93..33194a0 100644 --- a/lib/ui/map/cubit.dart +++ b/lib/ui/map/cubit.dart @@ -2,18 +2,30 @@ // SPDX-License-Identifier: MPL-2.0 import 'dart:async'; +import 'dart:math'; import 'package:equatable/equatable.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../../data/repositories/contacts.dart'; import '../../data/models/coag_contact.dart'; import '../../data/models/contact_location.dart'; +import '../../data/repositories/contacts.dart'; part 'cubit.g.dart'; part 'state.dart'; +// Just for testing purposes while no contacts share their locations +CoagContact _populateWithDummyLocations(CoagContact contact) => + contact.copyWith( + locations: contact.details!.addresses + .map((a) => AddressLocation( + coagContactId: contact.coagContactId, + longitude: Random().nextDouble() / 2 * 50, + latitude: Random().nextDouble() / 2 * 50, + name: a.label.name)) + .toList()); + Iterable _contactToLocations(CoagContact contact) => contact.locations.whereType().map((cl) => Location( coagContactId: contact.coagContactId, @@ -28,28 +40,20 @@ Iterable _contactToLocations(CoagContact contact) => class MapCubit extends HydratedCubit { MapCubit(this.contactsRepository) : super(const MapState({}, MapStatus.initial)) { - _contactUpdatesSubscription = - contactsRepository.getUpdateStatus().listen((event) { - // TODO: Is there something smarter than always replacing the full state? - emit(MapState( - contactsRepository.coagContacts.values - .expand(_contactToLocations) - .toList(), - MapStatus.success, + _contactsSuscription = + contactsRepository.getContactUpdates().listen((contact) { + emit(MapState([ + ...state.locations + .where((l) => l.coagContactId != contact.coagContactId), + ..._contactToLocations(contact) + ], MapStatus.success, mapboxApiToken: String.fromEnvironment('COAGULATE_MAPBOX_PUBLIC_TOKEN'))); }); - emit(MapState( - contactsRepository.coagContacts.values - .expand(_contactToLocations) - .toList(), - MapStatus.success, - mapboxApiToken: - String.fromEnvironment('COAGULATE_MAPBOX_PUBLIC_TOKEN'))); } final ContactsRepository contactsRepository; - late final StreamSubscription _contactUpdatesSubscription; + late final StreamSubscription _contactsSuscription; @override MapState fromJson(Map json) => MapState.fromJson(json); @@ -59,7 +63,7 @@ class MapCubit extends HydratedCubit { @override Future close() { - _contactUpdatesSubscription.cancel(); + _contactsSuscription.cancel(); return super.close(); } } diff --git a/lib/ui/profile/cubit.dart b/lib/ui/profile/cubit.dart index 44a495a..00e1281 100644 --- a/lib/ui/profile/cubit.dart +++ b/lib/ui/profile/cubit.dart @@ -1,24 +1,35 @@ // Copyright 2024 The Coagulate Authors. All rights reserved. // SPDX-License-Identifier: MPL-2.0 +import 'dart:async'; + import 'package:equatable/equatable.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:geocoding/geocoding.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../../data/models/coag_contact.dart'; import '../../data/repositories/contacts.dart'; part 'cubit.g.dart'; part 'state.dart'; -// TODO: Add contact refresh as listener via -// FlutterContacts.addListener(() => print('Contact DB changed')); - class ProfileCubit extends HydratedCubit { - ProfileCubit(this.contactsRepository) : super(ProfileState()); + ProfileCubit(this.contactsRepository) : super(const ProfileState()) { + _contactsSuscription = + contactsRepository.getContactUpdates().listen((contact) { + if (state.profileContact != null && + contact.systemContact?.id == state.profileContact!.id) { + emit(ProfileState( + status: ProfileStatus.success, + profileContact: contact.systemContact)); + } + }); + } final ContactsRepository contactsRepository; + late final StreamSubscription _contactsSuscription; void promptCreate() { emit(state.copyWith(status: ProfileStatus.create)); @@ -28,22 +39,21 @@ class ProfileCubit extends HydratedCubit { emit(state.copyWith(status: ProfileStatus.pick)); } - void setContact(Contact? contact) { + Future setContact(String? systemContactId) async { + final contact = (systemContactId == null) + ? null + : await FlutterContacts.getContact(systemContactId); + emit(state.copyWith( status: (contact == null) ? ProfileStatus.initial : ProfileStatus.success, profileContact: contact)); - } - Future updateContact() async { - if (state.profileContact != null) { - final contact = - await FlutterContacts.getContact(state.profileContact!.id); - setContact(contact); + if (contact != null) { // TODO: add more details, locations etc. await contactsRepository.updateProfileContact( // TODO: Switch to full blown CoagContact for profile contact and get rid of this hack - contactsRepository.getCoagContactIdForSystemContactId(contact!.id)!); + contactsRepository.getCoagContactIdForSystemContactId(contact.id)!); } } @@ -92,4 +102,10 @@ class ProfileCubit extends HydratedCubit { updatedLocCoords[name] = (lng, lat); emit(state.copyWith(locationCoordinates: updatedLocCoords)); } + + @override + Future close() { + _contactsSuscription.cancel(); + return super.close(); + } } diff --git a/lib/ui/profile/page.dart b/lib/ui/profile/page.dart index ac9499c..25fa163 100644 --- a/lib/ui/profile/page.dart +++ b/lib/ui/profile/page.dart @@ -140,7 +140,8 @@ class ProfileView extends StatefulWidget { Widget buildProfileScrollView(BuildContext context, Contact contact, Map? locationCoordinates) => RefreshIndicator( - onRefresh: context.read().updateContact, + onRefresh: () async => + context.read().setContact(contact.id), child: CustomScrollView(slivers: [ SliverFillRemaining( hasScrollBody: false, @@ -176,9 +177,8 @@ class ProfileViewState extends State { ), IconButton( icon: const Icon(Icons.replay_outlined), - onPressed: () { - context.read().setContact(null); - }, + onPressed: () async => + context.read().setContact(null), ), ], ), @@ -186,9 +186,9 @@ class ProfileViewState extends State { listener: (context, state) async { if (state.status.isPick) { if (await FlutterContacts.requestPermission()) { - context + await context .read() - .setContact(await FlutterContacts.openExternalPick()); + .setContact((await FlutterContacts.openExternalPick())?.id); } else { // TODO: Trigger hint about missing permission return; @@ -196,9 +196,8 @@ class ProfileViewState extends State { } else if (state.status.isCreate) { if (await FlutterContacts.requestPermission()) { // TODO: This doesn't seem to return the contact after creation - context - .read() - .setContact(await FlutterContacts.openExternalInsert()); + await context.read().setContact( + (await FlutterContacts.openExternalInsert())?.id); } else { // TODO: Trigger hint about missing permission return; diff --git a/lib/ui/receive_request/page.dart b/lib/ui/receive_request/page.dart index 77aa9a2..b2dd2fb 100644 --- a/lib/ui/receive_request/page.dart +++ b/lib/ui/receive_request/page.dart @@ -44,8 +44,7 @@ class ReceiveRequestPage extends StatelessWidget { case ReceiveRequestStatus.qrcode: if (state.profile != null) { Navigator.of(context).pop(); - Navigator.of(context) - .push(ContactPage.route(state.profile!.coagContactId)); + Navigator.of(context).push(ContactPage.route(state.profile!)); } return Scaffold( appBar: AppBar(title: const Text('Scan QR Code')), diff --git a/lib/ui/settings/cubit.dart b/lib/ui/settings/cubit.dart new file mode 100644 index 0000000..763c72a --- /dev/null +++ b/lib/ui/settings/cubit.dart @@ -0,0 +1,28 @@ +// Copyright 2024 The Coagulate Authors. All rights reserved. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:equatable/equatable.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'cubit.g.dart'; +part 'state.dart'; + +class SettingsCubit extends HydratedCubit { + SettingsCubit(super.state); + + @override + SettingsState fromJson(Map json) => + SettingsState.fromJson(json); + + @override + Map toJson(SettingsState state) => state.toJson(); + + Future updateMessage() async { + final _sharedPreference = await SharedPreferences.getInstance(); + final bgLog = _sharedPreference.getString('bgLog'); + emit(SettingsState( + status: SettingsStatus.success, message: (bgLog == null) ? '' : bgLog)); + } +} diff --git a/lib/ui/settings/cubit.g.dart b/lib/ui/settings/cubit.g.dart new file mode 100644 index 0000000..22ebb87 --- /dev/null +++ b/lib/ui/settings/cubit.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cubit.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SettingsState _$SettingsStateFromJson(Map json) => + SettingsState( + status: $enumDecode(_$SettingsStatusEnumMap, json['status']), + message: json['message'] as String, + ); + +Map _$SettingsStateToJson(SettingsState instance) => + { + 'status': _$SettingsStatusEnumMap[instance.status]!, + 'message': instance.message, + }; + +const _$SettingsStatusEnumMap = { + SettingsStatus.initial: 'initial', + SettingsStatus.success: 'success', + SettingsStatus.create: 'create', + SettingsStatus.pick: 'pick', +}; diff --git a/lib/ui/settings/page.dart b/lib/ui/settings/page.dart index 5d5b891..9c7fe48 100644 --- a/lib/ui/settings/page.dart +++ b/lib/ui/settings/page.dart @@ -2,10 +2,23 @@ // SPDX-License-Identifier: MPL-2.0 import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:workmanager/workmanager.dart'; import '../../veilid_processor/views/signal_strength_meter.dart'; +import 'cubit.dart'; import 'licenses/page.dart'; +// TODO: Move to cubit? +Future _backgroundPermissionStatus() async { + final hasPermission = await Workmanager().checkBackgroundRefreshPermission(); + if (hasPermission != BackgroundRefreshPermissionState.available) { + return Text('Background app refresh is disabled, please enable in ' + 'App settings. Status ${hasPermission.name}'); + } + return const Text('Background app refresh is enabled :)'); +} + class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -14,21 +27,35 @@ class SettingsPage extends StatelessWidget { appBar: AppBar( title: const Text('Settings'), ), - body: Container( - padding: const EdgeInsets.only(left: 20, right: 20), - child: - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('Network Status:'), - const SizedBox(width: 10), - SignalStrengthMeterWidget() - ]), - // TODO: Add dark mode switch - // TODO: Add map provider choice - // TODO: Add custom bootstrap servers choice - TextButton( - onPressed: () => - Navigator.of(context).push(LicensesPage.route()), - child: const Text('Show Open Source Licenses')) - ]))); + body: BlocProvider( + create: (context) => SettingsCubit( + const SettingsState(status: SettingsStatus.initial, message: '')), + child: BlocConsumer( + listener: (context, state) => {}, + builder: (context, state) => Container( + padding: const EdgeInsets.only(left: 20, right: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(state.message), + TextButton( + child: Text('UPDATE'), + onPressed: + context.read().updateMessage, + ), + Row(children: [ + const Text('Network Status:'), + const SizedBox(width: 10), + SignalStrengthMeterWidget() + ]), + // TODO: Move async things to cubit + // if (Platform.isIOS) _backgroundPermissionStatus(), + // TODO: Add dark mode switch + // TODO: Add map provider choice + // TODO: Add custom bootstrap servers choice + TextButton( + onPressed: () => Navigator.of(context) + .push(LicensesPage.route()), + child: const Text('Show Open Source Licenses')) + ]))))); } diff --git a/lib/ui/settings/state.dart b/lib/ui/settings/state.dart new file mode 100644 index 0000000..4bb4e51 --- /dev/null +++ b/lib/ui/settings/state.dart @@ -0,0 +1,29 @@ +// Copyright 2024 The Coagulate Authors. All rights reserved. +// SPDX-License-Identifier: MPL-2.0 + +part of 'cubit.dart'; + +enum SettingsStatus { initial, success, create, pick } + +extension SettingsStatusX on SettingsStatus { + bool get isInitial => this == SettingsStatus.initial; + bool get isSuccess => this == SettingsStatus.success; + bool get isCreate => this == SettingsStatus.create; + bool get isPick => this == SettingsStatus.pick; +} + +@JsonSerializable() +final class SettingsState extends Equatable { + const SettingsState({required this.status, required this.message}); + + factory SettingsState.fromJson(Map json) => + _$SettingsStateFromJson(json); + + final SettingsStatus status; + final String message; + + Map toJson() => _$SettingsStateToJson(this); + + @override + List get props => [status, message]; +} diff --git a/lib/ui/updates/cubit.dart b/lib/ui/updates/cubit.dart index 97441dd..57acdbb 100644 --- a/lib/ui/updates/cubit.dart +++ b/lib/ui/updates/cubit.dart @@ -7,6 +7,7 @@ import 'package:equatable/equatable.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../../data/models/coag_contact.dart'; import '../../data/models/contact_update.dart'; import '../../data/repositories/contacts.dart'; @@ -16,13 +17,15 @@ part 'cubit.g.dart'; class UpdatesCubit extends HydratedCubit { UpdatesCubit(this.contactsRepository) : super(const UpdatesState(UpdatesStatus.initial)) { - _contactUpdatesSubscription = contactsRepository.getUpdateStatus().listen( - (event) => emit(UpdatesState(UpdatesStatus.success, + _contactsSuscription = contactsRepository.getContactUpdates().listen( + (contact) => emit(UpdatesState(UpdatesStatus.success, updates: contactsRepository.updates.reversed))); + emit(UpdatesState(UpdatesStatus.success, + updates: contactsRepository.updates.reversed)); } final ContactsRepository contactsRepository; - late final StreamSubscription _contactUpdatesSubscription; + late final StreamSubscription _contactsSuscription; Future refresh() => contactsRepository.updateAndWatchReceivingDHT(); @@ -35,7 +38,7 @@ class UpdatesCubit extends HydratedCubit { @override Future close() { - _contactUpdatesSubscription.cancel(); + _contactsSuscription.cancel(); return super.close(); } } diff --git a/pubspec.lock b/pubspec.lock index d6e59a1..cf1c4c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -223,6 +223,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: ebe15d94de9dd7c31dc2ac54e42780acdf3384b1497c69290c9f3c5b0279fc57 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb + url: "https://pub.dev" + source: hosted + version: "2.0.0" convert: dependency: transitive description: @@ -263,6 +279,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.4" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" equatable: dependency: "direct main" description: @@ -709,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" node_preamble: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 632af10..a2f95a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: path: packages/bloc_tools charcode: ^1.3.1 collection: ^1.18.0 + connectivity_plus: ^6.0.2 equatable: ^2.0.5 fast_immutable_collections: ^10.1.2 flutter: @@ -54,7 +55,7 @@ dependencies: path: ../veilid/veilid-flutter veilid_support: path: packages/veilid_support - # TODO: Replace with 0.5.3 + # TODO: Replace with 0.5.3; 0.5.2 might also be enough if new iOS features aren't required workmanager: git: url: https://github.com/fluttercommunity/flutter_workmanager.git