kopia lustrzana https://gitlab.com/mysocialportal/relatica
Merge contacts-update branch into image-flows branch
commit
158c0c872e
|
@ -1,6 +1,6 @@
|
|||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- flutter_secure_storage (3.3.1):
|
||||
- Flutter
|
||||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
|
@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
|||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
|
|
|
@ -45,7 +45,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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -55,14 +55,16 @@ class _StatusControlState extends State<StatusControl> {
|
|||
@override
|
||||
void initState() {
|
||||
showContent = entry.spoilerText.isEmpty;
|
||||
showComments = isPost ? false : true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final manager = context.watch<TimelineManager>();
|
||||
_logger.finest('Building ${item.entry.toShortString()}');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
final padding = isPost ? 8.0 : 8.0;
|
||||
final body = Padding(
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
@ -119,6 +121,10 @@ class _StatusControlState extends State<StatusControl> {
|
|||
],
|
||||
),
|
||||
);
|
||||
|
||||
return isPost
|
||||
? body
|
||||
: Card(color: Theme.of(context).splashColor, child: body);
|
||||
}
|
||||
|
||||
Widget buildHeader(BuildContext context) {
|
||||
|
@ -184,7 +190,6 @@ class _StatusControlState extends State<StatusControl> {
|
|||
return const SizedBox();
|
||||
}
|
||||
return SizedBox(
|
||||
width: 250.0,
|
||||
height: 250.0,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
@ -235,16 +240,16 @@ class _StatusControlState extends State<StatusControl> {
|
|||
|
||||
Widget buildChildComments(BuildContext context) {
|
||||
final comments = widget.originalItem.children;
|
||||
|
||||
if (comments.isEmpty) {
|
||||
return Text('No comments');
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: 20.0, top: 5.0),
|
||||
padding: EdgeInsets.only(left: 5.0, top: 5.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.subdirectory_arrow_right),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: comments
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:result_monad/result_monad.dart';
|
||||
|
||||
import 'globals.dart';
|
||||
import 'models/TimelineIdentifiers.dart';
|
||||
import 'models/connection.dart';
|
||||
import 'models/credentials.dart';
|
||||
|
@ -16,9 +17,11 @@ import 'models/user_notification.dart';
|
|||
import 'serializers/friendica/connection_friendica_extensions.dart';
|
||||
import 'serializers/friendica/gallery_data_friendica_extensions.dart';
|
||||
import 'serializers/friendica/image_entry_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';
|
||||
import 'services/auth_service.dart';
|
||||
|
||||
class FriendicaClient {
|
||||
static final _logger = Logger('$FriendicaClient');
|
||||
|
@ -113,6 +116,95 @@ class FriendicaClient {
|
|||
: ExecError(type: ErrorType.localError, message: error.toString()));
|
||||
}
|
||||
|
||||
FutureResult<bool, ExecError> addConnectionToGroup(
|
||||
GroupData group,
|
||||
Connection connection,
|
||||
) async {
|
||||
_logger.finest(() => 'Adding connection to group');
|
||||
final url = 'https://$serverName/api/v1/lists/${group.id}/accounts';
|
||||
final request = Uri.parse(url);
|
||||
final requestData = {
|
||||
'account_ids': [connection.id]
|
||||
};
|
||||
return (await _postUrl(request, requestData)).mapValue((_) => true);
|
||||
}
|
||||
|
||||
FutureResult<bool, ExecError> removeConnectionFromGroup(
|
||||
GroupData group,
|
||||
Connection connection,
|
||||
) async {
|
||||
_logger.finest(() => 'Adding connection to group');
|
||||
final url = 'https://$serverName/api/v1/lists/${group.id}/accounts';
|
||||
final request = Uri.parse(url);
|
||||
final requestData = {
|
||||
'account_ids': [connection.id]
|
||||
};
|
||||
return (await _deleteUrl(request, requestData)).mapValue((_) => true);
|
||||
}
|
||||
|
||||
FutureResult<List<Connection>, 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<AuthService>().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<List<Connection>, 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<AuthService>().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<Connection, ExecError> getConnectionWithStatus(
|
||||
Connection connection) async {
|
||||
_logger.finest(() => 'Getting group (Mastodon List) data');
|
||||
final myId = getIt<AuthService>().currentId;
|
||||
final id = int.parse(connection.id);
|
||||
final paging = '?min_id=${id - 1}&max_id=${id + 1}';
|
||||
final baseUrl = 'https://$serverName/api/v1/accounts/$myId';
|
||||
final following =
|
||||
await _getApiListRequest(Uri.parse('$baseUrl/following$paging')).fold(
|
||||
onSuccess: (followings) => followings.isNotEmpty,
|
||||
onError: (error) {
|
||||
_logger.severe('Error getting following list: $error');
|
||||
return false;
|
||||
});
|
||||
final follower =
|
||||
await _getApiListRequest(Uri.parse('$baseUrl/followers$paging')).fold(
|
||||
onSuccess: (followings) => followings.isNotEmpty,
|
||||
onError: (error) {
|
||||
_logger.severe('Error getting follower list: $error');
|
||||
return false;
|
||||
});
|
||||
|
||||
var status = ConnectionStatus.none;
|
||||
if (following && follower) {
|
||||
status = ConnectionStatus.mutual;
|
||||
} else if (following) {
|
||||
status = ConnectionStatus.youFollowThem;
|
||||
} else if (follower) {
|
||||
status = ConnectionStatus.theyFollowYou;
|
||||
}
|
||||
return Result.ok(connection.copy(status: status));
|
||||
}
|
||||
|
||||
FutureResult<List<GroupData>, ExecError> getMemberGroupsForConnection(
|
||||
String connectionId) async {
|
||||
_logger.finest(() =>
|
||||
|
@ -150,14 +242,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);
|
||||
|
@ -175,7 +261,7 @@ class FriendicaClient {
|
|||
return (await runCatchingAsync(() async {
|
||||
final baseUrl = 'https://$serverName/api/v1/statuses/$id';
|
||||
final url = fullContext ? '$baseUrl/context' : baseUrl;
|
||||
final request = Uri.parse(url);
|
||||
final request = Uri.parse('$url?limit=1000');
|
||||
_logger.finest(() =>
|
||||
'Getting entry for status $id, full context? $fullContext : $url');
|
||||
return (await _getApiRequest(request).andThenSuccessAsync((json) async {
|
||||
|
@ -282,6 +368,74 @@ class FriendicaClient {
|
|||
});
|
||||
}
|
||||
|
||||
FutureResult<Connection, ExecError> 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<Connection, ExecError> 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<Connection, ExecError> 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<Connection, ExecError> 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((jsonString) async {
|
||||
return _updateConnectionFromFollowRequestResult(connection, jsonString);
|
||||
});
|
||||
return result.mapError((error) => error is ExecError
|
||||
? error
|
||||
: ExecError(type: ErrorType.localError, message: error.toString()));
|
||||
}
|
||||
|
||||
FutureResult<Connection, ExecError> unFollowConnection(
|
||||
Connection connection) async {
|
||||
final id = connection.id;
|
||||
final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unfollow');
|
||||
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<Connection, ExecError> getMyProfile() async {
|
||||
_logger.finest(() => 'Getting logged in user profile');
|
||||
final request = Uri.parse('https://$serverName/api/friendica/profile/show');
|
||||
|
@ -338,6 +492,31 @@ class FriendicaClient {
|
|||
}
|
||||
}
|
||||
|
||||
FutureResult<String, ExecError> _deleteUrl(
|
||||
Uri url, Map<String, dynamic> body) async {
|
||||
_logger.finest('DELETE: $url');
|
||||
try {
|
||||
final response = await http.delete(
|
||||
url,
|
||||
headers: {
|
||||
'Authorization': _authHeader,
|
||||
'Content-Type': 'application/json; charset=UTF-8'
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return Result.error(ExecError(
|
||||
type: ErrorType.authentication,
|
||||
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||
}
|
||||
return Result.ok(utf8.decode(response.bodyBytes));
|
||||
} catch (e) {
|
||||
return Result.error(
|
||||
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
FutureResult<List<dynamic>, ExecError> _getApiListRequest(Uri url) async {
|
||||
return (await _getUrl(url).andThenSuccessAsync(
|
||||
(jsonText) async => jsonDecode(jsonText) as List<dynamic>))
|
||||
|
@ -363,7 +542,8 @@ class FriendicaClient {
|
|||
case TimelineType.profile:
|
||||
return '/accounts/${type.auxData}/statuses';
|
||||
case TimelineType.self:
|
||||
throw UnimplementedError('These types are not supported yet');
|
||||
final myId = getIt<AuthService>().currentId;
|
||||
return '/accounts/$myId/statuses';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -373,11 +553,42 @@ class FriendicaClient {
|
|||
case TimelineType.global:
|
||||
case TimelineType.profile:
|
||||
case TimelineType.group:
|
||||
case TimelineType.self:
|
||||
return '';
|
||||
case TimelineType.local:
|
||||
return 'local=true';
|
||||
case TimelineType.self:
|
||||
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;
|
||||
}
|
||||
|
||||
Connection _updateConnectionFromFollowRequestResult(
|
||||
Connection connection, String jsonString) {
|
||||
final json = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,6 +81,10 @@ class App extends StatelessWidget {
|
|||
create: (_) => getIt<AuthService>(),
|
||||
lazy: true,
|
||||
),
|
||||
ChangeNotifierProvider<ConnectionsManager>(
|
||||
create: (_) => getIt<ConnectionsManager>(),
|
||||
lazy: true,
|
||||
),
|
||||
ChangeNotifierProvider<EntryManagerService>(
|
||||
create: (_) => getIt<EntryManagerService>(),
|
||||
lazy: true,
|
||||
|
|
|
@ -19,7 +19,7 @@ enum TimelineType {
|
|||
case TimelineType.profile:
|
||||
return 'Profile';
|
||||
case TimelineType.self:
|
||||
return 'Self';
|
||||
return 'Yours';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -37,8 +37,7 @@ class TimelineIdentifiers {
|
|||
factory TimelineIdentifiers.home() =>
|
||||
TimelineIdentifiers(timeline: TimelineType.home);
|
||||
|
||||
factory TimelineIdentifiers.profile(String profileId) =>
|
||||
TimelineIdentifiers(
|
||||
factory TimelineIdentifiers.profile(String profileId) => TimelineIdentifiers(
|
||||
timeline: TimelineType.profile,
|
||||
auxData: profileId,
|
||||
);
|
||||
|
@ -53,10 +52,10 @@ class TimelineIdentifiers {
|
|||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TimelineIdentifiers &&
|
||||
runtimeType == other.runtimeType &&
|
||||
timeline == other.timeline &&
|
||||
auxData == other.auxData;
|
||||
other is TimelineIdentifiers &&
|
||||
runtimeType == other.runtimeType &&
|
||||
timeline == other.timeline &&
|
||||
auxData == other.auxData;
|
||||
|
||||
@override
|
||||
int get hashCode => timeline.hashCode ^ auxData.hashCode;
|
||||
|
|
|
@ -12,7 +12,7 @@ class Connection {
|
|||
final Uri avatarUrl;
|
||||
|
||||
Connection(
|
||||
{this.status = ConnectionStatus.none,
|
||||
{this.status = ConnectionStatus.unknown,
|
||||
this.name = '',
|
||||
this.id = '',
|
||||
Uri? profileUrl,
|
||||
|
@ -41,6 +41,14 @@ class Connection {
|
|||
String toString() {
|
||||
return 'Connection{status: $status, name: $name, id: $id, profileUrl: $profileUrl, network: $network, avatar: $avatarUrl}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Connection && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
enum ConnectionStatus {
|
||||
|
@ -49,10 +57,11 @@ enum ConnectionStatus {
|
|||
mutual,
|
||||
you,
|
||||
none,
|
||||
unknown,
|
||||
}
|
||||
|
||||
extension FriendStatusWriter on ConnectionStatus {
|
||||
String name() {
|
||||
String label() {
|
||||
switch (this) {
|
||||
case ConnectionStatus.youFollowThem:
|
||||
return "You Follow Them";
|
||||
|
@ -64,6 +73,8 @@ extension FriendStatusWriter on ConnectionStatus {
|
|||
return "Not connected";
|
||||
case ConnectionStatus.you:
|
||||
return "You";
|
||||
case ConnectionStatus.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:result_monad/result_monad.dart';
|
||||
|
||||
class ExecError {
|
||||
final ErrorType type;
|
||||
final String message;
|
||||
|
@ -17,3 +19,11 @@ enum ErrorType {
|
|||
notFound,
|
||||
parsingError,
|
||||
}
|
||||
|
||||
extension ExecErrorExtension<T, E> on Result<T, E> {
|
||||
Result<T, ExecError> execErrorCast() => mapError((error) => error is ExecError
|
||||
? error
|
||||
: ExecError(type: ErrorType.localError, message: error.toString()));
|
||||
|
||||
FutureResult<T, ExecError> execErrorCastAsync() async => execErrorCast();
|
||||
}
|
||||
|
|
|
@ -8,4 +8,12 @@ class GroupData {
|
|||
String toString() {
|
||||
return 'GroupData{id: $id, name: $name}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is GroupData && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ class Timeline {
|
|||
final TimelineIdentifiers id;
|
||||
final List<EntryTreeItem> _posts = [];
|
||||
final Map<String, EntryTreeItem> _postsById = {};
|
||||
int _lowestStatusId = 0;
|
||||
int _lowestStatusId = 9223372036854775807;
|
||||
int _highestStatusId = 0;
|
||||
|
||||
int get highestStatusId => _highestStatusId;
|
||||
|
@ -44,7 +44,7 @@ class Timeline {
|
|||
final parentId = comment.entry.parentId;
|
||||
for (final p in _posts) {
|
||||
final parent =
|
||||
p.id == parentId ? p : p.getChildById(comment.entry.parentId);
|
||||
p.id == parentId ? p : p.getChildById(comment.entry.parentId);
|
||||
if (parent != null) {
|
||||
parent.addOrUpdate(comment);
|
||||
changed = true;
|
||||
|
@ -64,8 +64,7 @@ class Timeline {
|
|||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Timeline && runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
other is Timeline && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'globals.dart';
|
||||
import 'screens/contacts_screen.dart';
|
||||
import 'screens/editor.dart';
|
||||
import 'screens/gallery_browsers_screen.dart';
|
||||
import 'screens/gallery_screen.dart';
|
||||
import 'screens/follow_request_adjudication_screen.dart';
|
||||
import 'screens/home.dart';
|
||||
import 'screens/notifications_screen.dart';
|
||||
import 'screens/post_screen.dart';
|
||||
|
@ -15,6 +17,8 @@ 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 = '/';
|
||||
static String gallery = '/gallery';
|
||||
|
@ -61,6 +65,20 @@ 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: '/connect/:id',
|
||||
name: ScreenPaths.connectHandle,
|
||||
builder: (context, state) =>
|
||||
FollowRequestAdjudicationScreen(userId: state.params['id']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: ScreenPaths.timelines,
|
||||
name: ScreenPaths.timelines,
|
||||
|
|
|
@ -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<ConnectionsManager>();
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -26,6 +26,8 @@ class _EditorScreenState extends State<EditorScreen> {
|
|||
final spoilerController = TextEditingController();
|
||||
TimelineEntry? parentEntry;
|
||||
|
||||
var isSubmitting = false;
|
||||
|
||||
bool get isComment => widget.parentId.isNotEmpty;
|
||||
|
||||
String get statusType => widget.parentId.isEmpty ? 'Post' : 'Comment';
|
||||
|
@ -51,11 +53,17 @@ class _EditorScreenState extends State<EditorScreen> {
|
|||
buildSnackbar(context, "Can't submit an empty post/comment");
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isSubmitting = false;
|
||||
});
|
||||
final result = await manager.createNewStatus(
|
||||
contentController.text,
|
||||
spoilerText: spoilerController.text,
|
||||
inReplyToId: widget.parentId,
|
||||
);
|
||||
setState(() {
|
||||
isSubmitting = false;
|
||||
});
|
||||
|
||||
if (result.isFailure) {
|
||||
buildSnackbar(context, 'Error posting: ${result.error}');
|
||||
|
@ -85,6 +93,8 @@ class _EditorScreenState extends State<EditorScreen> {
|
|||
if (isComment && parentEntry != null)
|
||||
buildCommentPreview(context, parentEntry!),
|
||||
TextFormField(
|
||||
readOnly: isSubmitting,
|
||||
enabled: !isSubmitting,
|
||||
controller: spoilerController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '$statusType Spoiler Text (optional)',
|
||||
|
@ -98,6 +108,8 @@ class _EditorScreenState extends State<EditorScreen> {
|
|||
),
|
||||
const VerticalPadding(),
|
||||
TextFormField(
|
||||
readOnly: isSubmitting,
|
||||
enabled: !isSubmitting,
|
||||
maxLines: 10,
|
||||
controller: contentController,
|
||||
decoration: InputDecoration(
|
||||
|
@ -116,14 +128,18 @@ class _EditorScreenState extends State<EditorScreen> {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async => createStatus(context, manager),
|
||||
onPressed: isSubmitting
|
||||
? null
|
||||
: () async => createStatus(context, manager),
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
const HorizontalPadding(),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
onPressed: isSubmitting
|
||||
? null
|
||||
: () {
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -0,0 +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<FollowRequestAdjudicationScreen> createState() =>
|
||||
_FollowRequestAdjudicationScreenState();
|
||||
}
|
||||
|
||||
class _FollowRequestAdjudicationScreenState
|
||||
extends State<FollowRequestAdjudicationScreen> {
|
||||
var processing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final manager = context.watch<ConnectionsManager>();
|
||||
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
|
||||
|
||||
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<void> 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<void> reject(ConnectionsManager manager, Connection contact) async {
|
||||
setState(() {
|
||||
processing = true;
|
||||
});
|
||||
|
||||
await manager.rejectFollowRequest(contact);
|
||||
|
||||
setState(() {
|
||||
processing = false;
|
||||
});
|
||||
|
||||
if (mounted && context.canPop()) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ignore(ConnectionsManager manager, Connection contact) async {
|
||||
setState(() {
|
||||
processing = true;
|
||||
});
|
||||
|
||||
await manager.ignoreFollowRequest(contact);
|
||||
|
||||
setState(() {
|
||||
processing = false;
|
||||
});
|
||||
|
||||
if (mounted && context.canPop()) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
var currentType = TimelineType.home;
|
||||
GroupData? currentGroup;
|
||||
final types = [
|
||||
TimelineType.self,
|
||||
TimelineType.home,
|
||||
TimelineType.global,
|
||||
TimelineType.local,
|
||||
|
@ -79,7 +80,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
onPressed: () {
|
||||
context.push('/post/new');
|
||||
},
|
||||
icon: Icon(Icons.add),
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -13,11 +13,17 @@ class PostScreen extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final manager = context.watch<TimelineManager>();
|
||||
final body = manager.getPostTreeEntryBy(id).fold(
|
||||
onSuccess: (post) => SingleChildScrollView(
|
||||
child: StatusControl(
|
||||
originalItem: post,
|
||||
openRemote: true,
|
||||
showStatusOpenButton: true,
|
||||
onSuccess: (post) => RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await manager.refreshStatusChain(id);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: StatusControl(
|
||||
originalItem: post,
|
||||
openRemote: true,
|
||||
showStatusOpenButton: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
onError: (error) => Text('Error getting post: $error'));
|
||||
|
|
|
@ -1,18 +1,28 @@
|
|||
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 '../globals.dart';
|
||||
import '../models/connection.dart';
|
||||
import '../models/group_data.dart';
|
||||
import '../routes.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/connections_manager.dart';
|
||||
import '../utils/snackbar_builder.dart';
|
||||
import '../utils/url_opening_utils.dart';
|
||||
|
||||
class UserProfileScreen extends StatelessWidget {
|
||||
class UserProfileScreen extends StatefulWidget {
|
||||
final String userId;
|
||||
|
||||
const UserProfileScreen({super.key, required this.userId});
|
||||
|
||||
@override
|
||||
State<UserProfileScreen> createState() => _UserProfileScreenState();
|
||||
}
|
||||
|
||||
class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||
Future<void> openProfileExternal(
|
||||
BuildContext context,
|
||||
Connection connection,
|
||||
|
@ -25,28 +35,65 @@ class UserProfileScreen extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
var isUpdating = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final manager = getIt<ConnectionsManager>();
|
||||
final body = manager.getById(userId).fold(onSuccess: (profile) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CachedNetworkImage(imageUrl: profile.avatarUrl.toString()),
|
||||
Text(profile.name),
|
||||
Text(profile.status.toString()),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.pushNamed(
|
||||
ScreenPaths.userPosts,
|
||||
params: {'id': profile.id},
|
||||
final manager = context.watch<ConnectionsManager>();
|
||||
final body = manager.getById(widget.userId).fold(onSuccess: (profile) {
|
||||
final notMyProfile = getIt<AuthService>().currentId != profile.id;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await manager.fullRefresh(profile);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CachedNetworkImage(imageUrl: profile.avatarUrl.toString()),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
notMyProfile
|
||||
? profile.name
|
||||
: '${profile.name} (Your Account)',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
child: const Text('Posts')),
|
||||
ElevatedButton(
|
||||
onPressed: () async => await openProfileExternal(context, profile),
|
||||
child: const Text('Open In Browser'),
|
||||
const HorizontalPadding(),
|
||||
Text('( ${profile.status.label()} )'),
|
||||
],
|
||||
),
|
||||
const VerticalPadding(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (notMyProfile)
|
||||
buildConnectionStatusToggle(context, profile, manager),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.pushNamed(
|
||||
ScreenPaths.userPosts,
|
||||
params: {'id': profile.id},
|
||||
),
|
||||
child: const Text('Posts')),
|
||||
ElevatedButton(
|
||||
onPressed: () async =>
|
||||
await openProfileExternal(context, profile),
|
||||
child: const Text('Open In Browser'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalPadding(),
|
||||
if (profile.status == ConnectionStatus.mutual ||
|
||||
profile.status == ConnectionStatus.youFollowThem)
|
||||
buildGroups(context, profile, manager),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}, onError: (error) {
|
||||
return Text('Error getting profile: $error');
|
||||
|
@ -63,4 +110,105 @@ class UserProfileScreen extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildGroups(
|
||||
BuildContext context,
|
||||
Connection profile,
|
||||
ConnectionsManager manager,
|
||||
) {
|
||||
final myGroups = manager.getMyGroups();
|
||||
final usersGroups = manager.getGroupsForUser(profile.id).fold(
|
||||
onSuccess: (groups) => groups.toSet(),
|
||||
onError: (error) {
|
||||
buildSnackbar(context, 'Error getting group data: $error');
|
||||
return <GroupData>{};
|
||||
});
|
||||
myGroups.sort((g1, g2) => g1.name.compareTo(g2.name));
|
||||
final groupsWidgets = myGroups.map((g) {
|
||||
return CheckboxListTile(
|
||||
title: Text(g.name),
|
||||
value: usersGroups.contains(g),
|
||||
onChanged: isUpdating
|
||||
? null
|
||||
: (bool? value) async {
|
||||
setState(() {
|
||||
print('Started updating/isupdating cycle');
|
||||
isUpdating = true;
|
||||
});
|
||||
if (value == true) {
|
||||
await manager.addUserToGroup(g, profile);
|
||||
} else {
|
||||
await manager.removeUserFromGroup(g, profile);
|
||||
}
|
||||
setState(() {
|
||||
isUpdating = false;
|
||||
print('Done updating/isupdating cycle');
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Groups: ',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const VerticalPadding(),
|
||||
...groupsWidgets,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildConnectionStatusToggle(
|
||||
BuildContext context,
|
||||
Connection profile,
|
||||
ConnectionsManager manager,
|
||||
) {
|
||||
late Widget followToggleButton;
|
||||
switch (profile.status) {
|
||||
case ConnectionStatus.mutual:
|
||||
case ConnectionStatus.youFollowThem:
|
||||
followToggleButton = ElevatedButton(
|
||||
onPressed: isUpdating
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
print('Started updating/isupdating cycle');
|
||||
isUpdating = true;
|
||||
});
|
||||
await manager.unfollow(profile);
|
||||
setState(() {
|
||||
print('Done updating/isupdating cycle');
|
||||
isUpdating = false;
|
||||
});
|
||||
},
|
||||
child: const Text('Unfollow'),
|
||||
);
|
||||
break;
|
||||
case ConnectionStatus.theyFollowYou:
|
||||
case ConnectionStatus.none:
|
||||
followToggleButton = ElevatedButton(
|
||||
onPressed: isUpdating
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
print('Started updating/isupdating cycle');
|
||||
isUpdating = true;
|
||||
});
|
||||
await manager.follow(profile);
|
||||
setState(() {
|
||||
print('Done updating/isupdating cycle');
|
||||
isUpdating = false;
|
||||
});
|
||||
},
|
||||
child: const Text('Follow'),
|
||||
);
|
||||
break;
|
||||
case ConnectionStatus.you:
|
||||
case ConnectionStatus.unknown:
|
||||
followToggleButton = const SizedBox();
|
||||
break;
|
||||
}
|
||||
return followToggleButton;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:result_monad/result_monad.dart';
|
||||
|
@ -13,7 +15,10 @@ class ConnectionsManager extends ChangeNotifier {
|
|||
final _connectionsById = <String, Connection>{};
|
||||
final _connectionsByName = <String, Connection>{};
|
||||
final _connectionsByProfileUrl = <Uri, Connection>{};
|
||||
final _listsForConnection = <String, List<GroupData>>{};
|
||||
final _groupsForConnection = <String, List<GroupData>>{};
|
||||
final _myGroups = <GroupData>{};
|
||||
final _myContacts = <Connection>[];
|
||||
var _myContactsInitialized = false;
|
||||
|
||||
int get length => _connectionsById.length;
|
||||
|
||||
|
@ -21,16 +26,39 @@ class ConnectionsManager extends ChangeNotifier {
|
|||
_connectionsById.clear();
|
||||
_connectionsByName.clear();
|
||||
_connectionsByProfileUrl.clear();
|
||||
_listsForConnection.clear();
|
||||
_groupsForConnection.clear();
|
||||
_myGroups.clear();
|
||||
_myContacts.clear();
|
||||
}
|
||||
|
||||
bool addConnection(Connection connection) {
|
||||
if (_connectionsById.containsKey(connection.id)) {
|
||||
return false;
|
||||
}
|
||||
return updateConnection(connection);
|
||||
}
|
||||
|
||||
bool updateConnection(Connection connection) {
|
||||
_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;
|
||||
}
|
||||
|
@ -45,21 +73,268 @@ class ConnectionsManager extends ChangeNotifier {
|
|||
return result;
|
||||
}
|
||||
|
||||
Result<List<GroupData>, ExecError> getListsForUser(String id) {
|
||||
final result = _listsForConnection[id] ?? [];
|
||||
_refreshConnectionListData(id);
|
||||
Future<void> acceptFollowRequest(Connection connection) async {
|
||||
_logger.finest(
|
||||
'Attempting to accept follow request ${connection.name}: ${connection.status}');
|
||||
await getIt<AuthService>()
|
||||
.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<void> rejectFollowRequest(Connection connection) async {
|
||||
_logger.finest(
|
||||
'Attempting to accept follow request ${connection.name}: ${connection.status}');
|
||||
await getIt<AuthService>()
|
||||
.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<void> ignoreFollowRequest(Connection connection) async {
|
||||
_logger.finest(
|
||||
'Attempting to accept follow request ${connection.name}: ${connection.status}');
|
||||
await getIt<AuthService>()
|
||||
.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<void> follow(Connection connection) async {
|
||||
_logger.finest(
|
||||
'Attempting to follow ${connection.name}: ${connection.status}');
|
||||
await getIt<AuthService>()
|
||||
.currentClient
|
||||
.andThenAsync((client) => client.followConnection(connection))
|
||||
.match(
|
||||
onSuccess: (update) {
|
||||
_logger
|
||||
.finest('Successfully followed ${update.name}: ${update.status}');
|
||||
updateConnection(update);
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.severe('Error following ${connection.name}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> unfollow(Connection connection) async {
|
||||
_logger.finest(
|
||||
'Attempting to unfollow ${connection.name}: ${connection.status}');
|
||||
await getIt<AuthService>()
|
||||
.currentClient
|
||||
.andThenAsync((client) => client.unFollowConnection(connection))
|
||||
.match(
|
||||
onSuccess: (update) {
|
||||
_logger
|
||||
.finest('Successfully unfollowed ${update.name}: ${update.status}');
|
||||
updateConnection(update);
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.severe('Error following ${connection.name}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Connection> getMyContacts() {
|
||||
if (!_myContactsInitialized) {
|
||||
updateAllContacts();
|
||||
_myContactsInitialized = true;
|
||||
}
|
||||
|
||||
return _myContacts.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> updateAllContacts() async {
|
||||
_logger.fine('Updating all contacts');
|
||||
final clientResult = getIt<AuthService>().currentClient;
|
||||
if (clientResult.isFailure) {
|
||||
_logger.severe(
|
||||
'Unable to update contacts due to client error: ${clientResult.error}');
|
||||
return;
|
||||
}
|
||||
final client = clientResult.value;
|
||||
final results = <String, Connection>{};
|
||||
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<GroupData> getMyGroups() {
|
||||
if (_myGroups.isNotEmpty) {
|
||||
return _myGroups.toList(growable: false);
|
||||
}
|
||||
_updateMyGroups(true);
|
||||
return [];
|
||||
}
|
||||
|
||||
Result<List<GroupData>, ExecError> getGroupsForUser(String id) {
|
||||
if (!_groupsForConnection.containsKey(id)) {
|
||||
_refreshGroupListData(id, true);
|
||||
return Result.ok([]);
|
||||
}
|
||||
|
||||
return Result.ok(_groupsForConnection[id]!);
|
||||
}
|
||||
|
||||
FutureResult<bool, ExecError> addUserToGroup(
|
||||
GroupData group, Connection connection) async {
|
||||
_logger.finest('Adding ${connection.name} to group: ${group.name}');
|
||||
final result = await getIt<AuthService>().currentClient.andThenAsync(
|
||||
(client) => client.addConnectionToGroup(group, connection));
|
||||
result.match(
|
||||
onSuccess: (_) => _refreshGroupListData(connection.id, true),
|
||||
onError: (error) {
|
||||
_logger
|
||||
.severe('Error adding ${connection.name} to group: ${group.name}');
|
||||
},
|
||||
);
|
||||
|
||||
return result.execErrorCast();
|
||||
}
|
||||
|
||||
FutureResult<bool, ExecError> removeUserFromGroup(
|
||||
GroupData group, Connection connection) async {
|
||||
_logger.finest('Removing ${connection.name} from group: ${group.name}');
|
||||
final result = await getIt<AuthService>().currentClient.andThenAsync(
|
||||
(client) => client.removeConnectionFromGroup(group, connection));
|
||||
result.match(
|
||||
onSuccess: (_) => _refreshGroupListData(connection.id, true),
|
||||
onError: (error) {
|
||||
_logger.severe(
|
||||
'Error removing ${connection.name} from group: ${group.name}');
|
||||
},
|
||||
);
|
||||
|
||||
return result.execErrorCast();
|
||||
}
|
||||
|
||||
Result<Connection, String> getById(String id) {
|
||||
final result = _connectionsById[id];
|
||||
if (result == null) {
|
||||
return Result.error('$id not found');
|
||||
}
|
||||
if (result.status == ConnectionStatus.unknown) {
|
||||
_refreshConnection(result, true);
|
||||
}
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
Future<void> _refreshConnectionListData(String id) async {
|
||||
Result<Connection, String> getByName(String name) {
|
||||
final result = _connectionsByName[name];
|
||||
if (result == null) {
|
||||
Result.error('$name not found');
|
||||
}
|
||||
if (result!.status == ConnectionStatus.unknown) {
|
||||
_refreshConnection(result, true);
|
||||
}
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
Result<Connection, String> getByProfileUrl(Uri url) {
|
||||
final result = _connectionsByProfileUrl[url];
|
||||
if (result == null) {
|
||||
Result.error('$url not found');
|
||||
}
|
||||
if (result!.status == ConnectionStatus.unknown) {
|
||||
_refreshConnection(result, true);
|
||||
}
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
Future<void> fullRefresh(Connection connection) async {
|
||||
await _updateMyGroups(false);
|
||||
await _refreshGroupListData(connection.id, false);
|
||||
await _refreshConnection(connection, false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _refreshGroupListData(String id, bool withNotification) async {
|
||||
_logger.finest('Refreshing member list data for Connection $id');
|
||||
await getIt<AuthService>()
|
||||
.currentClient
|
||||
.andThenAsync((client) => client.getMemberGroupsForConnection(id))
|
||||
.match(
|
||||
onSuccess: (lists) {
|
||||
_listsForConnection[id] = lists;
|
||||
notifyListeners();
|
||||
_groupsForConnection[id] = lists;
|
||||
if (withNotification) {
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.severe('Error getting list data for $id: $error');
|
||||
|
@ -67,21 +342,42 @@ class ConnectionsManager extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
Result<Connection, String> getById(String id) {
|
||||
final result = _connectionsById[id];
|
||||
|
||||
return result != null ? Result.ok(result) : Result.error('$id not found');
|
||||
Future<void> _refreshConnection(
|
||||
Connection connection, bool withNotification) async {
|
||||
_logger.finest('Refreshing connection data for ${connection.name}');
|
||||
await getIt<AuthService>()
|
||||
.currentClient
|
||||
.andThenAsync((client) => client.getConnectionWithStatus(connection))
|
||||
.match(
|
||||
onSuccess: (update) {
|
||||
updateConnection(update);
|
||||
if (withNotification) {
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.severe('Error getting updates for ${connection.name}: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Result<Connection, String> getByName(String name) {
|
||||
final result = _connectionsByName[name];
|
||||
|
||||
return result != null ? Result.ok(result) : Result.error('$name not found');
|
||||
}
|
||||
|
||||
Result<Connection, String> getByProfileUrl(Uri url) {
|
||||
final result = _connectionsByProfileUrl[url];
|
||||
|
||||
return result != null ? Result.ok(result) : Result.error('$url not found');
|
||||
Future<void> _updateMyGroups(bool withNotification) async {
|
||||
_logger.finest('Refreshing my groups list');
|
||||
await getIt<AuthService>()
|
||||
.currentClient
|
||||
.andThenAsync((client) => client.getGroups())
|
||||
.match(
|
||||
onSuccess: (groups) {
|
||||
_logger.finest('Got updated groups:${groups.map((e) => e.name)}');
|
||||
_myGroups.clear();
|
||||
_myGroups.addAll(groups);
|
||||
if (withNotification) {
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.severe('Error getting my groups: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue