diff --git a/lib/src/utils/solid_alert.dart b/lib/src/utils/solid_alert.dart index f1def690..e049135f 100644 --- a/lib/src/utils/solid_alert.dart +++ b/lib/src/utils/solid_alert.dart @@ -30,7 +30,36 @@ library; import 'package:flutter/material.dart'; -/// Alert widget to popup a dialog with a given message and an optional title. +/// Approximate average width, in logical pixels, of one character at the +/// default Material body text size (~14 sp). Used to translate a +/// "characters per line" budget into an actual `maxWidth` constraint for +/// alert dialogs. Slightly conservative so wider glyphs (e.g. `m`, `W`) +/// still tend to fit within the requested character budget. + +const double _approxCharWidthLogicalPixels = 7.0; + +/// Default character-per-line cap applied to alert dialogs raised by +/// [alert]. ~90 characters keeps long error messages readable on wide +/// desktop windows without forcing short messages to look awkwardly narrow +/// on phones; it also matches the 80–100 character convention familiar +/// from prose and source-code line lengths. + +const int defaultAlertMaxCharsPerLine = 90; + +/// Convert a "characters per line" budget into a logical-pixel `maxWidth` +/// suitable for use with [BoxConstraints]. Shared by [alert] and any +/// custom alert dialogs that want a consistent reading width. + +double alertMaxWidthForCharsPerLine(int maxCharsPerLine) => + maxCharsPerLine * _approxCharWidthLogicalPixels; + +/// Pop up a dismissable alert dialog with the given [msg]. +/// +/// [title] defaults to `'Alert'`. The message column is capped at +/// approximately [defaultAlertMaxCharsPerLine] characters wide so that long +/// messages do not stretch across the full width of a desktop window. +/// Short messages are unaffected — the cap only takes effect when the +/// natural width of the text exceeds it. Future alert( BuildContext context, @@ -41,7 +70,12 @@ Future alert( context: context, builder: (context) => AlertDialog( title: Text(title), - content: Text(msg), + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: alertMaxWidthForCharsPerLine(defaultAlertMaxCharsPerLine), + ), + child: Text(msg), + ), actions: [ ElevatedButton( onPressed: () => Navigator.pop(context), diff --git a/lib/src/widgets/grant_permission_dialogs.dart b/lib/src/widgets/grant_permission_dialogs.dart new file mode 100644 index 00000000..6823007a --- /dev/null +++ b/lib/src/widgets/grant_permission_dialogs.dart @@ -0,0 +1,183 @@ +/// Dialog helpers used by the grant permission form. +/// +/// Copyright (C) 2024-2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore, Anushka Vidanage, Tony Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show WebIdCheckResult, WebIdCheckStatus; + +import 'package:solidui/src/utils/solid_alert.dart' + show alertMaxWidthForCharsPerLine, defaultAlertMaxCharsPerLine; +import 'package:solidui/src/widgets/grant_permission_helpers_ui.dart' + show podNotInitMsg; +import 'package:solidui/src/widgets/solid_invite_others.dart'; +import 'package:solidui/src/widgets/solid_invite_others_models.dart'; + +/// Shows a dismissable error dialog whose message column is constrained to +/// approximately [maxCharsPerLine] characters wide. +/// +/// The default ([defaultAlertMaxCharsPerLine], ~90 characters) matches the +/// width used by the shared [alert] helper so error dialogs raised from +/// the grant permission flow have the same reading width as alerts raised +/// elsewhere in the app. Callers can pass a smaller value for short, +/// narrow notices. + +Future showGrantPermissionErrorDialog( + BuildContext context, + String title, + String message, { + int maxCharsPerLine = defaultAlertMaxCharsPerLine, +}) async { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: alertMaxWidthForCharsPerLine(maxCharsPerLine), + ), + child: Text(message), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); +} + +/// Handles the case where granting failed because one or more +/// recipients have not yet set up their POD. When an +/// [SolidInviteOthersConfig] is provided, the user is offered a +/// follow-up option to send the application's invitation directly. +/// Otherwise the original snackbar behaviour is kept so existing call +/// sites continue to work. + +Future handleNotInitialisedRecipients( + BuildContext context, + SolidInviteOthersConfig? inviteConfig, +) async { + if (inviteConfig == null) { + await showGrantPermissionErrorDialog( + context, + 'Recipient POD not initialised', + podNotInitMsg, + ); + return; + } + + if (!context.mounted) return; + final shouldInvite = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Recipient has not set up a POD'), + // Cap the message column at ~90 characters (within the 80–100 + // character convention used by the shared [alert] helper) so the + // dialog reads comfortably on wide desktop windows rather than + // stretching across the full window width. + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: alertMaxWidthForCharsPerLine(defaultAlertMaxCharsPerLine), + ), + child: const Text( + 'One or more of the WebIDs you entered have not yet ' + 'initialised their POD. Ask them to log in once to set up ' + 'their data vault — then you can grant access. Would you ' + 'like to send them an invitation now?', + ), + ), + actions: [ + MarkdownTooltip( + message: ''' + + **Not now** + + Dismiss this dialog without sending an invitation. You + can grant access again once the recipient has logged + into the app and set up their POD. + + ''', + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Not now'), + ), + ), + MarkdownTooltip( + message: ''' + + **Invite this user** + + Open the Invite Others dialog so you can send the + recipient a link to the app, prompting them to set up + their data vault. + + ''', + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Invite'), + ), + ), + ], + ), + ); + + if (!context.mounted) return; + if (shouldInvite == true) { + await InviteOthersDialog.show(context, config: inviteConfig); + } +} + +/// Priority order used when several WebIDs in the group list fail. We +/// surface a single dialog and prefer the most actionable failure mode. + +const List _groupReportPriority = [ + WebIdCheckStatus.invalidIpv4, + WebIdCheckStatus.unreachable, + WebIdCheckStatus.notProfile, +]; + +/// Pick the [WebIdCheckResult] to surface in a single dialog when more +/// than one WebID in the group has failed. Higher-priority statuses are +/// preferred (see [_groupReportPriority]); otherwise the first failure +/// in input order is returned. + +(String, WebIdCheckResult) pickGroupFailureToReport( + List<(String, WebIdCheckResult)> failures, +) { + assert(failures.isNotEmpty); + for (final status in _groupReportPriority) { + for (final failure in failures) { + if (failure.$2.status == status) return failure; + } + } + return failures.first; +} diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index bbb1ccff..96ac5b79 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -32,14 +32,12 @@ library; import 'package:flutter/material.dart'; -import 'package:markdown_tooltip/markdown_tooltip.dart'; import 'package:solidpod/solidpod.dart'; import 'package:solidui/solidui.dart' show ActionColors, GrantPermFormLayout, - InviteOthersDialog, SolidInviteOthersConfig, debugPrintException, debugPrintFailure, @@ -47,17 +45,19 @@ import 'package:solidui/solidui.dart' getPermissionCheckBoxes, isPhone, makeSubHeading, - podNotInitMsg, smallGapV, successMsg, updatePermissionMsg; import 'package:solidui/src/utils/snack_bar.dart'; import 'package:solidui/src/utils/solid_alert.dart'; +import 'package:solidui/src/utils/webid_message.dart' show webIdCheckMessage; +import 'package:solidui/src/widgets/grant_permission_dialogs.dart'; import 'package:solidui/src/widgets/grant_permission_helpers_ui.dart'; import 'package:solidui/src/widgets/group_webid_input.dart'; +import 'package:solidui/src/widgets/ind_webid_input.dart' + show indWebIdFormatError; import 'package:solidui/src/widgets/ind_webid_input_screen.dart'; import 'package:solidui/src/widgets/select_recipients.dart'; -import 'package:solidui/src/widgets/show_selected_recipients.dart'; /// Sharing (grant permission) form dialog function /// @@ -164,11 +164,10 @@ class _GrantPermissionFormState extends State { RecipientType selectedRecipientType = RecipientType.none; - /// Selected recipient details - - String selectedRecipientDetails = ''; - - /// List of webIds for group permission + /// List of webIds for group permission. Populated for public and + /// authenticated recipients on type-selection; for individual and group + /// recipients it is populated after Grant Permission is pressed and the + /// typed WebID(s) have been validated. List finalWebIdList = []; @@ -184,12 +183,21 @@ class _GrantPermissionFormState extends State { bool permissionsGrantedSuccessfully = false; - /// Pending text typed into the individual WebID field but not yet - /// confirmed via "Select WebId". Used as a fallback when Grant - /// Permission is pressed without explicitly selecting a recipient. + /// Current text typed into the individual WebID field. Validated on + /// Grant Permission rather than via a dedicated "Select" button. String _pendingIndWebId = ''; + /// Current text typed into the group name field. Validated on Grant + /// Permission rather than via a dedicated "Select" button. + + String _pendingGroupName = ''; + + /// Current text typed into the group "List of WebIDs" field. Validated + /// on Grant Permission rather than via a dedicated "Select" button. + + String _pendingGroupWebIds = ''; + /// read permission checked flag bool readChecked = false; @@ -234,98 +242,6 @@ class _GrantPermissionFormState extends State { /// grant permission form dialog. Future _alert(String msg) async => alert(context, msg); - /// Handles the case where granting failed because one or more - /// recipients have not yet set up their POD. When an - /// [SolidInviteOthersConfig] is provided, the user is offered a - /// follow-up option to send the application's invitation - /// directly. Otherwise the original snackbar behaviour is kept so - /// existing call sites continue to work. - - /// Shows a dismissable error dialog whose content is constrained to - /// approximately 60 characters wide. - - Future _showErrorDialog(String title, String message) async { - if (!mounted) return; - await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(title), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Text(message), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - - Future _handleNotInitialisedRecipients() async { - final invite = widget.inviteConfig; - if (invite == null) { - await _showErrorDialog('Recipient POD not initialised', podNotInitMsg); - return; - } - - if (!context.mounted) return; - if (!mounted) return; - final shouldInvite = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Recipient has not set up a POD'), - content: const Text( - 'One or more of the WebIDs you entered have not yet ' - 'initialised their POD. Ask them to log in once to set up ' - 'their data vault — then you can grant access. Would you ' - 'like to send them an invitation now?', - ), - actions: [ - MarkdownTooltip( - message: ''' - - **Not now** - - Dismiss this dialog without sending an invitation. You - can grant access again once the recipient has logged - into the app and set up their POD. - - ''', - child: TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Not now'), - ), - ), - MarkdownTooltip( - message: ''' - - **Invite this user** - - Open the Invite Others dialog so you can send the - recipient a link to the app, prompting them to set up - their data vault. - - ''', - child: TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('Invite'), - ), - ), - ], - ); - }, - ); - - if (!mounted) return; - if (shouldInvite == true) { - await InviteOthersDialog.show(context, config: invite); - } - } - /// Private function to show snackbar in share resource button context Future _showSnackBar( String msg, @@ -334,24 +250,98 @@ class _GrantPermissionFormState extends State { }) async => showSnackBar(context, msg, bgColor, duration: duration); - /// Update selected webid list with individual recipient webid - /// [receiverWebId]. - void updateIndWebIdInput(String receiverWebId) => setState(() { - selectedRecipientDetails = receiverWebId; - finalWebIdList = [receiverWebId]; + /// Drop any individual WebID text typed by the user. Invoked by the + /// Clear button on the individual WebID input widget. + void clearIndWebIdInput() => setState(() { + finalWebIdList = []; + _pendingIndWebId = ''; }); - /// Update selected webid list with list of webids in - /// recipient group [webIdList] and their group name - /// [groupName]. - - void updateGroupWebIdInput(String groupName, List webIdList) => - setState(() { - selectedRecipientDetails = webIdList.join(', '); - finalWebIdList = webIdList; - selectedGroupName = groupName; + /// Drop any group text typed by the user. Invoked by the Clear button + /// on the group WebID input widget. + void clearGroupWebIdInput() => setState(() { + finalWebIdList = []; + selectedGroupName = ''; + _pendingGroupName = ''; + _pendingGroupWebIds = ''; }); + /// Validate the individual WebID typed by the user and, when valid, + /// populate [finalWebIdList]. Returns true when the value is acceptable + /// and Grant Permission may proceed; otherwise an alert has been shown + /// and the caller should stop. + Future _validateAndApplyIndWebId() async { + final webId = _pendingIndWebId.trim(); + if (webId.isEmpty) { + await _alert('Please enter a recipient WebID'); + return false; + } + final formatError = indWebIdFormatError(webId); + if (formatError != null) { + await _alert(formatError); + return false; + } + final result = await validateWebId(webId); + if (!mounted) return false; + if (!result.isValid) { + final msg = webIdCheckMessage(result, webId: webId) ?? + 'This WebID does not exist. Please enter the correct WebID.'; + await _alert(msg); + return false; + } + setState(() { + finalWebIdList = [webId]; + }); + return true; + } + + /// Validate the group fields typed by the user and, when valid, + /// populate [finalWebIdList] and [selectedGroupName]. Returns true when + /// the value is acceptable and Grant Permission may proceed; otherwise + /// an alert has been shown and the caller should stop. + Future _validateAndApplyGroupWebIds() async { + final groupName = _pendingGroupName.trim(); + final groupWebIds = _pendingGroupWebIds.trim(); + if (groupName.isEmpty || groupWebIds.isEmpty) { + await _alert('Please enter a group name and a list of Web IDs'); + return false; + } + final webIdList = groupWebIds + .split(';') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + if (webIdList.isEmpty) { + await _alert('Please enter a group name and a list of Web IDs'); + return false; + } + + // Validate every WebID and collect any failures. + final failures = <(String, WebIdCheckResult)>[]; + for (final webId in webIdList) { + final result = await validateWebId(webId); + if (!mounted) return false; + if (!result.isValid) { + failures.add((webId, result)); + } + } + + if (failures.isNotEmpty) { + // Surface the most informative failure to the user. + final (failedWebId, failedResult) = pickGroupFailureToReport(failures); + final msg = webIdCheckMessage(failedResult, webId: failedWebId) ?? + 'At least one of the Web IDs you entered is not valid'; + await _alert(msg); + return false; + } + + setState(() { + finalWebIdList = webIdList; + selectedGroupName = groupName; + }); + return true; + } + /// Update checked status of access mode boxes to show /// selected access modes. void updateCheckbox(bool newValue, AccessMode accessMode) => setState(() { @@ -377,15 +367,12 @@ class _GrantPermissionFormState extends State { /// Set recipients to public void _setRecipientsToPublic() => setState(() { selectedRecipientType = RecipientType.public; - selectedRecipientDetails = 'Anyone (release publicly)'; finalWebIdList = [publicAgent.value]; }); /// Set recipients to authorised users void _setRecipientsToAuthUsers() => setState(() { selectedRecipientType = RecipientType.authUser; - selectedRecipientDetails = - 'Authenticated Users (any user logged in with their webId)'; finalWebIdList = [authenticatedAgent.value]; }); @@ -438,22 +425,21 @@ class _GrantPermissionFormState extends State { // Select Individual recipient if required if (selectedRecipientType == RecipientType.individual) ...[ IndWebIdInputScreen( - onSubmitFunction: updateIndWebIdInput, dataFilesMap: widget.dataFilesMap, onTextChanged: (text) => setState(() => _pendingIndWebId = text.trim()), + onClearFunction: clearIndWebIdInput, ), ] else if (selectedRecipientType == RecipientType.group) ...[ // Select group of recipients if required - GroupWebIdTextInput(onSubmitFunction: updateGroupWebIdInput), + GroupWebIdTextInput( + onGroupNameChanged: (value) => + setState(() => _pendingGroupName = value), + onGroupWebIdsChanged: (value) => + setState(() => _pendingGroupWebIds = value), + onClearFunction: clearGroupWebIdInput, + ), ], - // List selected recipient webids or recipient - // type (public/auth) - ShowSelectedRecipients( - selectedRecipientType: selectedRecipientType, - selectedRecipientDetails: selectedRecipientDetails, - selectedGroupName: selectedGroupName, - ), smallGapV, makeSubHeading('Select one or more file access permissions'), // Show access mode checkboxes and update @@ -476,90 +462,98 @@ class _GrantPermissionFormState extends State { actions: [ TextButton( onPressed: () async { - // If the user typed a WebID in the individual field but didn't - // press "Select WebId", use that value as the recipient now. - if (selectedRecipientType == RecipientType.individual && - finalWebIdList.isEmpty && - _pendingIndWebId.isNotEmpty) { - updateIndWebIdInput(_pendingIndWebId); + // Bail out early when no recipient type has been chosen yet. + if (selectedRecipientType.type.isEmpty) { + await _alert('Please select a type of recipient'); + return; + } + + // Validate the typed WebID(s). On failure the helper has already + // shown an alert; stop here so we do not attempt to grant. + if (selectedRecipientType == RecipientType.individual) { + if (!await _validateAndApplyIndWebId()) return; + } else if (selectedRecipientType == RecipientType.group) { + if (!await _validateAndApplyGroupWebIds()) return; } // Grant Permission and update permission map - // used by permission table - - if (selectedRecipientType.type.isNotEmpty) { - if (selectedPermList.isNotEmpty) { - // Grant permission for each resource sequentially. - // When resourceNames is provided all resources share the - // same recipient and permission selections. - final resourcesToGrant = widget.resourceNames; - SolidFunctionCallStatus result = - SolidFunctionCallStatus.success; - try { - for (final name in resourcesToGrant) { - final r = await grantPermission( - fileName: name, - isFile: widget.isFile, - permissionList: selectedPermList, - recipientType: selectedRecipientType, - recipientWebIdList: finalWebIdList, - ownerWebId: widget.ownerWebId, - granterWebId: widget.granterWebId, - isExternalRes: widget.isExternalRes, - groupName: selectedGroupName, - ); - if (r != SolidFunctionCallStatus.success) { - result = r; - break; - } - } - - // Close grant permission dialog - if (!context.mounted) return; - Navigator.of(context).pop(); - } on Object catch (e, stackTrace) { - result = SolidFunctionCallStatus.fail; - debugPrintException(e, stackTrace); - } + // used by permission table. - if (result == SolidFunctionCallStatus.success) { - _showSnackBar(successMsg, ActionColors.success); - // Update permissions table for the primary resource. - await widget.updatePermissionsFunction( - widget.resourceNames.first, - isFile: widget.isFile, - isExternalRes: widget.isExternalRes, - ); - - // Mark permissions as granted successfully for callback tracking - await widget.updatePermissionGrantedFunction(); - - // Trigger the onPermissionGranted callback if provided - widget.onPermissionGranted?.call(); - } else if (result == SolidFunctionCallStatus.fail) { - await _showErrorDialog( - 'Permission granting failed', - failureMsg, - ); - - // Also log to console for debugging - debugPrintFailure( - widget.resourceNames.first, - finalWebIdList, - selectedPermList, - ); - } else if (result == SolidFunctionCallStatus.notInitialised) { - await _handleNotInitialisedRecipients(); - } else { - await _alert(updatePermissionMsg); - } - } else { - await _alert( - 'Please select one or more file access permissions', + if (selectedPermList.isEmpty) { + await _alert( + 'Please select one or more file access permissions', + ); + return; + } + + // Grant permission for each resource sequentially. When + // resourceNames is provided all resources share the same + // recipient and permission selections. + final resourcesToGrant = widget.resourceNames; + SolidFunctionCallStatus result = SolidFunctionCallStatus.success; + try { + for (final name in resourcesToGrant) { + final r = await grantPermission( + fileName: name, + isFile: widget.isFile, + permissionList: selectedPermList, + recipientType: selectedRecipientType, + recipientWebIdList: finalWebIdList, + ownerWebId: widget.ownerWebId, + granterWebId: widget.granterWebId, + isExternalRes: widget.isExternalRes, + groupName: selectedGroupName, ); + if (r != SolidFunctionCallStatus.success) { + result = r; + break; + } } + + // Close grant permission dialog + if (!context.mounted) return; + Navigator.of(context).pop(); + } on Object catch (e, stackTrace) { + result = SolidFunctionCallStatus.fail; + debugPrintException(e, stackTrace); + } + + if (result == SolidFunctionCallStatus.success) { + _showSnackBar(successMsg, ActionColors.success); + // Update permissions table for the primary resource. + await widget.updatePermissionsFunction( + widget.resourceNames.first, + isFile: widget.isFile, + isExternalRes: widget.isExternalRes, + ); + + // Mark permissions as granted successfully for callback tracking + await widget.updatePermissionGrantedFunction(); + + // Trigger the onPermissionGranted callback if provided + widget.onPermissionGranted?.call(); + } else if (result == SolidFunctionCallStatus.fail) { + if (!context.mounted) return; + await showGrantPermissionErrorDialog( + context, + 'Permission granting failed', + failureMsg, + ); + + // Also log to console for debugging + debugPrintFailure( + widget.resourceNames.first, + finalWebIdList, + selectedPermList, + ); + } else if (result == SolidFunctionCallStatus.notInitialised) { + if (!context.mounted) return; + await handleNotInitialisedRecipients( + context, + widget.inviteConfig, + ); } else { - await _alert('Please select a type of recipient'); + await _alert(updatePermissionMsg); } }, child: const Text('Grant Permission'), diff --git a/lib/src/widgets/group_webid_input.dart b/lib/src/widgets/group_webid_input.dart index 8af6a441..755ffb6b 100644 --- a/lib/src/widgets/group_webid_input.dart +++ b/lib/src/widgets/group_webid_input.dart @@ -33,54 +33,37 @@ library; import 'package:flutter/material.dart'; import 'package:markdown_tooltip/markdown_tooltip.dart'; -import 'package:solidpod/solidpod.dart' - show - WebIdCheckResult, - WebIdCheckStatus, - validateWebId, - whatIsWebID, - demoWebID; +import 'package:solidpod/solidpod.dart' show whatIsWebID, demoWebID; import 'package:solidui/solidui.dart' show smallGapV, makeSubHeading, GrantPermFormLayout; -import 'package:solidui/src/utils/solid_alert.dart'; -import 'package:solidui/src/utils/webid_message.dart' show webIdCheckMessage; -/// Priority order used when several WebIDs in the list fail at once. - -const List _groupReportPriority = [ - WebIdCheckStatus.invalidIpv4, - WebIdCheckStatus.unreachable, - WebIdCheckStatus.notProfile, -]; - -/// Pick the [WebIdCheckResult] to surface in a single dialog when more than -/// one WebID in the group has failed. Higher-priority statuses are preferred -/// (see [_groupReportPriority]); otherwise the first failure in input order -/// is returned. - -(String, WebIdCheckResult) _pickGroupFailureToReport( - List<(String, WebIdCheckResult)> failures, -) { - assert(failures.isNotEmpty); - for (final status in _groupReportPriority) { - for (final failure in failures) { - if (failure.$2.status == status) return failure; - } - } - return failures.first; -} - -/// A [StatefulWidget] dialog for entering group of webIds. +/// A [StatefulWidget] dialog for entering a group of WebIDs. /// -/// Parameters: -/// - [onSubmitFunction] - function to be called on submit. - +/// The widget no longer confirms a selection on its own; the parent form +/// reads the typed values via [onGroupNameChanged] and +/// [onGroupWebIdsChanged] and validates them when the user presses +/// Grant Permission. class GroupWebIdTextInput extends StatefulWidget { - /// Function run on Submit button press. - final Function onSubmitFunction; - - const GroupWebIdTextInput({super.key, required this.onSubmitFunction}); + /// Optional callback fired on every keystroke in the group name field. + final void Function(String)? onGroupNameChanged; + + /// Optional callback fired on every keystroke in the list of WebIDs + /// field. The raw text is passed through; splitting on ';' is performed + /// by the parent at validation time. + final void Function(String)? onGroupWebIdsChanged; + + /// Optional callback fired when the user presses the Clear button. The + /// parent form uses this to drop any cached state so the dialog returns + /// to a clean state. + final VoidCallback? onClearFunction; + + const GroupWebIdTextInput({ + super.key, + this.onGroupNameChanged, + this.onGroupWebIdsChanged, + this.onClearFunction, + }); @override State createState() => _GroupWebIdTextInputState(); @@ -132,6 +115,7 @@ class _GroupWebIdTextInputState extends State { hintText: 'Multiple words will be combined using the symbol -', ), + onChanged: (value) => widget.onGroupNameChanged?.call(value), ), smallGapV, // List of Web IDs divided by semicolon @@ -141,58 +125,25 @@ class _GroupWebIdTextInputState extends State { labelText: 'List of WebIDs', hintText: 'Divide multiple WebIDs using the semicolon (;)', ), + onChanged: (value) => widget.onGroupWebIdsChanged?.call(value), ), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: () async { - final groupName = formControllerGroupName.text.trim(); - final groupWebIds = formControllerGroupWebIds.text.trim(); - - if (groupName.isEmpty || groupWebIds.isEmpty) { - if (!context.mounted) return; - await alert( - context, - 'Please enter a group name and a list of Web IDs', - ); - return; - } - - final webIdList = groupWebIds - .split(';') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(); - - // Validate every WebID in parallel and pair each result - // with its source URL so the dialog can name the - // offending entry. - - final results = await Future.wait( - webIdList.map( - (webId) async => (webId, await validateWebId(webId)), - ), - ); - - final failures = - results.where((r) => !r.$2.isValid).toList(); - - if (failures.isEmpty) { - widget.onSubmitFunction(groupName, webIdList); - return; - } - - if (!context.mounted) return; - final report = _pickGroupFailureToReport(failures); - final message = webIdCheckMessage( - report.$2, - webId: report.$1, - ) ?? - 'At least one of the Web IDs you entered is not valid'; - await alert(context, message); + onPressed: () { + // Wipe both group fields and any cached state so the + // user can start over. The actual recipient list is + // confirmed later when Grant Permission is pressed. + setState(() { + formControllerGroupName.clear(); + formControllerGroupWebIds.clear(); + }); + widget.onGroupNameChanged?.call(''); + widget.onGroupWebIdsChanged?.call(''); + widget.onClearFunction?.call(); }, - child: const Text('Select Group of WebIds'), + child: const Text('Clear'), ), ], ), diff --git a/lib/src/widgets/ind_webid_input.dart b/lib/src/widgets/ind_webid_input.dart index 022faf71..2187a43b 100644 --- a/lib/src/widgets/ind_webid_input.dart +++ b/lib/src/widgets/ind_webid_input.dart @@ -33,8 +33,7 @@ library; import 'package:flutter/material.dart'; import 'package:markdown_tooltip/markdown_tooltip.dart'; -import 'package:solidpod/solidpod.dart' - show validateWebId, whatIsWebID, demoWebID; +import 'package:solidpod/solidpod.dart' show whatIsWebID, demoWebID; import 'package:solidui/solidui.dart' show @@ -43,36 +42,72 @@ import 'package:solidui/solidui.dart' GrantPermFormLayout, WebIdLayout, DropdownColors; -import 'package:solidui/src/utils/solid_alert.dart'; -import 'package:solidui/src/utils/webid_message.dart' show webIdCheckMessage; -/// A [StatefulWidget] dialog for adding an individual webId. -/// Function call requires the following inputs. -/// [onSubmitFunction] is the function to be called on submit. -/// [uniqRecipWebIdList] is a list of the webIds of unique recipients of the -/// owner's data. +/// Returns a human-readable error describing why [text] is not a valid WebID, +/// or `null` if [text] is well-formed. +/// +/// This is shared between the inline field validation in +/// [IndWebIdTextInput] and the deferred validation performed when the user +/// presses "Grant Permission" on the parent dialog. +String? indWebIdFormatError(String text) { + final trimmed = text.trim(); + final uri = Uri.parse(trimmed); + + // Check for https scheme and :// + if (!uri.isScheme('HTTPS') || !uri.toString().contains('://')) { + return 'Must start with https://'; + } + // Check WebID contains host followed by '/' + if (!uri.path.contains('/')) { + return 'Must have form https://[POD server host]/[their username]/profile/card#me'; + } + // Check for WebID path with profile suffix + if (!uri.path.toLowerCase().contains('/profile/card')) { + return 'Must end with \'/[their username]/profile/card#me\''; + } + // Check ends in #me + if (uri.fragment.toLowerCase() != 'me') { + return 'Must end with URL fragment #me after /profile/card'; + } + // Check fully qualified web address + // 20250721 jm Retaining this check, may not be needed + if (!Uri.parse(trimmed.replaceAll('#me', '')).isAbsolute) { + return 'Must be a fully qualified web address'; + } + return null; +} + +/// A [StatefulWidget] dialog for entering an individual WebID. +/// +/// The widget no longer confirms a selection on its own; instead the parent +/// form reads the typed text via [onTextChanged] and validates it when the +/// user presses Grant Permission. [uniqRecipWebIdList] is a list of the +/// webIds of unique recipients of the owner's data, used to populate +/// suggestions. /// class IndWebIdTextInput extends StatefulWidget { /// Initialise widget variables. const IndWebIdTextInput({ - required this.onSubmitFunction, this.uniqRecipWebIdList, this.onTextChanged, + this.onClearFunction, super.key, }); - /// Function run on Submit button press. - final Function onSubmitFunction; - /// List of unique recipient webIds final List? uniqRecipWebIdList; /// Optional callback fired on every keystroke with the current raw text. - /// Used by the parent form to track the field value so it can fall back to - /// it when Grant Permission is pressed before Select WebId is clicked. + /// The parent form uses this to capture the field value so it can be + /// validated when Grant Permission is pressed. final void Function(String)? onTextChanged; + /// Optional callback fired when the user presses the Clear button. The + /// parent form uses this to drop any cached state so the dialog returns + /// to a clean state. + final VoidCallback? onClearFunction; + @override State createState() => _IndWebIdTextInputState(); } @@ -104,36 +139,8 @@ class _IndWebIdTextInputState extends State { super.initState(); } - /// Generate advice to help user enter valid WebID - String? get _helpText { - final text = formControllerWebId.value.text.trim(); - final uri = Uri.parse(text); - - // Check for https scheme and :// - if (!uri.isScheme('HTTPS') || !uri.toString().contains('://')) { - return 'Must start with https://'; - } - // Check WebID contains host followed by '/' - - if (!uri.path.contains('/')) { - return 'Must have form https://[POD server host]/[their username]/profile/card#me'; - } - // Check for WebID path with profile suffix - if (!uri.path.toLowerCase().contains('/profile/card')) { - return 'Must end with \'/[their username]/profile/card#me\''; - } - // Check ends in #me - if (!(uri.fragment.toLowerCase() == 'me')) { - return 'Must end with URL fragment #me after /profile/card'; - } - // Check fully qualified web address - // 20250721 jm Retaining this check, may not be needed - if (!Uri.parse(text.replaceAll('#me', '')).isAbsolute) { - return 'Must be a fully qualified web address'; - } - // return null if the text is valid - return null; - } + /// Generate advice to help user enter valid WebID. + String? get _helpText => indWebIdFormatError(formControllerWebId.value.text); /// Generate suggestions for users based on input matches to /// current complete recipient list of user @@ -202,32 +209,19 @@ class _IndWebIdTextInputState extends State { ], ], TextButton( - onPressed: () async { - final receiverWebId = formControllerWebId.text.trim(); - - // User has entered WebId text that satisfies error checks - if (receiverWebId.isEmpty || _helpText != null) return; - - // Run the full WebID validation pipeline (IP form check - // + RDF profile content check + network error mapping) - // in one call so the failure modes can be reported - // through a single dialog rather than a series of - // nested try/catch blocks. - final result = await validateWebId(receiverWebId); - - if (result.isValid) { - widget.onSubmitFunction(receiverWebId); - return; - } - - if (!context.mounted) return; - final message = webIdCheckMessage( - result, - webId: receiverWebId, - ); - if (message != null) await alert(context, message); + onPressed: () { + // Wipe the WebID field and any cached state so the + // user can start over. The actual recipient is + // confirmed later when Grant Permission is pressed. + setState(() { + formControllerWebId.clear(); + _textEntered = false; + suggestionList.clear(); + }); + widget.onTextChanged?.call(''); + widget.onClearFunction?.call(); }, - child: const Text('Select WebId'), + child: const Text('Clear'), ), ], ), @@ -253,11 +247,15 @@ class _IndWebIdTextInputState extends State { focusColor: DropdownColors.primary, hoverColor: DropdownColors.accent, splashColor: DropdownColors.primary, - onTap: () => setState(() { - // User has started entering text - _textEntered = true; - formControllerWebId.text = idList[index]; - }), + onTap: () { + setState(() { + // User has started entering text + _textEntered = true; + formControllerWebId.text = idList[index]; + }); + // Notify parent so it picks up the chosen value. + widget.onTextChanged?.call(idList[index]); + }, ), ); }, diff --git a/lib/src/widgets/ind_webid_input_screen.dart b/lib/src/widgets/ind_webid_input_screen.dart index 6aa9f756..cbdad483 100644 --- a/lib/src/widgets/ind_webid_input_screen.dart +++ b/lib/src/widgets/ind_webid_input_screen.dart @@ -42,21 +42,15 @@ import 'package:solidui/src/widgets/solid_loading_screen.dart'; /// A screen that runs before opening the WebID input dialog, which /// retrieves the list of files in the owner's pod. /// -/// Parameters: -/// - [onSubmitFunction] is the function to be called on submit -/// class IndWebIdInputScreen extends StatefulWidget { /// Initialise widget variables. const IndWebIdInputScreen({ - required this.onSubmitFunction, this.dataFilesMap = const {}, this.onTextChanged, + this.onClearFunction, super.key, }); - /// Function run on Submit button press. - final Function onSubmitFunction; - /// Map of data files on a user's POD used to extract the /// user's recipient list by the WebIdTextInputScreen. /// If not provided, the file list must be read to obtain @@ -66,6 +60,10 @@ class IndWebIdInputScreen extends StatefulWidget { /// Optional callback fired on every keystroke in the WebID text field. final void Function(String)? onTextChanged; + /// Optional callback fired when the user presses the Clear button on the + /// individual WebID dialog. Forwarded to [IndWebIdTextInput]. + final VoidCallback? onClearFunction; + @override State createState() => _IndWebIdInputScreenState(); } @@ -90,21 +88,20 @@ class _IndWebIdInputScreenState extends State { } // Load Individual WebId Text Input - Widget _loadIndWebIdTextInput( - Function onSubmitFunction, [ + Widget _loadIndWebIdTextInput([ List uniqRecipWebIdList = const [], ]) { return IndWebIdTextInput( - onSubmitFunction: onSubmitFunction, uniqRecipWebIdList: uniqRecipWebIdList, onTextChanged: widget.onTextChanged, + onClearFunction: widget.onClearFunction, ); } @override Widget build(BuildContext context) { return (widget.dataFilesMap.isNotEmpty) - ? _loadIndWebIdTextInput(widget.onSubmitFunction, uniqRecipWebIdList) + ? _loadIndWebIdTextInput(uniqRecipWebIdList) : FutureBuilder( future: _asyncGetRecipList, builder: (context, snapshot) { @@ -114,14 +111,9 @@ class _IndWebIdInputScreenState extends State { snapshot.data.toString() == 'null' || snapshot.data == [] // Load Individual WebId Input Dialog Screen without recipient list - ? returnVal = _loadIndWebIdTextInput( - widget.onSubmitFunction, - ) + ? returnVal = _loadIndWebIdTextInput() // Load Individual WebId Input Dialog Screen with recipient list - : returnVal = _loadIndWebIdTextInput( - widget.onSubmitFunction, - snapshot.data!, - ); + : returnVal = _loadIndWebIdTextInput(snapshot.data!); } else { returnVal = loadingScreen(normalLoadingScreenHeight); }