diff --git a/.github/workflows/create_release_from_builds.yml b/.github/workflows/create_release_from_builds.yml index 76c0e92..eab565d 100644 --- a/.github/workflows/create_release_from_builds.yml +++ b/.github/workflows/create_release_from_builds.yml @@ -20,23 +20,23 @@ jobs: channel: stable version: 3.35.7 - - name: Install dependencies + - name: Install flutter dependencies run: flutter pub get - - name: Build Windows package - run: flutter build windows + - name: Flutter Build Windows + run: flutter build windows --release - - name: Zip Windows build for release + - name: Zip Windows build # Compress the entire Release folder into a single zip for attaching to the GitHub Release. run: | - Compress-Archive -Path (Get-ChildItem -Path 'build/windows/x64/runner/Release' -Recurse | ForEach-Object FullName) -DestinationPath 'windows-build.zip' -Force + Compress-Archive -Path (Get-ChildItem -Path 'build/windows/x64/runner/Release' -Recurse | ForEach-Object FullName) -DestinationPath 'recon-windows.zip' -Force shell: powershell - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: - name: windows-build - path: windows-build.zip + name: recon-windows + path: recon-windows.zip build_linux_appimage: name: Build Linux (AppImage) @@ -82,7 +82,7 @@ jobs: - name: Install flutter dependencies run: flutter pub get - - name: Build Linux binary + - name: Flutter Build Linux run: flutter build linux --release - name: Prepare for AppImage build @@ -100,8 +100,8 @@ jobs: - name: Upload Linux AppImage artifact uses: actions/upload-artifact@v4 with: - name: ReCon-latest-x86_64.AppImage - path: ReCon-latest-x86_64.AppImage + name: recon-linux.AppImage + path: recon-linux.AppImage build_android: name: Build Android @@ -132,13 +132,13 @@ jobs: run: | flutter build apk --release cd ../build/app/outputs/flutter-apk - mv app-release.apk recon-build.apk + mv app-release.apk recon-android.apk - name: Upload Android artifact uses: actions/upload-artifact@v4 with: - name: recon-build - path: build/app/outputs/flutter-apk/recon-build.apk + name: recon-android + path: build/app/outputs/flutter-apk/recon-android.apk release: name: Create Release and attach artifacts @@ -159,7 +159,7 @@ jobs: run: | VERSION=$(grep '^version: ' pubspec.yaml | sed 's/version: //') echo "Project version: $VERSION" - echo "::set-output name=version::$VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Create release id: create_release @@ -174,14 +174,14 @@ jobs: prerelease: false - name: Upload Windows Build to Release - id: upload-release-asset + id: upload-release-asset-windows uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps - asset_path: release_artifacts/windows-build/windows-build.zip - asset_name: ReConRunner-Windows-${{ steps.get_version.outputs.version }}.zip + asset_path: release_artifacts/recon-windows/recon-windows.zip + asset_name: ReCon-Windows-${{ steps.get_version.outputs.version }}.zip asset_content_type: application/zip - name: Upload Android APK to Release @@ -191,6 +191,17 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: release_artifacts/recon-build/recon-build.apk - asset_name: ReConRunner-Android-${{ steps.get_version.outputs.version }}.apk + asset_path: release_artifacts/recon-android/recon-android.apk + asset_name: ReCon-Android-${{ steps.get_version.outputs.version }}.apk asset_content_type: application/vnd.android.package-archive + + - name: Upload Linux AppImage to Release + id: upload-release-asset-linux + uses: actions/upload-release-asset@v1.0.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: release_artifacts/recon-linux/recon-linux.AppImage + asset_name: ReCon-Linux-${{ steps.get_version.outputs.version }}.AppImage + asset_content_type: application/x-executable diff --git a/analysis_options.yaml b/analysis_options.yaml index a115631..c8ce58a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -33,7 +33,6 @@ linter: - prefer_void_to_null - unnecessary_statements - always_declare_return_types - - avoid_annotating_with_dynamic - avoid_bool_literals_in_conditional_expressions - avoid_escaping_inner_quotes - avoid_field_initializers_in_const_classes diff --git a/lib/apis/contact_api.dart b/lib/apis/contact_api.dart index 1cd6517..79096bd 100644 --- a/lib/apis/contact_api.dart +++ b/lib/apis/contact_api.dart @@ -2,10 +2,6 @@ import 'dart:convert'; import 'package:recon/clients/api_client.dart'; import 'package:recon/models/users/friend.dart'; -import 'package:recon/models/users/friend_status.dart'; -import 'package:recon/models/users/user.dart'; -import 'package:recon/models/users/user_profile.dart'; -import 'package:recon/models/users/user_status.dart'; class ContactApi { static Future> getFriendsList(ApiClient client, {DateTime? lastStatusUpdate}) async { @@ -14,24 +10,4 @@ class ContactApi { final data = jsonDecode(response.body) as List; return data.map((e) => Friend.fromMap(e)).toList(); } - - static Future addUserAsFriend(ApiClient client, {required User user}) async { - final friend = Friend( - id: user.id, - username: user.username, - ownerId: client.userId, - userStatus: UserStatus.empty(), - userProfile: UserProfile.empty(), - contactStatus: FriendStatus.accepted, - latestMessageTime: DateTime.now(), - ); - final body = jsonEncode(friend.toMap(shallow: true)); - final response = await client.put("/users/${client.userId}/contacts/${user.id}", body: body); - client.checkResponse(response); - } - - static Future removeUserAsFriend(ApiClient client, {required User user}) async { - final response = await client.delete("/users/${client.userId}/friends/${user.id}"); - client.checkResponse(response); - } -} \ No newline at end of file +} diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index ba763f9..bea8744 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -24,7 +24,6 @@ class ApiClient { final AuthenticationData _authenticationData; final Logger _logger = Logger("API"); - // Saving the context here feels kinda cringe ngl final _logoutNotifier = EventNotifier(); final http.Client _client = http.Client(); diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 38abc43..3ff4a68 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -15,7 +15,9 @@ import 'package:recon/models/hub_events.dart'; import 'package:recon/models/message.dart'; import 'package:recon/models/session.dart'; import 'package:recon/models/users/friend.dart'; +import 'package:recon/models/users/friend_status.dart'; import 'package:recon/models/users/online_status.dart'; +import 'package:recon/models/users/user.dart'; import 'package:recon/models/users/user_status.dart'; class MessagingClient extends ChangeNotifier { @@ -73,9 +75,9 @@ class MessagingClient extends ChangeNotifier { List get cachedFriends => _sortedFriendsCache; - List getUnreadsForFriend(Friend friend) => _unreads[friend.id] ?? []; + List getUnreadsForFriend(Friend friend) => _unreads[friend.contactUserId] ?? []; - bool friendHasUnreads(Friend friend) => _unreads.containsKey(friend.id); + bool friendHasUnreads(Friend friend) => _unreads.containsKey(friend.contactUserId); bool messageIsUnread(Message message) => _unreads[message.senderId]?.any((element) => element.id == message.id) ?? false; @@ -95,7 +97,7 @@ class MessagingClient extends ChangeNotifier { } Future initFriendsList() async { - _hubManager.send( + _hubManager.send( "InitializeStatus", responseHandler: (data) async { final rawContacts = data["contacts"] as List; @@ -176,6 +178,39 @@ class MessagingClient extends ChangeNotifier { notifyListeners(); } + Future addContact(User user) async { + return _hubUpdateContact( + Friend.empty().copyWith( + ownerId: _apiClient.userId, + contactUserId: user.id, + contactUsername: user.username, + contactStatus: ContactStatus.accepted, + ), + ); + } + + Future removeContact(Friend friend) async { + return _hubUpdateContact( + friend.copyWith( + contactStatus: ContactStatus.ignored, + ), + ); + } + + Future _hubUpdateContact(Friend friend) async { + final response = await _hubManager.sendAndWait( + "UpdateContact", + arguments: [ + friend.toMap(), + ], + ) ?? + false; + if (response) { + await _updateContact(friend); + } + return response; + } + void addUnread(Message message) { var messages = _unreads[message.senderId]; if (messages == null) { @@ -253,42 +288,44 @@ class MessagingClient extends ChangeNotifier { } void _sortFriendsCache() { - _sortedFriendsCache.sort((a, b) { - // Check for unreads and sort by latest message time if either has unreads - final aHasUnreads = friendHasUnreads(a); - final bHasUnreads = friendHasUnreads(b); - if (aHasUnreads || bHasUnreads) { - if (aHasUnreads && bHasUnreads) { - return -a.latestMessageTime.compareTo(b.latestMessageTime); - } + _sortedFriendsCache + ..removeWhere((element) => element.contactStatus != ContactStatus.accepted) + ..sort((a, b) { + // Check for unreads and sort by latest message time if either has unreads + final aHasUnreads = friendHasUnreads(a); + final bHasUnreads = friendHasUnreads(b); + if (aHasUnreads || bHasUnreads) { + if (aHasUnreads && bHasUnreads) { + return -a.latestMessageTime.compareTo(b.latestMessageTime); + } - return aHasUnreads ? -1 : 1; - } + return aHasUnreads ? -1 : 1; + } - final onlineStatusComparison = getOnlineStatusValue(a).compareTo(getOnlineStatusValue(b)); - if (onlineStatusComparison != 0) { - return onlineStatusComparison; - } + final onlineStatusComparison = getOnlineStatusValue(a).compareTo(getOnlineStatusValue(b)); + if (onlineStatusComparison != 0) { + return onlineStatusComparison; + } - return -a.latestMessageTime.compareTo(b.latestMessageTime); - }); + return -a.latestMessageTime.compareTo(b.latestMessageTime); + }); } Future _updateContacts(List friends) async { final box = Hive.box(_messageBoxKey); for (final friend in friends) { - await box.put(friend.id, friend.toMap()); + await box.put(friend.contactUserId, friend.toMap()); final lastStatusUpdate = box.get(_lastUpdateKey); if (lastStatusUpdate == null || friend.userStatus.lastStatusChange.isAfter(lastStatusUpdate)) { await box.put(_lastUpdateKey, friend.userStatus.lastStatusChange); } - final sIndex = _sortedFriendsCache.indexWhere((element) => element.id == friend.id); + final sIndex = _sortedFriendsCache.indexWhere((element) => element.contactUserId == friend.contactUserId); if (sIndex == -1) { _sortedFriendsCache.add(friend); } else { _sortedFriendsCache[sIndex] = friend; } - if (friend.id == selectedFriend?.id) { + if (friend.contactUserId == selectedFriend?.contactUserId) { selectedFriend = friend; } } @@ -297,18 +334,18 @@ class MessagingClient extends ChangeNotifier { Future _updateContact(Friend friend) async { final box = Hive.box(_messageBoxKey); - await box.put(friend.id, friend.toMap()); + await box.put(friend.contactUserId, friend.toMap()); final lastStatusUpdate = box.get(_lastUpdateKey); if (lastStatusUpdate == null || friend.userStatus.lastStatusChange.isAfter(lastStatusUpdate)) { await box.put(_lastUpdateKey, friend.userStatus.lastStatusChange); } - final sIndex = _sortedFriendsCache.indexWhere((element) => element.id == friend.id); + final sIndex = _sortedFriendsCache.indexWhere((element) => element.contactUserId == friend.contactUserId); if (sIndex == -1) { _sortedFriendsCache.add(friend); } else { _sortedFriendsCache[sIndex] = friend; } - if (friend.id == selectedFriend?.id) { + if (friend.contactUserId == selectedFriend?.contactUserId) { selectedFriend = friend; } _sortFriendsCache(); @@ -346,7 +383,7 @@ class MessagingClient extends ChangeNotifier { final msg = args[0]; final message = Message.fromMap(msg); (getUserMessageCache(message.senderId) ?? _createUserMessageCache(message.senderId)).addMessage(message); - if (message.senderId != selectedFriend?.id) { + if (message.senderId != selectedFriend?.contactUserId) { addUnread(message); requestUserStatus(message.senderId); } else { diff --git a/lib/hub_manager.dart b/lib/hub_manager.dart index f598001..432e08f 100644 --- a/lib/hub_manager.dart +++ b/lib/hub_manager.dart @@ -17,7 +17,7 @@ class HubManager { final Logger _logger = Logger("Hub"); final Map _headers = {}; final Map _handlers = {}; - final Map _responseHandlers = {}; + final Map _responseHandlers = {}; bool _disposed = false; FutureOr Function() onConnected = () {}; WebSocket? _wsChannel; @@ -32,7 +32,7 @@ class HubManager { _headers.addAll(headers); } - Future _onDisconnected(error) async { + Future _onDisconnected(dynamic error) async { _wsChannel = null; _logger.warning("Hub connection died with error '$error', reconnecting..."); await start(); @@ -69,7 +69,7 @@ class HubManager { } } - void _handleEvent(event) { + void _handleEvent(dynamic event) { if (_disposed) return; final bodies = event.toString().split(_eofChar); final eventBodies = bodies.whereNot((element) => element.isEmpty).map(jsonDecode); @@ -122,7 +122,7 @@ class HubManager { handler(args); } - void send(String target, {List arguments = const [], Function(Map data)? responseHandler}) { + void send(String target, {List arguments = const [], void Function(T data)? responseHandler}) { final invocationId = const Uuid().v4(); final data = { "type": EventType.invocation.index, @@ -130,13 +130,42 @@ class HubManager { "target": target, "arguments": arguments, }; + if (_wsChannel == null) throw "Resonite Hub is not connected"; if (responseHandler != null) { - _responseHandlers[invocationId] = responseHandler; + _responseHandlers[invocationId] = (d) => responseHandler(d); } - if (_wsChannel == null) throw "Resonite Hub is not connected"; _wsChannel!.add(jsonEncode(data) + _eofChar); } + Future sendAndWait(String target, {List arguments = const [], Duration timeout = const Duration(seconds: 5)}) async { + DateTime? startTime; + T? result; + Object? error; + var hasResponse = false; + while (!hasResponse && (startTime == null || DateTime.now().isBefore(startTime.add(timeout)))) { + if (startTime == null) { + startTime = DateTime.now(); + send( + target, + arguments: arguments, + responseHandler: (data) async { + try { + result = data as T; + } catch (e) { + error = e; + } + hasResponse = true; + }, + ); + } + await Future.delayed(const Duration(milliseconds: 100)); + } + if (error != null) { + throw error!; + } + return result; + } + void dispose() { _wsChannel?.close(); _disposed = true; diff --git a/lib/models/message.dart b/lib/models/message.dart index 19b5484..d32a8ea 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; -import 'package:recon/clients/api_client.dart'; import 'package:recon/apis/message_api.dart'; import 'package:recon/auxiliary.dart'; +import 'package:recon/clients/api_client.dart'; import 'package:recon/models/invite_request.dart'; import 'package:recon/string_formatter.dart'; import 'package:uuid/uuid.dart'; @@ -51,6 +51,7 @@ class Message implements Comparable { final String content; final FormatNode formattedContent; final DateTime sendTime; + final DateTime lastUpdateTime; final MessageState state; Message({ @@ -60,9 +61,11 @@ class Message implements Comparable { required this.type, required this.content, required DateTime sendTime, + DateTime? lastUpdateTime, required this.state, }) : formattedContent = FormatNode.fromText(content), - sendTime = sendTime.toUtc(); + sendTime = sendTime.toUtc(), + lastUpdateTime = lastUpdateTime?.toUtc() ?? sendTime.toUtc(); factory Message.fromMap(Map map, {MessageState? withState}) { final typeString = (map["messageType"] as String?) ?? ""; @@ -77,6 +80,7 @@ class Message implements Comparable { type: type, content: map["content"], sendTime: DateTime.parse(map["sendTime"]), + lastUpdateTime: DateTime.parse(map["lastUpdateTime"]), state: withState ?? (map["readTime"] != null ? MessageState.read : MessageState.sent), ); } @@ -146,7 +150,7 @@ class Message implements Comparable { @override int compareTo(covariant Message other) { - return other.sendTime.compareTo(sendTime); + return other.lastUpdateTime.compareTo(lastUpdateTime); } } @@ -208,8 +212,9 @@ class MessageCache { } void _ensureIntegrity() { - _messages.sort(); - _messages.unique((element) => element.id); + _messages + ..sort() + ..unique((element) => element.id); } } diff --git a/lib/models/users/friend.dart b/lib/models/users/friend.dart index 37a878e..71f79d6 100644 --- a/lib/models/users/friend.dart +++ b/lib/models/users/friend.dart @@ -7,27 +7,29 @@ import 'package:recon/models/users/user_status.dart'; class Friend implements Comparable { static const _emptyId = "-1"; static const _resoniteBotId = "U-Resonite"; - final String id; - final String username; + final String contactUserId; + final String contactUsername; final String ownerId; final UserStatus userStatus; final UserProfile userProfile; - final FriendStatus contactStatus; + final ContactStatus contactStatus; final DateTime latestMessageTime; + final bool isAccepted; const Friend({ - required this.id, - required this.username, + required this.contactUserId, + required this.contactUsername, required this.ownerId, required this.userStatus, required this.userProfile, required this.contactStatus, required this.latestMessageTime, + required this.isAccepted, }); bool get isHeadless => userStatus.sessionType == UserSessionType.headless; - bool get isBot => userStatus.sessionType == UserSessionType.bot || id == _resoniteBotId; + bool get isBot => userStatus.sessionType == UserSessionType.bot || contactUserId == _resoniteBotId; bool get isSociable => (userStatus.onlineStatus == OnlineStatus.sociable) && !isBot && !isHeadless; @@ -38,14 +40,15 @@ class Friend implements Comparable { factory Friend.fromMap(Map map) { final userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]); return Friend( - id: map["id"], - username: map["contactUsername"], + contactUserId: map["id"], + contactUsername: map["contactUsername"], ownerId: map["ownerId"] ?? map["id"], // Resonite bot status is always offline but should be displayed as online userStatus: map["id"] == _resoniteBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus, userProfile: UserProfile.fromMap(map["profile"] ?? {}), - contactStatus: FriendStatus.fromString(map["contactStatus"]), + contactStatus: ContactStatus.fromString(map["contactStatus"]), latestMessageTime: map["latestMessageTime"] == null ? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(map["latestMessageTime"]), + isAccepted: map["isAccepted"] ?? false, ); } @@ -56,52 +59,56 @@ class Friend implements Comparable { factory Friend.empty() { return Friend( - id: _emptyId, - username: "", + contactUserId: _emptyId, + contactUsername: "", ownerId: "", userStatus: UserStatus.empty(), userProfile: UserProfile.empty(), - contactStatus: FriendStatus.none, + contactStatus: ContactStatus.none, latestMessageTime: DateTimeX.epoch, + isAccepted: false, ); } - bool get isEmpty => id == _emptyId; + bool get isEmpty => contactUserId == _emptyId; Friend copyWith({ - String? id, - String? username, + String? contactUserId, + String? contactUsername, String? ownerId, UserStatus? userStatus, UserProfile? userProfile, - FriendStatus? contactStatus, + ContactStatus? contactStatus, DateTime? latestMessageTime, + bool? isAccepted, }) { return Friend( - id: id ?? this.id, - username: username ?? this.username, + contactUserId: contactUserId ?? this.contactUserId, + contactUsername: contactUsername ?? this.contactUsername, ownerId: ownerId ?? this.ownerId, userStatus: userStatus ?? this.userStatus, userProfile: userProfile ?? this.userProfile, contactStatus: contactStatus ?? this.contactStatus, latestMessageTime: latestMessageTime ?? this.latestMessageTime, + isAccepted: isAccepted ?? this.isAccepted, ); } Map toMap({bool shallow = false}) { return { - "id": id, - "contactUsername": username, + "id": contactUserId, + "contactUsername": contactUsername, "ownerId": ownerId, "userStatus": userStatus.toMap(shallow: shallow), "profile": userProfile.toMap(), "contactStatus": contactStatus.name, "latestMessageTime": latestMessageTime.toUtc().toIso8601String(), + "isAccepted": isAccepted, }; } @override int compareTo(covariant Friend other) { - return username.compareTo(other.username); + return contactUsername.compareTo(other.contactUsername); } } diff --git a/lib/models/users/friend_status.dart b/lib/models/users/friend_status.dart index 1177b2d..57bfe28 100644 --- a/lib/models/users/friend_status.dart +++ b/lib/models/users/friend_status.dart @@ -1,4 +1,4 @@ -enum FriendStatus { +enum ContactStatus { none, searchResult, requested, @@ -6,9 +6,9 @@ enum FriendStatus { blocked, accepted; - factory FriendStatus.fromString(String text) { - return FriendStatus.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(), - orElse: () => FriendStatus.none, + factory ContactStatus.fromString(String text) { + return ContactStatus.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(), + orElse: () => ContactStatus.none, ); } } diff --git a/lib/widgets/friends/friend_list_tile.dart b/lib/widgets/friends/friend_list_tile.dart index 36dc4e4..35cd89a 100644 --- a/lib/widgets/friends/friend_list_tile.dart +++ b/lib/widgets/friends/friend_list_tile.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -12,20 +14,17 @@ import 'package:recon/widgets/generic_avatar.dart'; import 'package:recon/widgets/messages/messages_list.dart'; class FriendListTile extends StatelessWidget { - const FriendListTile({required this.friend, required this.unreads, this.onTap, super.key}); + const FriendListTile({required this.friend, required this.unreads, super.key}); final Friend friend; final int unreads; - final Function? onTap; @override Widget build(BuildContext context) { final imageUri = Aux.resdbToHttp(friend.userProfile.iconUrl); final theme = Theme.of(context); final mClient = Provider.of(context, listen: false); - final currentSession = friend.userStatus.currentSessionIndex == -1 - ? null - : friend.userStatus.decodedSessions.elementAtOrNull(friend.userStatus.currentSessionIndex); + final currentSession = friend.userStatus.currentSessionIndex == -1 ? null : friend.userStatus.decodedSessions.elementAtOrNull(friend.userStatus.currentSessionIndex); return ListTile( leading: GenericAvatar( imageUri: imageUri, @@ -38,7 +37,7 @@ class FriendListTile extends StatelessWidget { : null, title: Row( children: [ - Text(friend.username), + Text(friend.contactUsername), if (friend.isHeadless) Padding( padding: const EdgeInsets.only(left: 8), @@ -77,7 +76,7 @@ class FriendListTile extends StatelessWidget { overflow: TextOverflow.ellipsis, maxLines: 1, ), - ) + ), ] else if (friend.userStatus.appVersion.isNotEmpty) Expanded( child: Text( @@ -103,21 +102,26 @@ class FriendListTile extends StatelessWidget { style: theme.textTheme.bodyMedium?.copyWith( color: const Color.fromARGB(255, 41, 77, 92), ), - ) + ), ], ), onTap: () async { - onTap?.call(); - mClient.loadUserMessageCache(friend.id); - final unreads = mClient.getUnreadsForFriend(friend); - if (unreads.isNotEmpty) { - final readBatch = MarkReadBatch( - senderId: friend.id, - ids: unreads.map((e) => e.id).toList(), - readTime: DateTime.now(), - ); - mClient.markMessagesRead(readBatch); - } + unawaited( + Future( + () async { + await mClient.loadUserMessageCache(friend.contactUserId); + final unreads = mClient.getUnreadsForFriend(friend); + if (unreads.isNotEmpty) { + final readBatch = MarkReadBatch( + senderId: friend.contactUserId, + ids: unreads.map((e) => e.id).toList(), + readTime: DateTime.now(), + ); + mClient.markMessagesRead(readBatch); + } + }, + ), + ); mClient.selectedFriend = friend; await Navigator.of(context).push( MaterialPageRoute( diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index db6266d..e6c778d 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -45,8 +45,8 @@ class _FriendsListState extends State with AutomaticKeepAliveClient } else { var friends = List.from(mClient.cachedFriends); // Explicit copy. if (_searchFilter.isNotEmpty) { - friends = friends.where((element) => element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList() - ..sort((a, b) => a.username.length.compareTo(b.username.length)); + friends = friends.where((element) => element.contactUsername.toLowerCase().contains(_searchFilter.toLowerCase())).toList() + ..sort((a, b) => a.contactUsername.length.compareTo(b.contactUsername.length)); } return RefreshIndicator( onRefresh: () async { diff --git a/lib/widgets/friends/user_list_tile.dart b/lib/widgets/friends/user_list_tile.dart index a21de3c..8fba5bf 100644 --- a/lib/widgets/friends/user_list_tile.dart +++ b/lib/widgets/friends/user_list_tile.dart @@ -1,17 +1,20 @@ -import 'package:recon/apis/contact_api.dart'; +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import 'package:recon/auxiliary.dart'; -import 'package:recon/client_holder.dart'; +import 'package:recon/clients/messaging_client.dart'; +import 'package:recon/models/users/friend.dart'; +import 'package:recon/models/users/friend_status.dart'; import 'package:recon/models/users/user.dart'; import 'package:recon/widgets/generic_avatar.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; class UserListTile extends StatefulWidget { - const UserListTile({required this.user, required this.isFriend, required this.onChanged, super.key}); + const UserListTile({required this.user, required this.onChanged, super.key}); final User user; - final bool isFriend; - final Function()? onChanged; + final FutureOr Function()? onChanged; @override State createState() => _UserListTileState(); @@ -19,78 +22,137 @@ class UserListTile extends StatefulWidget { class _UserListTileState extends State { final DateFormat _regDateFormat = DateFormat.yMMMMd('en_US'); - late bool _localAdded = widget.isFriend; + ContactStatus? _statusOverride; bool _loading = false; + Friend? _asFriend; + + ContactStatus get _status => _asFriend?.contactStatus ?? ContactStatus.searchResult; + + @override + void didUpdateWidget(covariant UserListTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.user.id != widget.user.id) { + final mClient = Provider.of(context, listen: false); + _asFriend = mClient.getAsFriend(widget.user.id); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final mClient = Provider.of(context, listen: false); + _asFriend = mClient.getAsFriend(widget.user.id); + } @override Widget build(BuildContext context) { - final colorScheme = Theme - .of(context) - .colorScheme; - final style = _localAdded ? IconButton.styleFrom( - foregroundColor: colorScheme.onSurface, - side: BorderSide( - color: colorScheme.error, - width: 2 - ), - ) : IconButton.styleFrom( - foregroundColor: colorScheme.onSurface, - side: BorderSide( - color: colorScheme.primary, - width: 2 + final mClient = Provider.of(context, listen: false); + final colorScheme = Theme.of(context).colorScheme; + final (icon, style, action) = switch (_statusOverride ?? _status) { + ContactStatus.none || ContactStatus.searchResult || ContactStatus.ignored => ( + const Icon(Icons.person_add), + IconButton.styleFrom( + foregroundColor: colorScheme.onSurface, + side: BorderSide( + color: colorScheme.primary, + width: 2, + ), + ), + () async { + final success = await mClient.addContact(widget.user); + if (success) { + setState(() { + _statusOverride = ContactStatus.accepted; + }); + } + return success; + }, ), - ); + ContactStatus.requested || ContactStatus.accepted => ( + Icon(Icons.person_remove), + IconButton.styleFrom( + foregroundColor: colorScheme.onSurface, + side: BorderSide( + color: colorScheme.error, + width: 2, + ), + ), + () async { + if (_asFriend == null) { + throw "This user is not on your friends-list"; + } + final success = await mClient.removeContact(_asFriend!); + if (success) { + setState(() { + _statusOverride = ContactStatus.ignored; + }); + } + }, + ), + ContactStatus.blocked => ( + Icon(Icons.block), + IconButton.styleFrom( + foregroundColor: colorScheme.onSurface, + side: BorderSide( + color: colorScheme.error, + width: 2, + ), + ), + null, + ), + }; return ListTile( - leading: GenericAvatar(imageUri: Aux.resdbToHttp(widget.user.userProfile?.iconUrl),), + leading: GenericAvatar( + imageUri: Aux.resdbToHttp(widget.user.userProfile?.iconUrl), + ), title: Text(widget.user.username), subtitle: Text(_regDateFormat.format(widget.user.registrationDate)), trailing: IconButton( splashRadius: 24, iconSize: 20, - icon: _localAdded ? const Icon(Icons.person_remove) : const Icon(Icons.person_add), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _loading + ? SizedBox.square( + key: ValueKey("loading"), + dimension: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : icon, + ), style: style, - onPressed: _loading ? null : () async { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Sorry, this feature is not yet available"))); - return; - setState(() { - _loading = true; - }); - try { - if (_localAdded) { - await ContactApi.removeUserAsFriend(ClientHolder - .of(context) - .apiClient, user: widget.user); - } else { - await ContactApi.addUserAsFriend(ClientHolder - .of(context) - .apiClient, user: widget.user); - } - setState(() { - _loading = false; - _localAdded = !_localAdded; - }); - widget.onChanged?.call(); - } catch (e, s) { - FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - duration: const Duration(seconds: 5), - content: Text( - "Something went wrong: $e", - softWrap: true, - maxLines: null, - ), - ), - ); - } - setState(() { - _loading = false; - }); - return; - } - }, + onPressed: _loading + ? null + : () async { + setState(() { + _loading = true; + }); + try { + final result = await action?.call() ?? false; + if (result) { + await widget.onChanged?.call(); + } + } catch (e, s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Failed to add contact:\n$e", + softWrap: true, + maxLines: null, + ), + ), + ); + } + } + setState(() { + _loading = false; + }); + }, ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/friends/user_search.dart b/lib/widgets/friends/user_search.dart index fa36fe2..e0876da 100644 --- a/lib/widgets/friends/user_search.dart +++ b/lib/widgets/friends/user_search.dart @@ -1,13 +1,13 @@ import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:recon/apis/user_api.dart'; import 'package:recon/client_holder.dart'; import 'package:recon/clients/messaging_client.dart'; import 'package:recon/models/users/user.dart'; import 'package:recon/widgets/default_error_widget.dart'; import 'package:recon/widgets/friends/user_list_tile.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; class SearchError { final String message; @@ -28,25 +28,17 @@ class _UserSearchState extends State { Timer? _searchDebouncer; late Future?>? _usersFuture = _emptySearch; - Future> get _emptySearch => - Future(() => - throw const SearchError( - message: "Start typing to search for users", icon: Icons.search) - ); + Future> get _emptySearch => Future(() => throw const SearchError(message: "Start typing to search for users", icon: Icons.search)); void _querySearch(BuildContext context, String needle) { if (needle.isEmpty) { _usersFuture = _emptySearch; return; } - _usersFuture = UserApi.searchUsers(ClientHolder - .of(context) - .apiClient, needle: needle).then((value) { + _usersFuture = UserApi.searchUsers(ClientHolder.of(context).apiClient, needle: needle).then((value) { final res = value.toList(); if (res.isEmpty) throw SearchError(message: "No user found with username '$needle'", icon: Icons.search_off); - res.sort( - (a, b) => a.username.length.compareTo(b.username.length) - ); + res.sort((a, b) => a.username.length.compareTo(b.username.length)); return res; }); } @@ -67,14 +59,17 @@ class _UserSearchState extends State { future: _usersFuture, builder: (context, snapshot) { if (snapshot.hasData) { - final users = snapshot.data as List; + final users = snapshot.data!; return ListView.builder( itemCount: users.length, itemBuilder: (context, index) { final user = users[index]; - return UserListTile(user: user, onChanged: () { - mClient.refreshFriendsList(); - }, isFriend: mClient.getAsFriend(user.id) != null,); + return UserListTile( + user: user, + onChanged: () { + mClient.refreshFriendsList(); + }, + ); }, ); } else if (snapshot.hasError) { @@ -85,9 +80,10 @@ class _UserSearchState extends State { iconOverride: err.icon, ); } else { - FlutterError.reportError( - FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); - return DefaultErrorWidget(title: "${snapshot.error}",); + FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); + return DefaultErrorWidget( + title: "${snapshot.error}", + ); } } else { return const Column( @@ -103,16 +99,14 @@ class _UserSearchState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: TextField( decoration: InputDecoration( - isDense: true, - hintText: "Search for users...", - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24) - ) + isDense: true, + hintText: "Search for users...", + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), ), autocorrect: false, controller: _searchInputController, - onChanged: (String value) { + onChanged: (value) { _searchDebouncer?.cancel(); if (value.isEmpty) { setState(() { @@ -136,4 +130,4 @@ class _UserSearchState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart index 53ddb86..05fddd2 100644 --- a/lib/widgets/messages/message_input_bar.dart +++ b/lib/widgets/messages/message_input_bar.dart @@ -44,7 +44,7 @@ class _MessageInputBarState extends State { bool get _isRecording => _recordingStartTime != null; - set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null; + set _isRecording(bool value) => _recordingStartTime = value ? DateTime.now() : null; bool _recordingCancelled = false; @override @@ -58,7 +58,7 @@ class _MessageInputBarState extends State { if (content.isEmpty) return; final message = Message( id: Message.generateId(), - recipientId: widget.recipient.id, + recipientId: widget.recipient.contactUserId, senderId: client.userId, type: MessageType.text, content: content, @@ -83,7 +83,7 @@ class _MessageInputBarState extends State { ); final message = Message( id: record.extractMessageId() ?? Message.generateId(), - recipientId: widget.recipient.id, + recipientId: widget.recipient.contactUserId, senderId: client.userId, type: MessageType.object, content: jsonEncode(record.toMap()), @@ -108,7 +108,7 @@ class _MessageInputBarState extends State { ); final message = Message( id: record.extractMessageId() ?? Message.generateId(), - recipientId: widget.recipient.id, + recipientId: widget.recipient.contactUserId, senderId: client.userId, type: MessageType.sound, content: jsonEncode(record.toMap()), @@ -133,7 +133,7 @@ class _MessageInputBarState extends State { ); final message = Message( id: record.extractMessageId() ?? Message.generateId(), - recipientId: widget.recipient.id, + recipientId: widget.recipient.contactUserId, senderId: client.userId, type: MessageType.object, content: jsonEncode(record.toMap()), @@ -501,7 +501,7 @@ class _MessageInputBarState extends State { style: Theme.of(context).textTheme.bodyLarge, decoration: InputDecoration( isDense: true, - hintText: _isRecording ? "" : "Message ${widget.recipient.username}...", + hintText: _isRecording ? "" : "Message ${widget.recipient.contactUsername}...", hintMaxLines: 1, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), fillColor: Colors.black26, diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 901d40a..3bd8a66 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -57,7 +57,7 @@ class _MessagesListState extends State with SingleTickerProviderSt return Consumer( builder: (context, mClient, _) { final friend = mClient.selectedFriend ?? Friend.empty(); - final cache = mClient.getUserMessageCache(friend.id); + final cache = mClient.getUserMessageCache(friend.contactUserId); final sessions = friend.userStatus.decodedSessions.where((element) => element.isVisible).toList(); return Scaffold( appBar: AppBar( @@ -68,7 +68,7 @@ class _MessagesListState extends State with SingleTickerProviderSt const SizedBox( width: 8, ), - Text(friend.username), + Text(friend.contactUsername), if (friend.isHeadless) Padding( padding: const EdgeInsets.only(left: 12), @@ -88,7 +88,7 @@ class _MessagesListState extends State with SingleTickerProviderSt builder: (context) { return AlertDialog( title: const Text("Ask for session invite"), - content: Text("Do you want to ask ${friend.username} for a session invite?"), + content: Text("Do you want to ask ${friend.contactUsername} for a session invite?"), actionsAlignment: MainAxisAlignment.spaceBetween, actions: [ TextButton( @@ -110,9 +110,9 @@ class _MessagesListState extends State with SingleTickerProviderSt final self = await UserApi.getUser(clientHolder.apiClient, userId: clientHolder.apiClient.userId); final message = Message.inviteRequest( senderId: clientHolder.apiClient.userId, - recipientId: friend.id, + recipientId: friend.contactUserId, senderName: self.username, - recipientName: friend.username, + recipientName: friend.contactUsername, ); mClient.sendMessage(message); } catch (e) { @@ -212,9 +212,9 @@ class _MessagesListState extends State with SingleTickerProviderSt message: cache.error.toString(), onRetry: () { setState(() { - mClient.deleteUserMessageCache(friend.id); + mClient.deleteUserMessageCache(friend.contactUserId); }); - mClient.loadUserMessageCache(friend.id); + mClient.loadUserMessageCache(friend.contactUserId); }, ); } diff --git a/pubspec.yaml b/pubspec.yaml index e5f3ff1..296e32b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.12.0-beta +version: 0.12.1-beta environment: sdk: ">=3.0.1"