From 93d08dcf82baf38c16c0bad922b4bc50226f8933 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Wed, 14 Dec 2022 22:27:30 -0500 Subject: [PATCH] Add initial contacts screen --- lib/controls/app_bottom_nav_bar.dart | 2 +- lib/friendica_client.dart | 55 +++++++++++++--- lib/routes.dart | 10 +++ lib/screens/contacts_screen.dart | 53 +++++++++++++++ lib/services/connections_manager.dart | 92 ++++++++++++++++++++++++++- 5 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 lib/screens/contacts_screen.dart diff --git a/lib/controls/app_bottom_nav_bar.dart b/lib/controls/app_bottom_nav_bar.dart index 799474e..9d51e59 100644 --- a/lib/controls/app_bottom_nav_bar.dart +++ b/lib/controls/app_bottom_nav_bar.dart @@ -44,7 +44,7 @@ class AppBottomNavBar extends StatelessWidget { // TODO: Handle this case. break; case NavBarButtons.contacts: - // TODO: Handle this case. + context.pushNamed(ScreenPaths.contacts); break; case NavBarButtons.profile: context.pushNamed(ScreenPaths.profile); diff --git a/lib/friendica_client.dart b/lib/friendica_client.dart index 1d88a61..489629d 100644 --- a/lib/friendica_client.dart +++ b/lib/friendica_client.dart @@ -13,6 +13,7 @@ import 'models/group_data.dart'; import 'models/timeline_entry.dart'; import 'models/user_notification.dart'; import 'serializers/friendica/connection_friendica_extensions.dart'; +import 'serializers/mastodon/connection_mastodon_extensions.dart'; import 'serializers/mastodon/group_data_mastodon_extensions.dart'; import 'serializers/mastodon/notification_mastodon_extension.dart'; import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart'; @@ -109,6 +110,36 @@ class FriendicaClient { return (await _deleteUrl(request, requestData)).mapValue((_) => true); } + FutureResult, ExecError> getMyFollowing( + {int sinceId = -1, int maxId = -1, int limit = 50}) async { + _logger.finest(() => + 'Getting following data since $sinceId, maxId $maxId, limit $limit'); + final myId = getIt().currentId; + final paging = + _buildPagingData(sinceId: sinceId, maxId: maxId, limit: limit); + final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; + return (await _getApiListRequest(Uri.parse('$baseUrl/following&$paging')) + .andThenSuccessAsync((listJson) async => listJson + .map((json) => ConnectionMastodonExtensions.fromJson(json)) + .toList())) + .execErrorCast(); + } + + FutureResult, ExecError> getMyFollowers( + {int sinceId = -1, int maxId = -1, int limit = 50}) async { + _logger.finest(() => + 'Getting followers data since $sinceId, maxId $maxId, limit $limit'); + final myId = getIt().currentId; + final paging = + _buildPagingData(sinceId: sinceId, maxId: maxId, limit: limit); + final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; + return (await _getApiListRequest(Uri.parse('$baseUrl/followers&$paging')) + .andThenSuccessAsync((listJson) async => listJson + .map((json) => ConnectionMastodonExtensions.fromJson(json)) + .toList())) + .execErrorCast(); + } + FutureResult getConnectionWithStatus( Connection connection) async { _logger.finest(() => 'Getting group (Mastodon List) data'); @@ -179,14 +210,8 @@ class FriendicaClient { final String timelinePath = _typeToTimelinePath(type); final String timelineQPs = _typeToTimelineQueryParameters(type); final baseUrl = 'https://$serverName/api/v1/$timelinePath'; - var pagingData = 'limit=$limit'; - if (maxId > 0) { - pagingData = '$pagingData&max_id=$maxId'; - } - - if (sinceId > 0) { - pagingData = '&since_id=$sinceId'; - } + final pagingData = + _buildPagingData(sinceId: sinceId, maxId: maxId, limit: limit); final url = '$baseUrl?exclude_replies=true&$pagingData&$timelineQPs'; final request = Uri.parse(url); @@ -464,4 +489,18 @@ class FriendicaClient { throw UnimplementedError('These types are not supported yet'); } } + + String _buildPagingData( + {required int sinceId, required int maxId, required int limit}) { + var pagingData = 'limit=$limit'; + if (maxId > 0) { + pagingData = '$pagingData&max_id=$maxId'; + } + + if (sinceId > 0) { + pagingData = '&since_id=$sinceId'; + } + + return pagingData; + } } diff --git a/lib/routes.dart b/lib/routes.dart index 9cd1680..a477834 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,6 +1,7 @@ import 'package:go_router/go_router.dart'; import 'globals.dart'; +import 'screens/contacts_screen.dart'; import 'screens/editor.dart'; import 'screens/home.dart'; import 'screens/notifications_screen.dart'; @@ -13,6 +14,7 @@ import 'screens/user_profile_screen.dart'; import 'services/auth_service.dart'; class ScreenPaths { + static String contacts = '/contacts'; static String splash = '/splash'; static String timelines = '/'; static String profile = '/profile'; @@ -58,6 +60,14 @@ final appRouter = GoRouter( name: ScreenPaths.signin, builder: (context, state) => SignInScreen(), ), + GoRoute( + path: ScreenPaths.contacts, + name: ScreenPaths.contacts, + pageBuilder: (context, state) => NoTransitionPage( + name: ScreenPaths.contacts, + child: ContactsScreen(), + ), + ), GoRoute( path: ScreenPaths.timelines, name: ScreenPaths.timelines, diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart new file mode 100644 index 0000000..ebecdbc --- /dev/null +++ b/lib/screens/contacts_screen.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../controls/app_bottom_nav_bar.dart'; +import '../models/connection.dart'; +import '../routes.dart'; +import '../services/connections_manager.dart'; + +class ContactsScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final contacts = manager.getMyContacts(); + contacts.sort((c1, c2) => c1.name.compareTo(c2.name)); + late Widget body; + if (contacts.isEmpty) { + body = const SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Text('No Contacts'), + ); + } else { + body = ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemBuilder: (context, index) { + final contact = contacts[index]; + return ListTile( + onTap: () { + context.pushNamed(ScreenPaths.userProfile, + params: {'id': contact.id}); + }, + title: Text(contact.name), + trailing: Text(contact.status.label()), + ); + }, + separatorBuilder: (context, index) => const Divider(), + itemCount: contacts.length); + } + return Scaffold( + body: RefreshIndicator( + onRefresh: () async { + await manager.updateAllContacts(); + }, + child: Center( + child: body, + ), + ), + bottomNavigationBar: AppBottomNavBar( + currentButton: NavBarButtons.contacts, + ), + ); + } +} diff --git a/lib/services/connections_manager.dart b/lib/services/connections_manager.dart index 69f178d..1c5bcdb 100644 --- a/lib/services/connections_manager.dart +++ b/lib/services/connections_manager.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; @@ -15,6 +17,8 @@ class ConnectionsManager extends ChangeNotifier { final _connectionsByProfileUrl = {}; final _groupsForConnection = >{}; final _myGroups = {}; + final _myContacts = []; + var _myContactsInitialized = false; int get length => _connectionsById.length; @@ -36,6 +40,23 @@ class ConnectionsManager extends ChangeNotifier { _connectionsById[connection.id] = connection; _connectionsByName[connection.name] = connection; _connectionsByProfileUrl[connection.profileUrl] = connection; + int index = _myContacts.indexWhere((c) => c.id == connection.id); + if (index >= 0) { + _myContacts.removeAt(index); + } + switch (connection.status) { + case ConnectionStatus.youFollowThem: + case ConnectionStatus.theyFollowYou: + case ConnectionStatus.mutual: + if (index > 0) { + _myContacts.insert(index, connection); + } else { + _myContacts.add(connection); + } + break; + default: + break; + } return true; } @@ -88,6 +109,73 @@ class ConnectionsManager extends ChangeNotifier { ); } + List getMyContacts() { + if (!_myContactsInitialized) { + updateAllContacts(); + _myContactsInitialized = true; + } + + return _myContacts.toList(growable: false); + } + + Future updateAllContacts() async { + _logger.fine('Updating all contacts'); + final clientResult = getIt().currentClient; + if (clientResult.isFailure) { + _logger.severe( + 'Unable to update contacts due to client error: ${clientResult.error}'); + return; + } + final client = clientResult.value; + final results = {}; + var moreResults = true; + var maxId = -1; + const limit = 100; + while (moreResults) { + await client.getMyFollowers(sinceId: maxId, limit: limit).match( + onSuccess: (followers) { + if (followers.length < limit) { + moreResults = false; + for (final f in followers) { + results[f.id] = f.copy(status: ConnectionStatus.theyFollowYou); + int id = int.parse(f.id); + maxId = max(maxId, id); + } + } + }, onError: (error) { + _logger.severe('Error getting followers data: $error'); + }); + } + + moreResults = true; + maxId = -1; + while (moreResults) { + await client.getMyFollowing(sinceId: maxId, limit: limit).match( + onSuccess: (following) { + if (following.length < limit) { + moreResults = false; + for (final f in following) { + if (results.containsKey(f.id)) { + results[f.id] = f.copy(status: ConnectionStatus.mutual); + } else { + results[f.id] = f.copy(status: ConnectionStatus.youFollowThem); + } + int id = int.parse(f.id); + maxId = max(maxId, id); + } + } + }, onError: (error) { + _logger.severe('Error getting followers data: $error'); + }); + } + + _myContacts.clear(); + _myContacts.addAll(results.values); + addAllConnections(results.values); + _myContacts.sort((c1, c2) => c1.name.compareTo(c2.name)); + notifyListeners(); + } + List getMyGroups() { if (_myGroups.isNotEmpty) { return _myGroups.toList(growable: false); @@ -140,9 +228,9 @@ class ConnectionsManager extends ChangeNotifier { Result getById(String id) { final result = _connectionsById[id]; if (result == null) { - Result.error('$id not found'); + return Result.error('$id not found'); } - if (result!.status == ConnectionStatus.unknown) { + if (result.status == ConnectionStatus.unknown) { _refreshConnection(result, true); } return Result.ok(result);