diff --git a/lib/controls/notifications_control.dart b/lib/controls/notifications_control.dart index 49b1e56..cf62e26 100644 --- a/lib/controls/notifications_control.dart +++ b/lib/controls/notifications_control.dart @@ -54,12 +54,10 @@ class NotificationControl extends StatelessWidget { onTap: () async { switch (notification.type) { case NotificationType.follow: - buildSnackbar( - context, 'Want to follow ${notification.fromName}?'); + context.push('/connect/${notification.fromId}'); break; case NotificationType.follow_request: - buildSnackbar( - context, 'Want to accept follow ${notification.fromName}?'); + context.push('/connect/${notification.fromId}'); break; case NotificationType.unknown: buildSnackbar(context, 'Unknown message type, nothing to do'); diff --git a/lib/friendica_client.dart b/lib/friendica_client.dart index 47d26fc..b1d0ac8 100644 --- a/lib/friendica_client.dart +++ b/lib/friendica_client.dart @@ -336,15 +336,55 @@ class FriendicaClient { }); } + FutureResult acceptFollow( + Connection connection) async { + final id = connection.id; + final url = + Uri.parse('https://$serverName/api/v1/follow_requests/$id/authorize'); + final result = + await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { + return _updateConnectionFromFollowRequestResult(connection, jsonString); + }); + return result.mapError((error) => error is ExecError + ? error + : ExecError(type: ErrorType.localError, message: error.toString())); + } + + FutureResult rejectFollow( + Connection connection) async { + final id = connection.id; + final url = + Uri.parse('https://$serverName/api/v1/follow_requests/$id/reject'); + final result = + await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { + return _updateConnectionFromFollowRequestResult(connection, jsonString); + }); + return result.mapError((error) => error is ExecError + ? error + : ExecError(type: ErrorType.localError, message: error.toString())); + } + + FutureResult ignoreFollow( + Connection connection) async { + final id = connection.id; + final url = + Uri.parse('https://$serverName/api/v1/follow_requests/$id/ignore'); + final result = + await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { + return _updateConnectionFromFollowRequestResult(connection, jsonString); + }); + return result.mapError((error) => error is ExecError + ? error + : ExecError(type: ErrorType.localError, message: error.toString())); + } + FutureResult followConnection( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/follow'); - final result = await _postUrl(url, {}).andThenSuccessAsync((_) async { - final newStatus = connection.status == ConnectionStatus.theyFollowYou - ? ConnectionStatus.mutual - : ConnectionStatus.youFollowThem; - return connection.copy(status: newStatus); + final result = + await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { + return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error @@ -355,11 +395,9 @@ class FriendicaClient { Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unfollow'); - final result = await _postUrl(url, {}).andThenSuccessAsync((_) async { - final newStatus = connection.status == ConnectionStatus.mutual - ? ConnectionStatus.theyFollowYou - : ConnectionStatus.none; - return connection.copy(status: newStatus); + final result = + await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { + return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error @@ -503,4 +541,22 @@ class FriendicaClient { return pagingData; } + + Connection _updateConnectionFromFollowRequestResult( + Connection connection, String jsonString) { + final json = jsonDecode(jsonString) as Map; + final theyFollowYou = json['followed_by'] ?? 'false'; + final youFollowThem = json['following'] ?? 'false'; + late final ConnectionStatus newStatus; + if (theyFollowYou && youFollowThem) { + newStatus = ConnectionStatus.mutual; + } else if (theyFollowYou) { + newStatus = ConnectionStatus.theyFollowYou; + } else if (youFollowThem) { + newStatus = ConnectionStatus.youFollowThem; + } else { + newStatus = ConnectionStatus.none; + } + return connection.copy(status: newStatus); + } } diff --git a/lib/routes.dart b/lib/routes.dart index a477834..7271e40 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'globals.dart'; import 'screens/contacts_screen.dart'; import 'screens/editor.dart'; +import 'screens/follow_request_adjudication_screen.dart'; import 'screens/home.dart'; import 'screens/notifications_screen.dart'; import 'screens/post_screen.dart'; @@ -14,6 +15,7 @@ import 'screens/user_profile_screen.dart'; import 'services/auth_service.dart'; class ScreenPaths { + static String connectHandle = '/connect'; static String contacts = '/contacts'; static String splash = '/splash'; static String timelines = '/'; @@ -68,6 +70,12 @@ final appRouter = GoRouter( child: ContactsScreen(), ), ), + GoRoute( + path: '/connect/:id', + name: ScreenPaths.connectHandle, + builder: (context, state) => + FollowRequestAdjudicationScreen(userId: state.params['id']!), + ), GoRoute( path: ScreenPaths.timelines, name: ScreenPaths.timelines, diff --git a/lib/screens/follow_request_adjudication_screen.dart b/lib/screens/follow_request_adjudication_screen.dart index 24e10d7..ee7d8a5 100644 --- a/lib/screens/follow_request_adjudication_screen.dart +++ b/lib/screens/follow_request_adjudication_screen.dart @@ -1,16 +1,164 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../controls/padding.dart'; +import '../models/connection.dart'; +import '../services/connections_manager.dart'; + +class FollowRequestAdjudicationScreen extends StatefulWidget { + final String userId; + + const FollowRequestAdjudicationScreen({super.key, required this.userId}); + + @override + State createState() => + _FollowRequestAdjudicationScreenState(); +} + +class _FollowRequestAdjudicationScreenState + extends State { + var processing = false; -class FollowRequestAdjudicationScreen extends StatelessWidget { @override Widget build(BuildContext context) { - // with ID, get contact + final manager = context.watch(); + final connResult = manager.getById(widget.userId); + late final Widget body; + if (connResult.isFailure) { + body = Text('Error getting contact information: ${connResult.error}'); + } + + final contact = connResult.value; + switch (contact.status) { + case ConnectionStatus.mutual: + case ConnectionStatus.theyFollowYou: + case ConnectionStatus.youFollowThem: + case ConnectionStatus.none: + body = _buildMainPanel(context, manager, contact); + break; + case ConnectionStatus.you: + case ConnectionStatus.unknown: + body = Text('Invalid state, nothing to do here: ${contact.status}'); + break; + } + + return Scaffold( + appBar: AppBar( + title: const Text( + 'Accept Request?', + )), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Center(child: body), + ), + ); + } + + Widget _buildMainPanel( + BuildContext context, ConnectionsManager manager, Connection contact) { // Options are: // Accept and follow back // Accept and don't follow back // Reject // Back with no action // Calling method should check if completed (true) or not (false) to decide if updating their view of that item - // TODO: implement build - throw UnimplementedError(); + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + CachedNetworkImage(imageUrl: contact.avatarUrl.toString()), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + contact.name, + style: Theme.of(context).textTheme.titleLarge, + ), + const HorizontalPadding(), + ], + ), + const VerticalPadding(), + ElevatedButton( + onPressed: processing + ? null + : () async => await accept(manager, contact, true), + child: const Text('Accept and follow back'), + ), + const VerticalPadding(), + ElevatedButton( + onPressed: + processing ? null : () async => accept(manager, contact, false), + child: const Text("Accept but don't follow back"), + ), + const VerticalPadding(), + ElevatedButton( + onPressed: processing ? null : () async => reject(manager, contact), + child: const Text('Reject'), + ), + const VerticalPadding(), + ElevatedButton( + onPressed: processing ? null : () async => ignore(manager, contact), + child: const Text('Ignore (Rejects but user cannot ask again)'), + ), + ], + ); + } + + Future accept( + ConnectionsManager manager, + Connection contact, + bool followBack, + ) async { + setState(() { + processing = true; + }); + + await manager.acceptFollowRequest(contact); + if (followBack) { + await manager.follow(contact); + } + + setState(() { + processing = false; + }); + + if (mounted && context.canPop()) { + context.pop(); + } + } + + Future reject(ConnectionsManager manager, Connection contact) async { + setState(() { + processing = true; + }); + + await manager.rejectFollowRequest(contact); + + setState(() { + processing = false; + }); + + if (mounted && context.canPop()) { + context.pop(); + } + } + + Future ignore(ConnectionsManager manager, Connection contact) async { + setState(() { + processing = true; + }); + + await manager.ignoreFollowRequest(contact); + + setState(() { + processing = false; + }); + + if (mounted && context.canPop()) { + context.pop(); + } } } diff --git a/lib/services/connections_manager.dart b/lib/services/connections_manager.dart index 1c5bcdb..eaf9542 100644 --- a/lib/services/connections_manager.dart +++ b/lib/services/connections_manager.dart @@ -27,6 +27,8 @@ class ConnectionsManager extends ChangeNotifier { _connectionsByName.clear(); _connectionsByProfileUrl.clear(); _groupsForConnection.clear(); + _myGroups.clear(); + _myContacts.clear(); } bool addConnection(Connection connection) { @@ -71,6 +73,63 @@ class ConnectionsManager extends ChangeNotifier { return result; } + Future acceptFollowRequest(Connection connection) async { + _logger.finest( + 'Attempting to accept follow request ${connection.name}: ${connection.status}'); + await getIt() + .currentClient + .andThenAsync((client) => client.acceptFollow(connection)) + .match( + onSuccess: (update) { + _logger + .finest('Successfully followed ${update.name}: ${update.status}'); + updateConnection(update); + notifyListeners(); + }, + onError: (error) { + _logger.severe('Error following ${connection.name}'); + }, + ); + } + + Future rejectFollowRequest(Connection connection) async { + _logger.finest( + 'Attempting to accept follow request ${connection.name}: ${connection.status}'); + await getIt() + .currentClient + .andThenAsync((client) => client.rejectFollow(connection)) + .match( + onSuccess: (update) { + _logger + .finest('Successfully followed ${update.name}: ${update.status}'); + updateConnection(update); + notifyListeners(); + }, + onError: (error) { + _logger.severe('Error following ${connection.name}'); + }, + ); + } + + Future ignoreFollowRequest(Connection connection) async { + _logger.finest( + 'Attempting to accept follow request ${connection.name}: ${connection.status}'); + await getIt() + .currentClient + .andThenAsync((client) => client.ignoreFollow(connection)) + .match( + onSuccess: (update) { + _logger + .finest('Successfully followed ${update.name}: ${update.status}'); + updateConnection(update); + notifyListeners(); + }, + onError: (error) { + _logger.severe('Error following ${connection.name}'); + }, + ); + } + Future follow(Connection connection) async { _logger.finest( 'Attempting to follow ${connection.name}: ${connection.status}');