Merge contacts-update branch into image-flows branch

codemagic-setup
Hank Grabowski 2022-12-19 14:04:26 -05:00
commit 158c0c872e
19 zmienionych plików z 1038 dodań i 88 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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);

Wyświetl plik

@ -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');

Wyświetl plik

@ -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

Wyświetl plik

@ -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);
}
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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;

Wyświetl plik

@ -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';
}
}
}

Wyświetl plik

@ -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();
}

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -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,

Wyświetl plik

@ -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,
),
);
}
}

Wyświetl plik

@ -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'),
),
],

Wyświetl plik

@ -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();
}
}
}

Wyświetl plik

@ -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,
),
),
],
),

Wyświetl plik

@ -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'));

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -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');
},
);
}
}