diff --git a/apps/enmeshed/lib/account/contacts/contacts_view.dart b/apps/enmeshed/lib/account/contacts/contacts_view.dart index cd218bb54..3cf958558 100644 --- a/apps/enmeshed/lib/account/contacts/contacts_view.dart +++ b/apps/enmeshed/lib/account/contacts/contacts_view.dart @@ -361,29 +361,39 @@ class _ContactItem extends StatelessWidget { return DismissibleContactItem( contact: contact, + request: item.openContactRequest, onTap: () => _onTap(context), - trailing: - item.openContactRequest != null - ? const Padding(padding: EdgeInsets.all(8), child: Icon(Icons.edit)) - : IconButton( - icon: isFavoriteContact ? const Icon(Icons.star) : const Icon(Icons.star_border), - color: isFavoriteContact ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.shadow, - onPressed: () => toggleContactFavorite(contact), - ), + isFavoriteContact: isFavoriteContact, + onToggleFavorite: () => toggleContactFavorite(contact), onDeletePressed: _onDeletePressed, ); } - void _onTap(BuildContext context) { + Future _onTap(BuildContext context) async { final contact = item.contact; if (item.openContactRequest == null) { - context.push('/account/$accountId/contacts/${contact.id}'); + unawaited(context.push('/account/$accountId/contacts/${contact.id}')); return; } + final session = GetIt.I.get().getSession(accountId); final request = item.openContactRequest!; - context.go('/account/$accountId/contacts/contact-request/${request.id}', extra: request); + + final validateRelationshipCreationResponse = await validateRelationshipCreation(accountId: accountId, request: request, session: session); + + if (!context.mounted) return; + + if (validateRelationshipCreationResponse.success) return context.go('/account/$accountId/contacts/contact-request/${request.id}', extra: request); + + final result = await showDialog( + context: context, + builder: (context) => CreateRelationshipErrorDialog(errorCode: validateRelationshipCreationResponse.errorCode!), + ); + + if (!context.mounted) return; + + if (result ?? false) await _onDeletePressed(context); } Future _onDeletePressed(BuildContext context) async { @@ -395,6 +405,20 @@ class _ContactItem extends StatelessWidget { } final request = item.openContactRequest!; + final session = GetIt.I.get().getSession(accountId); + + if (request.status == LocalRequestStatus.Expired) { + final deleteResult = await session.consumptionServices.incomingRequests.delete(requestId: request.id); + + if (deleteResult.isError) { + GetIt.I.get().e(deleteResult.error); + + if (!context.mounted) return; + + showErrorSnackbar(context: context, text: context.l10n.error_deleteRequestFailed); + } + return; + } final rejectItems = List.from( request.items.map((e) { @@ -409,7 +433,6 @@ class _ContactItem extends StatelessWidget { final rejectParams = DecideRequestParameters(requestId: request.id, items: rejectItems); - final session = GetIt.I.get().getSession(accountId); final result = await session.consumptionServices.incomingRequests.reject(params: rejectParams); if (result.isError) GetIt.I.get().e(result.error); } diff --git a/apps/enmeshed/lib/account/contacts/request_screen.dart b/apps/enmeshed/lib/account/contacts/request_screen.dart index 1bea79b48..207f2358c 100644 --- a/apps/enmeshed/lib/account/contacts/request_screen.dart +++ b/apps/enmeshed/lib/account/contacts/request_screen.dart @@ -1,17 +1,31 @@ +import 'package:enmeshed_runtime_bridge/enmeshed_runtime_bridge.dart'; import 'package:enmeshed_types/enmeshed_types.dart'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:logger/logger.dart'; import '/core/core.dart'; -class RequestScreen extends StatelessWidget { +class RequestScreen extends StatefulWidget { final String accountId; final String requestId; final bool isIncoming; - final LocalRequestDVO? requestDVO; - const RequestScreen({required this.accountId, required this.requestId, required this.isIncoming, super.key, this.requestDVO}); + const RequestScreen({required this.accountId, required this.requestId, required this.isIncoming, this.requestDVO, super.key}); + + @override + State createState() => _RequestScreenState(); +} + +class _RequestScreenState extends State { + @override + void initState() { + super.initState(); + + _validateCreateRelationship(); + } @override Widget build(BuildContext context) { @@ -20,18 +34,60 @@ class RequestScreen extends StatelessWidget { appBar: AppBar(title: Text(context.l10n.contact_request)), body: SafeArea( child: RequestDVORenderer( - accountId: accountId, - requestId: requestId, - isIncoming: isIncoming, - requestDVO: requestDVO, + accountId: widget.accountId, + requestId: widget.requestId, + isIncoming: widget.isIncoming, + requestDVO: widget.requestDVO, acceptRequestText: context.l10n.home_addContact, validationErrorDescription: context.l10n.contact_request_validationErrorDescription, onAfterAccept: () { - if (context.mounted) context.go('/account/$accountId/contacts'); + if (context.mounted) context.go('/account/${widget.accountId}/contacts'); }, description: context.l10n.contact_requestDescription, + validateCreateRelationship: _validateCreateRelationship, ), ), ); } + + Future _validateCreateRelationship() async { + final session = GetIt.I.get().getSession(widget.accountId); + + final validateRelationshipCreationResponse = await validateRelationshipCreation( + accountId: widget.accountId, + request: widget.requestDVO, + session: session, + ); + + if (validateRelationshipCreationResponse.success) return true; + + if (!mounted) return false; + + final result = await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => CreateRelationshipErrorDialog(errorCode: validateRelationshipCreationResponse.errorCode!), + ); + + if (result ?? false) await _deleteRequest(); + + if (mounted) context.pop(); + + return false; + } + + Future _deleteRequest() async { + if (widget.requestDVO == null) showErrorSnackbar(context: context, text: context.l10n.error_deleteRequestFailed); + + final session = GetIt.I.get().getSession(widget.accountId); + final deleteResult = await session.consumptionServices.incomingRequests.delete(requestId: widget.requestDVO!.id); + + if (deleteResult.isError) { + GetIt.I.get().e(deleteResult.error); + + if (!mounted) return; + + showErrorSnackbar(context: context, text: context.l10n.error_deleteRequestFailed); + } + } } diff --git a/apps/enmeshed/lib/account/contacts/widgets/dismissible_contact_item.dart b/apps/enmeshed/lib/account/contacts/widgets/dismissible_contact_item.dart index bcfb82f5c..1ccd465d3 100644 --- a/apps/enmeshed/lib/account/contacts/widgets/dismissible_contact_item.dart +++ b/apps/enmeshed/lib/account/contacts/widgets/dismissible_contact_item.dart @@ -8,6 +8,9 @@ class DismissibleContactItem extends StatefulWidget { final IdentityDVO contact; final VoidCallback onTap; final void Function(BuildContext) onDeletePressed; + final bool isFavoriteContact; + final VoidCallback onToggleFavorite; + final LocalRequestDVO? request; final Widget? trailing; final Widget? subtitle; final String? query; @@ -17,6 +20,9 @@ class DismissibleContactItem extends StatefulWidget { required this.contact, required this.onTap, required this.onDeletePressed, + required this.isFavoriteContact, + required this.onToggleFavorite, + this.request, this.trailing, this.subtitle, this.query, @@ -53,8 +59,15 @@ class _DismissibleContactItemState extends State with Si @override Widget build(BuildContext context) { final coloringStatus = [RelationshipStatus.Terminated, RelationshipStatus.DeletionProposed]; + + final subtitle = + widget.request?.status == LocalRequestStatus.Expired + ? Text(context.l10n.contacts_requestExpired, style: TextStyle(color: Theme.of(context).colorScheme.error)) + : null; + final tileColor = - widget.contact.relationship == null || coloringStatus.contains(widget.contact.relationship!.status) + (widget.contact.relationship == null && widget.request?.status != LocalRequestStatus.Expired) || + coloringStatus.contains(widget.contact.relationship?.status) ? Theme.of(context).colorScheme.primaryContainer : null; @@ -86,8 +99,15 @@ class _DismissibleContactItemState extends State with Si widget.onTap(); _slidableController.close(); }, - trailing: widget.trailing, - subtitle: widget.subtitle, + trailing: + widget.trailing ?? + _TrailingIcon( + request: widget.request, + isFavoriteContact: widget.isFavoriteContact, + onToggleFavorite: widget.onToggleFavorite, + onDeletePressed: () => widget.onDeletePressed, + ), + subtitle: widget.subtitle ?? subtitle, query: widget.query, iconSize: widget.iconSize, ), @@ -96,3 +116,25 @@ class _DismissibleContactItemState extends State with Si ); } } + +class _TrailingIcon extends StatelessWidget { + final bool isFavoriteContact; + final VoidCallback onDeletePressed; + final VoidCallback onToggleFavorite; + final LocalRequestDVO? request; + + const _TrailingIcon({required this.isFavoriteContact, required this.onDeletePressed, required this.onToggleFavorite, this.request}); + + @override + Widget build(BuildContext context) { + if (request?.status == LocalRequestStatus.Expired) return IconButton(icon: const Icon(Icons.cancel_outlined), onPressed: onDeletePressed); + + if (request != null) return const Padding(padding: EdgeInsets.all(8), child: Icon(Icons.edit)); + + return IconButton( + icon: isFavoriteContact ? const Icon(Icons.star) : const Icon(Icons.star_border), + color: isFavoriteContact ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurfaceVariant, + onPressed: onToggleFavorite, + ); + } +} diff --git a/apps/enmeshed/lib/account/mailbox/message_detail_screen.dart b/apps/enmeshed/lib/account/mailbox/message_detail_screen.dart index e87950488..7425f3ad1 100644 --- a/apps/enmeshed/lib/account/mailbox/message_detail_screen.dart +++ b/apps/enmeshed/lib/account/mailbox/message_detail_screen.dart @@ -207,6 +207,7 @@ class _RequestInformationState extends State<_RequestInformation> { validationErrorDescription: context.l10n.message_request_validationErrorDescription, showHeader: false, onAfterAccept: () => setState(() => useRequestFromMessage = false), + validateCreateRelationship: null, ), ); } diff --git a/apps/enmeshed/lib/core/modals/create_relationship_error_dialog.dart b/apps/enmeshed/lib/core/modals/create_relationship_error_dialog.dart new file mode 100644 index 000000000..894ec0f8f --- /dev/null +++ b/apps/enmeshed/lib/core/modals/create_relationship_error_dialog.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '/core/utils/extensions.dart'; + +class CreateRelationshipErrorDialog extends StatelessWidget { + final String errorCode; + + const CreateRelationshipErrorDialog({required this.errorCode, super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: _icon(context), + title: Text(_title(context)), + content: Text(_content(context), textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium), + actions: [ + FilledButton( + onPressed: errorCode == 'error.transport.relationships.relationshipTemplateIsExpired' ? () => context.pop(true) : () => context.pop(false), + child: Text( + errorCode == 'error.transport.relationships.relationshipTemplateIsExpired' + ? context.l10n.error_deleteRequest + : context.l10n.error_understood, + ), + ), + ], + actionsAlignment: MainAxisAlignment.center, + ); + } + + Icon _icon(BuildContext context) => switch (errorCode) { + 'error.transport.relationships.activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate' => Icon( + Icons.cancel, + color: Theme.of(context).colorScheme.error, + ), + _ => Icon(Icons.error, color: Theme.of(context).colorScheme.error), + }; + + String _title(BuildContext context) => switch (errorCode) { + 'error.transport.relationships.relationshipNotYetDecomposedByPeer' => context.l10n.errorDialog_relationshipNotYetDecomposedByPeer_title, + 'error.transport.relationships.activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate' => + context.l10n.errorDialog_activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate_title, + 'error.transport.relationships.relationshipTemplateIsExpired' => context.l10n.errorDialog_relationshipTemplateIsExpired_title, + 'error.relationshipTemplateProcessedModule.requestExpired' => context.l10n.errorDialog_requestExpired_title, + _ => context.l10n.errorDialog_title, + }; + + String _content(BuildContext context) => switch (errorCode) { + 'error.transport.relationships.relationshipNotYetDecomposedByPeer' => context.l10n.errorDialog_relationshipNotYetDecomposedByPeer_description, + 'error.transport.relationships.activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate' => + context.l10n.errorDialog_activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate_description, + 'error.transport.relationships.relationshipTemplateIsExpired' => context.l10n.errorDialog_relationshipTemplateIsExpired_description, + 'error.relationshipTemplateProcessedModule.requestExpired' => context.l10n.errorDialog_requestExpired_description, + _ => context.l10n.errorDialog_description, + }; +} diff --git a/apps/enmeshed/lib/core/modals/modals.dart b/apps/enmeshed/lib/core/modals/modals.dart index 909450f74..ab76249da 100644 --- a/apps/enmeshed/lib/core/modals/modals.dart +++ b/apps/enmeshed/lib/core/modals/modals.dart @@ -1,5 +1,6 @@ export 'create_attribute.dart'; export 'create_recovery_kit/create_recovery_kit.dart'; +export 'create_relationship_error_dialog.dart'; export 'delete_attribute.dart'; export 'delete_local_data.dart'; export 'enter_password/enter_password.dart'; diff --git a/apps/enmeshed/lib/core/utils/contact_utils.dart b/apps/enmeshed/lib/core/utils/contact_utils.dart index 55beaf562..a23d1b81f 100644 --- a/apps/enmeshed/lib/core/utils/contact_utils.dart +++ b/apps/enmeshed/lib/core/utils/contact_utils.dart @@ -82,7 +82,11 @@ Future> getContacts({required Session session}) async { Future> incomingOpenRequestsFromRelationshipTemplate({required Session session}) async { final incomingRequestResult = await session.consumptionServices.incomingRequests.getRequests( query: { - 'status': QueryValue.stringList([LocalRequestStatus.DecisionRequired.name, LocalRequestStatus.ManualDecisionRequired.name]), + 'status': QueryValue.stringList([ + LocalRequestStatus.DecisionRequired.name, + LocalRequestStatus.ManualDecisionRequired.name, + LocalRequestStatus.Expired.name, + ]), 'source.type': QueryValue.string(LocalRequestSourceType.RelationshipTemplate.name), }, ); @@ -147,3 +151,21 @@ Future deleteContact({ onContactDeleted(); } + +Future<({bool success, String? errorCode})> validateRelationshipCreation({ + required String accountId, + required Session session, + LocalRequestDVO? request, +}) async { + if (request == null || request.peer.hasRelationship || request.source?.type != LocalRequestSourceType.RelationshipTemplate) { + return (success: true, errorCode: null); + } + + final response = await session.transportServices.relationships.canCreateRelationship(templateId: request.source!.reference); + + if (response.value.isSuccess) return (success: true, errorCode: null); + + final failureResponse = response.value as CanCreateRelationshipFailureResponse; + + return (success: false, errorCode: failureResponse.code); +} diff --git a/apps/enmeshed/lib/core/widgets/request_dvo_renderer.dart b/apps/enmeshed/lib/core/widgets/request_dvo_renderer.dart index 2b2f28908..c0ed1d04f 100644 --- a/apps/enmeshed/lib/core/widgets/request_dvo_renderer.dart +++ b/apps/enmeshed/lib/core/widgets/request_dvo_renderer.dart @@ -26,6 +26,7 @@ class RequestDVORenderer extends StatefulWidget { final bool showHeader; final LocalRequestDVO? requestDVO; final String? description; + final Future Function()? validateCreateRelationship; const RequestDVORenderer({ required this.accountId, @@ -34,6 +35,7 @@ class RequestDVORenderer extends StatefulWidget { required this.acceptRequestText, required this.validationErrorDescription, required this.onAfterAccept, + required this.validateCreateRelationship, this.showHeader = true, this.requestDVO, this.description, @@ -165,7 +167,7 @@ class _RequestDVORendererState extends State { children: [ OutlinedButton(onPressed: _loading && _request != null ? null : _rejectRequest, child: Text(context.l10n.reject)), Gaps.w8, - FilledButton(onPressed: _acceptRequest, child: Text(widget.acceptRequestText)), + FilledButton(onPressed: _onAcceptButtonPressed, child: Text(widget.acceptRequestText)), ], ), ), @@ -194,6 +196,14 @@ class _RequestDVORendererState extends State { void _setController(Session session, LocalRequestDVO request) => _controller = RequestRendererController(request: request); + Future _onAcceptButtonPressed() async { + final canCreateRelationship = await widget.validateCreateRelationship?.call(); + + if (canCreateRelationship == false) return; + + await _acceptRequest(); + } + Future _acceptRequest() async { if (_loading) return; diff --git a/apps/enmeshed/lib/l10n/app_de.arb b/apps/enmeshed/lib/l10n/app_de.arb index 554cbcbbd..475ab0ee7 100644 --- a/apps/enmeshed/lib/l10n/app_de.arb +++ b/apps/enmeshed/lib/l10n/app_de.arb @@ -33,6 +33,14 @@ "errorDialog_invalidQRCode_description": "Der gescannte QR-Code wird von dieser App nicht unterstüzt. Falls Ihnen ein alternativer QR-Code zur Verfügung steht, nutzen Sie diesen.\n\nStellen Sie sicher, dass Sie die neueste Version der App verwenden. Gehen Sie zum Store und aktualisieren Sie die App, falls nötig.", "errorDialog_QRCodeProcessingFailed_title": "Fehler bei der Verarbeitung des QR-Codes", "errorDialog_QRCodeProcessingFailed_description": "Der gescannte QR-Code konnte nicht verarbeitet werden. Falls Ihnen ein alternativer QR-Code zur Verfügung steht, nutzen Sie diesen.\n\nStellen Sie sicher, dass Sie die neueste Version der App verwenden. Gehen Sie zum Store und aktualisieren Sie die App, falls nötig.", + "errorDialog_relationshipNotYetDecomposedByPeer_title": "Sie können mit diesem Kontakt aktuell keine erneute Beziehung eingehen.", + "errorDialog_relationshipNotYetDecomposedByPeer_description": "Versuchen Sie es zu einem späterem Zeitpunkt erneut.", + "errorDialog_activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate_title": "Der Kontakt existiert nicht mehr", + "errorDialog_activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate_description": "Der Kontakt zu diesem QR-Code existiert nicht mehr oder ist nicht mehr Teil von \"enmeshed\". Sie können keine Kontakt-Beziehung mehr herstellen.", + "errorDialog_relationshipTemplateIsExpired_title": "Die Kontaktanfrage ist abgelaufen", + "errorDialog_relationshipTemplateIsExpired_description": "Der Kontakt hat für diesen QR-Code eine Antwortfrist voreingestellt. Die mit diesem QR-Code verbundene Kontaktanfrage ist abgelaufen.", + "errorDialog_requestExpired_title": "Der QR-Code ist bereits abgelaufen", + "errorDialog_requestExpired_description": "Dieser QR-Code ist nicht mehr gültig. Versuchen Sie einen neuen QR-Code von dem Kontakt zu erhalten.", "error_image": "Es konnte kein Bild ausgewählt werden.", "error_upload_file": "Die Datei konnte nicht hochgeladen werden.", "error_download_file": "Die Datei konnte nicht heruntergeladen werden.", @@ -49,6 +57,8 @@ "error_openMailApp": "Fehler beim Öffnen der E-Mail-App", "error_openMailApp_description": "Die E-Mail-App auf Ihrem Gerät konnte nicht geöffnet werden. Schreiben Sie mit einem anderen Gerät an den Support.\n\nDie E-Mail-Adresse lautet:\nenmeshed.support@js-soft.com", "error_understood": "Verstanden", + "error_deleteRequest": "Anfrage löschen", + "error_deleteRequestFailed": "Die Kontakanfrage konnte nicht gelöscht werden. Versuchen SIe es später erneut.", "favorites": "Favoriten", "tryAgain": "Erneut versuchen", "title": "Titel", diff --git a/apps/enmeshed/lib/l10n/app_en.arb b/apps/enmeshed/lib/l10n/app_en.arb index b7e731331..8b5f6f76c 100644 --- a/apps/enmeshed/lib/l10n/app_en.arb +++ b/apps/enmeshed/lib/l10n/app_en.arb @@ -33,6 +33,14 @@ "errorDialog_invalidQRCode_description": "The scanned QR code is not supported by this app. If you have an alternative QR code available, please use it.\n\nMake sure you are using the latest version of the app. Go to the store and update the app if necessary.", "errorDialog_QRCodeProcessingFailed_title": "Error when processing the QR code", "errorDialog_QRCodeProcessingFailed_description": "The scanned QR code could not be processed. If you have an alternative QR code available, please use it.\n\nMake sure you are using the latest version of the app. Go to the store and update the app if necessary.", + "errorDialog_relationshipNotYetDecomposedByPeer_title": "You cannot currently enter into a new relationship with this contact.", + "errorDialog_relationshipNotYetDecomposedByPeer_description": "Try again at a later date", + "errorDialog_activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate_title": "The contact no longer exists", + "errorDialog_activeIdentityDeletionProcessOfOwnerOfRelationshipTemplate_description": "The contact for this QR code no longer exists or is no longer part of \"enmeshed\". You can no longer establish a contact relationship.", + "errorDialog_relationshipTemplateIsExpired_title": "The contact request has expired", + "errorDialog_relationshipTemplateIsExpired_description": "The contact has preset a response deadline for this QR code. The contact request associated with this QR code has expired.", + "errorDialog_requestExpired_title": "The QR code has already expired", + "errorDialog_requestExpired_description": "This QR code is no longer valid. Try to get a new QR code from the contact.", "error_image": "Failed to pick image.", "error_upload_file": "Failed to upload the file.", "error_download_file": "Failed to download the file.", @@ -49,6 +57,8 @@ "error_openMailApp": "Error when opening the e-mail app", "error_openMailApp_description": "The e-mail app on your device could not be opened. Write to support using a different device.\n\nThe e-mail address is:\nenmeshed.support@js-soft.com", "error_understood": "Understood", + "error_deleteRequest": "Delete request", + "error_deleteRequestFailed": "The contact request could not be deleted. Please try again later.", "favorites": "Favorites", "tryAgain": "Try again", "title": "Title",