From 7b4f210a1b1bdee6140b393b55a4c1ac666344c9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 20 May 2026 00:12:12 +1000 Subject: [PATCH 1/8] Add Clear button to Sharing File dialog --- lib/src/widgets/grant_permission_form.dart | 22 +++++++++++++++++++- lib/src/widgets/group_webid_input.dart | 23 ++++++++++++++++++++- lib/src/widgets/ind_webid_input.dart | 20 ++++++++++++++++++ lib/src/widgets/ind_webid_input_screen.dart | 6 ++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index bbb1ccff..fbb44444 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -352,6 +352,22 @@ class _GrantPermissionFormState extends State { selectedGroupName = groupName; }); + /// Drop any individual WebID the user has previously confirmed via + /// "Select WebId" along with any pending text. + void clearIndWebIdInput() => setState(() { + selectedRecipientDetails = ''; + finalWebIdList = []; + _pendingIndWebId = ''; + }); + + /// Drop any group the user has previously confirmed via + /// "Select Group of WebIds". + void clearGroupWebIdInput() => setState(() { + selectedRecipientDetails = ''; + finalWebIdList = []; + selectedGroupName = ''; + }); + /// Update checked status of access mode boxes to show /// selected access modes. void updateCheckbox(bool newValue, AccessMode accessMode) => setState(() { @@ -442,10 +458,14 @@ class _GrantPermissionFormState extends State { 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( + onSubmitFunction: updateGroupWebIdInput, + onClearFunction: clearGroupWebIdInput, + ), ], // List selected recipient webids or recipient // type (public/auth) diff --git a/lib/src/widgets/group_webid_input.dart b/lib/src/widgets/group_webid_input.dart index 242d891f..f1be9ba0 100644 --- a/lib/src/widgets/group_webid_input.dart +++ b/lib/src/widgets/group_webid_input.dart @@ -50,7 +50,16 @@ class GroupWebIdTextInput extends StatefulWidget { /// Function run on Submit button press. final Function onSubmitFunction; - const GroupWebIdTextInput({super.key, required this.onSubmitFunction}); + /// Optional callback fired when the user presses the Clear button. The + /// parent form uses this to drop any group already confirmed via + /// "Select Group of WebIds" so the dialog returns to a clean state. + final VoidCallback? onClearFunction; + + const GroupWebIdTextInput({ + super.key, + required this.onSubmitFunction, + this.onClearFunction, + }); @override State createState() => _GroupWebIdTextInputState(); @@ -157,6 +166,18 @@ class _GroupWebIdTextInputState extends State { }, child: const Text('Select Group of WebIds'), ), + TextButton( + onPressed: () { + // Wipe both group fields and any cached selection so + // the user can start over. + setState(() { + formControllerGroupName.clear(); + formControllerGroupWebIds.clear(); + }); + widget.onClearFunction?.call(); + }, + child: const Text('Clear'), + ), ], ), ], diff --git a/lib/src/widgets/ind_webid_input.dart b/lib/src/widgets/ind_webid_input.dart index a224a4f1..860fb5df 100644 --- a/lib/src/widgets/ind_webid_input.dart +++ b/lib/src/widgets/ind_webid_input.dart @@ -58,6 +58,7 @@ class IndWebIdTextInput extends StatefulWidget { required this.onSubmitFunction, this.uniqRecipWebIdList, this.onTextChanged, + this.onClearFunction, super.key, }); @@ -72,6 +73,11 @@ class IndWebIdTextInput extends StatefulWidget { /// it when Grant Permission is pressed before Select WebId is clicked. final void Function(String)? onTextChanged; + /// Optional callback fired when the user presses the Clear button. The + /// parent form uses this to drop any recipient already confirmed via + /// "Select WebId" so the dialog returns to a clean state. + final VoidCallback? onClearFunction; + @override State createState() => _IndWebIdTextInputState(); } @@ -224,6 +230,20 @@ class _IndWebIdTextInputState extends State { }, child: const Text('Select WebId'), ), + TextButton( + onPressed: () { + // Wipe the WebID field and any cached selection so the + // user can start over. + setState(() { + formControllerWebId.clear(); + _textEntered = false; + suggestionList.clear(); + }); + widget.onTextChanged?.call(''); + widget.onClearFunction?.call(); + }, + child: const Text('Clear'), + ), ], ), ], diff --git a/lib/src/widgets/ind_webid_input_screen.dart b/lib/src/widgets/ind_webid_input_screen.dart index 6aa9f756..d7d985cc 100644 --- a/lib/src/widgets/ind_webid_input_screen.dart +++ b/lib/src/widgets/ind_webid_input_screen.dart @@ -51,6 +51,7 @@ class IndWebIdInputScreen extends StatefulWidget { required this.onSubmitFunction, this.dataFilesMap = const {}, this.onTextChanged, + this.onClearFunction, super.key, }); @@ -66,6 +67,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(); } @@ -98,6 +103,7 @@ class _IndWebIdInputScreenState extends State { onSubmitFunction: onSubmitFunction, uniqRecipWebIdList: uniqRecipWebIdList, onTextChanged: widget.onTextChanged, + onClearFunction: widget.onClearFunction, ); } From 52c7463eece4573cd2beeac3b57a47ed79ec64d4 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 20 May 2026 16:33:02 +1000 Subject: [PATCH 2/8] Adjust the dialog width limit --- lib/src/utils/solid_alert.dart | 56 ++++++++++++++++++++------ lib/src/utils/webid_message.dart | 9 +++++ lib/src/widgets/group_webid_input.dart | 9 ++++- lib/src/widgets/ind_webid_input.dart | 11 ++++- pubspec.yaml | 8 +--- 5 files changed, 69 insertions(+), 24 deletions(-) diff --git a/lib/src/utils/solid_alert.dart b/lib/src/utils/solid_alert.dart index e15e122d..95b3f038 100644 --- a/lib/src/utils/solid_alert.dart +++ b/lib/src/utils/solid_alert.dart @@ -30,24 +30,54 @@ 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 +/// [alert]'s `maxCharsPerLine` into an actual `maxWidth` constraint. A +/// slightly conservative value is chosen so that wide glyphs (e.g. `m`, `W`) +/// still tend to fit within the requested character budget. +const double _approxCharWidthLogicalPixels = 7.5; +/// Default character-per-line cap used by callers that want a sensible +/// reading width without picking a number themselves. ~90 characters keeps +/// long messages readable on wide desktop windows while still leaving room +/// for the dialog's chrome on a phone. +const int defaultDialogMaxCharsPerLine = 90; + +/// Pop up an alert dialog with the given [msg]. +/// +/// [title] defaults to `'Notice'`. Pass [maxCharsPerLine] to cap the width +/// of the message column at roughly that many characters of body text, +/// preventing the dialog from stretching across the full width of a desktop +/// window when the message is long. Pass `null` (the default) to keep the +/// platform default sizing. Future alert( BuildContext context, - String msg, [ + String msg, { String title = 'Notice', -]) async { + int? maxCharsPerLine, +}) async { await showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(msg), - actions: [ - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], - ), + builder: (context) { + final messageText = Text(msg); + final content = maxCharsPerLine == null + ? messageText + : ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxCharsPerLine * _approxCharWidthLogicalPixels, + ), + child: messageText, + ); + return AlertDialog( + title: Text(title), + content: content, + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ); + }, ); } diff --git a/lib/src/utils/webid_message.dart b/lib/src/utils/webid_message.dart index 80f666f0..406d62d8 100644 --- a/lib/src/utils/webid_message.dart +++ b/lib/src/utils/webid_message.dart @@ -34,6 +34,15 @@ library; import 'package:solidpod/solidpod.dart' show WebIdCheckResult, WebIdCheckStatus, isValidIpv4; +/// Recommended character-per-line cap for WebID error dialogs. +/// +/// These messages can be quite long (host names, URL examples, etc.) and on +/// a wide desktop window they would otherwise stretch the dialog across the +/// entire screen and become hard to read. ~90 characters gives a comfortable +/// reading width on both phone and desktop. Callers should pass this to +/// `alert(..., maxCharsPerLine: webIdMessageMaxCharsPerLine)`. +const int webIdMessageMaxCharsPerLine = 90; + /// Build a user-facing message for a non-valid [result], or `null` when the /// result is [WebIdCheckStatus.valid] and no message is needed. /// diff --git a/lib/src/widgets/group_webid_input.dart b/lib/src/widgets/group_webid_input.dart index 8af6a441..9e2e46e4 100644 --- a/lib/src/widgets/group_webid_input.dart +++ b/lib/src/widgets/group_webid_input.dart @@ -44,7 +44,8 @@ import 'package:solidpod/solidpod.dart' 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; +import 'package:solidui/src/utils/webid_message.dart' + show webIdCheckMessage, webIdMessageMaxCharsPerLine; /// Priority order used when several WebIDs in the list fail at once. @@ -190,7 +191,11 @@ class _GroupWebIdTextInputState extends State { webId: report.$1, ) ?? 'At least one of the Web IDs you entered is not valid'; - await alert(context, message); + await alert( + context, + message, + maxCharsPerLine: webIdMessageMaxCharsPerLine, + ); }, child: const Text('Select Group of WebIds'), ), diff --git a/lib/src/widgets/ind_webid_input.dart b/lib/src/widgets/ind_webid_input.dart index 022faf71..83471a4d 100644 --- a/lib/src/widgets/ind_webid_input.dart +++ b/lib/src/widgets/ind_webid_input.dart @@ -44,7 +44,8 @@ import 'package:solidui/solidui.dart' WebIdLayout, DropdownColors; import 'package:solidui/src/utils/solid_alert.dart'; -import 'package:solidui/src/utils/webid_message.dart' show webIdCheckMessage; +import 'package:solidui/src/utils/webid_message.dart' + show webIdCheckMessage, webIdMessageMaxCharsPerLine; /// A [StatefulWidget] dialog for adding an individual webId. /// Function call requires the following inputs. @@ -225,7 +226,13 @@ class _IndWebIdTextInputState extends State { result, webId: receiverWebId, ); - if (message != null) await alert(context, message); + if (message != null) { + await alert( + context, + message, + maxCharsPerLine: webIdMessageMaxCharsPerLine, + ); + } }, child: const Text('Select WebId'), ), diff --git a/pubspec.yaml b/pubspec.yaml index e33b7f2a..6e05c964 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: rdflib: ^0.2.12 share_plus: ^11.0.0 shared_preferences: ^2.5.4 - solidpod: ^0.12.6 + solidpod: ^0.12.9 universal_io: ^2.3.1 url_launcher: ^6.3.2 version_widget: ^1.0.10 @@ -40,12 +40,6 @@ dev_dependencies: flutter_lints: ^6.0.0 window_manager: ^0.5.1 -dependency_overrides: - solidpod: - git: - url: https://github.com/anusii/solidpod.git - ref: tony/320_failed_host_lookup - flutter: uses-material-design: true assets: From 16dd67d9ff017ddda0d27448b2e41143048583f1 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 21 May 2026 00:55:39 +1000 Subject: [PATCH 3/8] Remove the SELECT WEBID and so also the RECIPIENT --- lib/src/widgets/grant_permission_form.dart | 295 ++++++++++++-------- lib/src/widgets/group_webid_input.dart | 80 ++---- lib/src/widgets/ind_webid_input.dart | 138 ++++----- lib/src/widgets/ind_webid_input_screen.dart | 22 +- 4 files changed, 262 insertions(+), 273 deletions(-) diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index fbb44444..485c3e9d 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -55,9 +55,9 @@ import 'package:solidui/src/utils/snack_bar.dart'; import 'package:solidui/src/utils/solid_alert.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; @@ -334,40 +342,89 @@ 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]; - }); - - /// 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 individual WebID the user has previously confirmed via - /// "Select WebId" along with any pending text. + /// Drop any individual WebID text typed by the user. Invoked by the + /// Clear button on the individual WebID input widget. void clearIndWebIdInput() => setState(() { - selectedRecipientDetails = ''; finalWebIdList = []; _pendingIndWebId = ''; }); - /// Drop any group the user has previously confirmed via - /// "Select Group of WebIds". + /// Drop any group text typed by the user. Invoked by the Clear button + /// on the group WebID input widget. void clearGroupWebIdInput() => setState(() { - selectedRecipientDetails = ''; 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; + } + if (await checkResourceStatus(webId) != ResourceStatus.exist) { + if (!mounted) return false; + await _alert( + 'This WebID does not exist. Please enter the correct WebID', + ); + 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; + } + for (final webId in webIdList) { + final isAbsolute = Uri.parse(webId.replaceAll('#me', '')).isAbsolute; + final exists = + isAbsolute && await checkResourceStatus(webId) == ResourceStatus.exist; + if (!mounted) return false; + if (!isAbsolute || !exists) { + await _alert( + 'At least one of the Web IDs you entered is not valid', + ); + 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(() { @@ -393,15 +450,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]; }); @@ -454,7 +508,6 @@ 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()), @@ -463,17 +516,13 @@ class _GrantPermissionFormState extends State { ] else if (selectedRecipientType == RecipientType.group) ...[ // Select group of recipients if required GroupWebIdTextInput( - onSubmitFunction: updateGroupWebIdInput, + 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 @@ -496,90 +545,92 @@ 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) { + 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('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 f1be9ba0..0b660b76 100644 --- a/lib/src/widgets/group_webid_input.dart +++ b/lib/src/widgets/group_webid_input.dart @@ -34,30 +34,35 @@ library; import 'package:flutter/material.dart'; import 'package:markdown_tooltip/markdown_tooltip.dart'; -import 'package:solidpod/solidpod.dart' - show checkResourceStatus, ResourceStatus, 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'; -/// 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; + /// 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 group already confirmed via - /// "Select Group of WebIds" so the dialog returns to a clean state. + /// parent form uses this to drop any cached state so the dialog returns + /// to a clean state. final VoidCallback? onClearFunction; const GroupWebIdTextInput({ super.key, - required this.onSubmitFunction, + this.onGroupNameChanged, + this.onGroupWebIdsChanged, this.onClearFunction, }); @@ -111,6 +116,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 @@ -120,60 +126,22 @@ 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 { - // Check if all the input entries are correct - final groupName = formControllerGroupName.text.trim(); - final groupWebIds = formControllerGroupWebIds.text.trim(); - - // Check if both fields are not empty - if (groupName.isNotEmpty && groupWebIds.isNotEmpty) { - final webIdList = groupWebIds.split(';'); - - // Check if all the webIds are true links - var trueWebIdsFlag = true; - for (final webId in webIdList) { - if (!Uri.parse( - webId.replaceAll('#me', ''), - ).isAbsolute || - !(await checkResourceStatus(webId) == - ResourceStatus.exist)) { - trueWebIdsFlag = false; - } - } - - if (trueWebIdsFlag) { - // Save selected webid group - widget.onSubmitFunction(groupName, webIdList); - } else { - if (!context.mounted) return; - await alert( - context, - 'At least one of the Web IDs you entered is not valid', - ); - } - } else { - if (!context.mounted) return; - await alert( - context, - 'Please enter a group name and a list of Web IDs', - ); - } - }, - child: const Text('Select Group of WebIds'), - ), TextButton( onPressed: () { - // Wipe both group fields and any cached selection so - // the user can start over. + // 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('Clear'), diff --git a/lib/src/widgets/ind_webid_input.dart b/lib/src/widgets/ind_webid_input.dart index 860fb5df..e18d199d 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 checkResourceStatus, ResourceStatus, whatIsWebID, demoWebID; +import 'package:solidpod/solidpod.dart' show whatIsWebID, demoWebID; import 'package:solidui/solidui.dart' show @@ -43,39 +42,70 @@ import 'package:solidui/solidui.dart' GrantPermFormLayout, WebIdLayout, DropdownColors; -import 'package:solidui/src/utils/solid_alert.dart'; -/// 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 recipient already confirmed via - /// "Select WebId" so the dialog returns to a clean state. + /// parent form uses this to drop any cached state so the dialog returns + /// to a clean state. final VoidCallback? onClearFunction; @override @@ -109,36 +139,9 @@ 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 @@ -206,34 +209,11 @@ class _IndWebIdTextInputState extends State { boxedSuggestionList(context, webIdList), ], ], - TextButton( - onPressed: () async { - final receiverWebId = formControllerWebId.text.trim(); - - // User has entered WebId text that satisfies error checks - if (receiverWebId.isNotEmpty && _helpText == null) { - // Check WebId exists - - if (await checkResourceStatus(receiverWebId) == - ResourceStatus.exist) { - // Save provided WebId - widget.onSubmitFunction(receiverWebId); - } else { - if (!context.mounted) return; - // Request WebId that exists - await alert( - context, - 'This WebID does not exist. Please enter the correct WebID', - ); - } - } - }, - child: const Text('Select WebId'), - ), TextButton( onPressed: () { - // Wipe the WebID field and any cached selection so the - // user can start over. + // 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; @@ -268,11 +248,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 d7d985cc..cbdad483 100644 --- a/lib/src/widgets/ind_webid_input_screen.dart +++ b/lib/src/widgets/ind_webid_input_screen.dart @@ -42,22 +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 @@ -95,12 +88,10 @@ 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, @@ -110,7 +101,7 @@ class _IndWebIdInputScreenState extends State { @override Widget build(BuildContext context) { return (widget.dataFilesMap.isNotEmpty) - ? _loadIndWebIdTextInput(widget.onSubmitFunction, uniqRecipWebIdList) + ? _loadIndWebIdTextInput(uniqRecipWebIdList) : FutureBuilder( future: _asyncGetRecipList, builder: (context, snapshot) { @@ -120,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); } From 3e0184e7c38bc76a048895d06212dd90bcc0bbaa Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 21 May 2026 01:34:53 +1000 Subject: [PATCH 4/8] Fix some minor issues --- lib/src/widgets/grant_permission_form.dart | 62 +++++++++++++++++----- lib/src/widgets/group_webid_input.dart | 35 +----------- lib/src/widgets/ind_webid_input.dart | 5 +- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index 485c3e9d..00b66d3f 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -55,6 +55,7 @@ import 'package:solidui/src/utils/snack_bar.dart'; import 'package:solidui/src/utils/solid_alert.dart'; import 'package:solidui/src/widgets/grant_permission_helpers_ui.dart'; import 'package:solidui/src/widgets/group_webid_input.dart'; +import 'package:solidui/src/utils/webid_message.dart' show webIdCheckMessage; 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'; @@ -342,6 +343,31 @@ class _GrantPermissionFormState extends State { }) async => showSnackBar(context, msg, bgColor, duration: duration); + /// Priority order used when several WebIDs in the group list fail. We + /// surface a single dialog and prefer the most actionable failure mode. + + static 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. + static (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; + } + /// Drop any individual WebID text typed by the user. Invoked by the /// Clear button on the individual WebID input widget. void clearIndWebIdInput() => setState(() { @@ -373,11 +399,12 @@ class _GrantPermissionFormState extends State { await _alert(formatError); return false; } - if (await checkResourceStatus(webId) != ResourceStatus.exist) { - if (!mounted) return false; - await _alert( - 'This WebID does not exist. Please enter the correct WebID', - ); + 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(() { @@ -406,18 +433,27 @@ class _GrantPermissionFormState extends State { 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 isAbsolute = Uri.parse(webId.replaceAll('#me', '')).isAbsolute; - final exists = - isAbsolute && await checkResourceStatus(webId) == ResourceStatus.exist; + final result = await validateWebId(webId); if (!mounted) return false; - if (!isAbsolute || !exists) { - await _alert( - 'At least one of the Web IDs you entered is not valid', - ); - 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; diff --git a/lib/src/widgets/group_webid_input.dart b/lib/src/widgets/group_webid_input.dart index ec0421a7..755ffb6b 100644 --- a/lib/src/widgets/group_webid_input.dart +++ b/lib/src/widgets/group_webid_input.dart @@ -33,43 +33,10 @@ 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 a group of WebIDs. /// diff --git a/lib/src/widgets/ind_webid_input.dart b/lib/src/widgets/ind_webid_input.dart index 3744d278..de6f84c1 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,8 +42,6 @@ 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; /// Returns a human-readable error describing why [text] is not a valid WebID, /// or `null` if [text] is well-formed. From 7b2247df22c6d2e4fa21bf5115f06e1e00875b86 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 21 May 2026 01:36:46 +1000 Subject: [PATCH 5/8] Lint --- lib/src/widgets/grant_permission_form.dart | 8 ++++---- lib/src/widgets/ind_webid_input.dart | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index 00b66d3f..75e0a5cd 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -53,10 +53,11 @@ import 'package:solidui/solidui.dart' 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_helpers_ui.dart'; import 'package:solidui/src/widgets/group_webid_input.dart'; -import 'package:solidui/src/utils/webid_message.dart' show webIdCheckMessage; -import 'package:solidui/src/widgets/ind_webid_input.dart' show indWebIdFormatError; +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'; @@ -446,8 +447,7 @@ class _GrantPermissionFormState extends State { if (failures.isNotEmpty) { // Surface the most informative failure to the user. - final (failedWebId, failedResult) = - _pickGroupFailureToReport(failures); + 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); diff --git a/lib/src/widgets/ind_webid_input.dart b/lib/src/widgets/ind_webid_input.dart index de6f84c1..2187a43b 100644 --- a/lib/src/widgets/ind_webid_input.dart +++ b/lib/src/widgets/ind_webid_input.dart @@ -140,8 +140,7 @@ class _IndWebIdTextInputState extends State { } /// Generate advice to help user enter valid WebID. - String? get _helpText => - indWebIdFormatError(formControllerWebId.value.text); + String? get _helpText => indWebIdFormatError(formControllerWebId.value.text); /// Generate suggestions for users based on input matches to /// current complete recipient list of user From e9053360dfef8459a435af31e45066d89a6b71d2 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 21 May 2026 01:49:05 +1000 Subject: [PATCH 6/8] Fix the locmax issue --- lib/src/widgets/grant_permission_dialogs.dart | 163 ++++++++++++++++++ lib/src/widgets/grant_permission_form.dart | 133 ++------------ 2 files changed, 173 insertions(+), 123 deletions(-) create mode 100644 lib/src/widgets/grant_permission_dialogs.dart diff --git a/lib/src/widgets/grant_permission_dialogs.dart b/lib/src/widgets/grant_permission_dialogs.dart new file mode 100644 index 00000000..ea030682 --- /dev/null +++ b/lib/src/widgets/grant_permission_dialogs.dart @@ -0,0 +1,163 @@ +/// 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/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 content is constrained to +/// approximately 60 characters wide. + +Future showGrantPermissionErrorDialog( + BuildContext context, + String title, + String message, +) async { + 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'), + ), + ], + ), + ); +} + +/// 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'), + 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 (!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 75e0a5cd..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,13 +45,13 @@ 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' @@ -244,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, @@ -344,31 +250,6 @@ class _GrantPermissionFormState extends State { }) async => showSnackBar(context, msg, bgColor, duration: duration); - /// Priority order used when several WebIDs in the group list fail. We - /// surface a single dialog and prefer the most actionable failure mode. - - static 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. - static (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; - } - /// Drop any individual WebID text typed by the user. Invoked by the /// Clear button on the individual WebID input widget. void clearIndWebIdInput() => setState(() { @@ -447,7 +328,7 @@ class _GrantPermissionFormState extends State { if (failures.isNotEmpty) { // Surface the most informative failure to the user. - final (failedWebId, failedResult) = _pickGroupFailureToReport(failures); + 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); @@ -652,7 +533,9 @@ class _GrantPermissionFormState extends State { // Trigger the onPermissionGranted callback if provided widget.onPermissionGranted?.call(); } else if (result == SolidFunctionCallStatus.fail) { - await _showErrorDialog( + if (!context.mounted) return; + await showGrantPermissionErrorDialog( + context, 'Permission granting failed', failureMsg, ); @@ -664,7 +547,11 @@ class _GrantPermissionFormState extends State { selectedPermList, ); } else if (result == SolidFunctionCallStatus.notInitialised) { - await _handleNotInitialisedRecipients(); + if (!context.mounted) return; + await handleNotInitialisedRecipients( + context, + widget.inviteConfig, + ); } else { await _alert(updatePermissionMsg); } From fa2bbd1883f0cdda52b6a739bb54e023e8785ddf Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 21 May 2026 02:02:55 +1000 Subject: [PATCH 7/8] Merge branch #570 to this branch --- lib/src/utils/solid_alert.dart | 82 ++-- lib/src/utils/webid_message.dart | 9 - lib/src/widgets/grant_permission_dialogs.dart | 174 ++++++++ lib/src/widgets/grant_permission_form.dart | 408 +++++++++--------- lib/src/widgets/group_webid_input.dart | 132 ++---- lib/src/widgets/ind_webid_input.dart | 155 ++++--- lib/src/widgets/ind_webid_input_screen.dart | 28 +- 7 files changed, 540 insertions(+), 448 deletions(-) create mode 100644 lib/src/widgets/grant_permission_dialogs.dart diff --git a/lib/src/utils/solid_alert.dart b/lib/src/utils/solid_alert.dart index 27265899..e049135f 100644 --- a/lib/src/utils/solid_alert.dart +++ b/lib/src/utils/solid_alert.dart @@ -31,53 +31,57 @@ library; import 'package:flutter/material.dart'; /// Approximate average width, in logical pixels, of one character at the -/// default Material body text size (~14 sp). Used to translate -/// [alert]'s `maxCharsPerLine` into an actual `maxWidth` constraint. A -/// slightly conservative value is chosen so that wide glyphs (e.g. `m`, `W`) +/// 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.5; -/// Default character-per-line cap used by callers that want a sensible -/// reading width without picking a number themselves. ~90 characters keeps -/// long messages readable on wide desktop windows while still leaving room -/// for the dialog's chrome on a phone. -const int defaultDialogMaxCharsPerLine = 90; +const double _approxCharWidthLogicalPixels = 7.0; -/// Pop up an alert dialog with the given [msg]. +/// 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 `'Notice'`. Pass [maxCharsPerLine] to cap the width -/// of the message column at roughly that many characters of body text, -/// preventing the dialog from stretching across the full width of a desktop -/// window when the message is long. Pass `null` (the default) to keep the -/// platform default sizing. +/// [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, - String msg, { + String msg, [ String title = 'Alert', - int? maxCharsPerLine, -}) async { +]) async { await showDialog( context: context, - builder: (context) { - final messageText = Text(msg); - final content = maxCharsPerLine == null - ? messageText - : ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxCharsPerLine * _approxCharWidthLogicalPixels, - ), - child: messageText, - ); - return AlertDialog( - title: Text(title), - content: content, - actions: [ - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], - ); - }, + builder: (context) => AlertDialog( + title: Text(title), + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: alertMaxWidthForCharsPerLine(defaultAlertMaxCharsPerLine), + ), + child: Text(msg), + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), ); } diff --git a/lib/src/utils/webid_message.dart b/lib/src/utils/webid_message.dart index 406d62d8..80f666f0 100644 --- a/lib/src/utils/webid_message.dart +++ b/lib/src/utils/webid_message.dart @@ -34,15 +34,6 @@ library; import 'package:solidpod/solidpod.dart' show WebIdCheckResult, WebIdCheckStatus, isValidIpv4; -/// Recommended character-per-line cap for WebID error dialogs. -/// -/// These messages can be quite long (host names, URL examples, etc.) and on -/// a wide desktop window they would otherwise stretch the dialog across the -/// entire screen and become hard to read. ~90 characters gives a comfortable -/// reading width on both phone and desktop. Callers should pass this to -/// `alert(..., maxCharsPerLine: webIdMessageMaxCharsPerLine)`. -const int webIdMessageMaxCharsPerLine = 90; - /// Build a user-facing message for a non-valid [result], or `null` when the /// result is [WebIdCheckStatus.valid] and no message is needed. /// diff --git a/lib/src/widgets/grant_permission_dialogs.dart b/lib/src/widgets/grant_permission_dialogs.dart new file mode 100644 index 00000000..f559e264 --- /dev/null +++ b/lib/src/widgets/grant_permission_dialogs.dart @@ -0,0 +1,174 @@ +/// 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'), + 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 (!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 9e2e46e4..755ffb6b 100644 --- a/lib/src/widgets/group_webid_input.dart +++ b/lib/src/widgets/group_webid_input.dart @@ -33,55 +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, webIdMessageMaxCharsPerLine; -/// 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(); @@ -133,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 @@ -142,62 +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, - maxCharsPerLine: webIdMessageMaxCharsPerLine, - ); + 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 83471a4d..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,37 +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, webIdMessageMaxCharsPerLine; -/// 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(); } @@ -105,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 @@ -203,38 +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, - maxCharsPerLine: webIdMessageMaxCharsPerLine, - ); - } + 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'), ), ], ), @@ -260,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); } From 7b49ab456266d98659090e315776d08d2d77b49c Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 21 May 2026 02:23:59 +1000 Subject: [PATCH 8/8] Cap the message column at ~90 characters in 'Recipient has not set up a POD' dialog --- lib/src/widgets/grant_permission_dialogs.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/src/widgets/grant_permission_dialogs.dart b/lib/src/widgets/grant_permission_dialogs.dart index f559e264..6823007a 100644 --- a/lib/src/widgets/grant_permission_dialogs.dart +++ b/lib/src/widgets/grant_permission_dialogs.dart @@ -100,11 +100,20 @@ Future handleNotInitialisedRecipients( context: context, builder: (dialogContext) => 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?', + // 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(