diff --git a/README.md b/README.md index a1d45b2..2663324 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ utilising the solidui package. - [Requirements](#requirements) - [SolidScaffold](#solidscaffold) - [SolidFile](#solidfile) +- [Grant Permission UI Example](#grant-permission-ui-example) +- [View Permission UI Example](#view-permission-ui-example) - [Authentication and Login Detection](#authentication-and-login-detection) - [Security Key Management](#security-key-management) - [API Reference](#api-reference) @@ -115,23 +117,22 @@ Fine tune to suit the theme of the app: - For defining specific access mode types or recipient types, use optional parameters `accessModeList` and `recipientTypeList`. -Granting permission (currently ublished in solidpod): +Granting permission:
KeyPod Login + alt="Grant Permission" width="400">
Revoking permission:
KeyPod Login + alt="Revoke Permission" width="400">
- SharedResourcesUi widget displays - resources shared with a Pod by others (currently published in - solidpod): + resources shared with a Pod by others:
Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GrantPermissionUi( + child: ReturnPage(), + ), + ), + ), +) +``` + +### Permissions for a specific file + +```dart +ElevatedButton( + child: const Text('Add/Delete Permissions from a Specific File'), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GrantPermissionUi( + resourceName: 'my-data-file.ttl', + child: ReturnPage(), + ), + ), + ), +) +``` + +### Permissions for a specific directory + +```dart +ElevatedButton( + child: const Text('Add/Delete Permissions from a Specific Directory'), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GrantPermissionUi( + resourceName: 'parentDir/', + child: ReturnPage(), + isFile: false, + ), + ), + ), +) +``` + +### Permissions for an externally owned resource + +When the user has *control* access to a resource owned by someone else: + +```dart +ElevatedButton( + child: const Text('Add/Delete Permissions from an External File'), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GrantPermissionUi( + resourceName: 'my-data-file.ttl', + isExternalRes: true, + ownerWebId: ownerWebId, + granterWebId: granterWebId, + child: ReturnPage(), + ), + ), + ), +) +``` + +## View Permission UI Example + +The `SharedResourcesUi` widget displays the resources that have been +shared with the current user's POD by others. + +### View all shared resources + +```dart +ElevatedButton( + child: const Text('View Resources your WebID have access to'), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SharedResourcesUi( + child: ReturnPage(), + ), + ), + ), +) +``` + +### View a specific shared resource + +```dart +ElevatedButton( + child: const Text('View access to specific Resource'), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SharedResourcesUi( + fileName: 'my-data-file.ttl', + sourceWebId: + 'https://pods.solidcommunity.au/john-doe/profile/card#me', + child: ReturnPage(), + ), + ), + ), +) +``` + ## Authentication and Login Detection SolidUI provides dynamic login status detection and management through diff --git a/example/pubspec.yaml b/example/pubspec.yaml index dd5aacf..c04f535 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -21,7 +21,7 @@ dependency_overrides: solidpod: git: url: https://github.com/anusii/solidpod.git - ref: dev + ref: tony/59_ui_migration solidui: path: .. diff --git a/lib/solidui.dart b/lib/solidui.dart index 91d8cf4..50e80af 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -99,6 +99,7 @@ export 'src/models/snackbar_config.dart'; export 'src/utils/file_operations.dart'; export 'src/utils/is_text_file.dart'; export 'src/utils/solid_file_operations.dart'; +export 'src/utils/is_phone.dart'; export 'src/utils/solid_alert.dart'; export 'src/utils/solid_notifications.dart'; export 'src/utils/solid_pod_helpers.dart' @@ -108,6 +109,26 @@ export 'src/widgets/solid_format_info_card.dart'; export 'src/widgets/build_message_container.dart'; +export 'src/widgets/app_bar.dart'; +export 'src/widgets/file_explorer.dart'; +export 'src/widgets/group_webid_input.dart'; +export 'src/widgets/ind_webid_input.dart'; +export 'src/widgets/ind_webid_input_screen.dart'; +export 'src/widgets/permission_checkbox.dart'; +export 'src/widgets/shared_resources_table.dart'; + +export 'src/widgets/grant_permission_ui.dart'; +export 'src/widgets/shared_resources_ui.dart'; +export 'src/widgets/permission_table.dart'; +export 'src/widgets/grant_permission_form.dart'; +export 'src/widgets/select_recipients.dart'; +export 'src/widgets/show_selected_recipients.dart'; +export 'src/widgets/revoke_permission_button.dart'; +export 'src/widgets/share_resource_button.dart'; + +export 'src/widgets/permission_history.dart'; +export 'src/widgets/grant_permission_helpers_ui.dart'; + export 'src/constants/initial_setup.dart'; export 'src/screens/initial_setup_screen.dart'; export 'src/screens/initial_setup_screen_body.dart'; diff --git a/lib/src/utils/is_phone.dart b/lib/src/utils/is_phone.dart new file mode 100644 index 0000000..9a2ab73 --- /dev/null +++ b/lib/src/utils/is_phone.dart @@ -0,0 +1,49 @@ +/// Check if we are running on a mobile device (and not a browser). +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +library; + +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; + +/// Checks the platform type to determine whether running on +/// a mobile device. +/// +/// Returns true if running on iOS or Android. +/// Returns false on Flutter Web or desktop platforms. + +bool isPhone() { + if (kIsWeb) { + return false; + } + + return defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; +} diff --git a/lib/src/widgets/app_bar.dart b/lib/src/widgets/app_bar.dart new file mode 100644 index 0000000..72228ed --- /dev/null +++ b/lib/src/widgets/app_bar.dart @@ -0,0 +1,75 @@ +/// A default app bar. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage, Ashley Tang + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/src/widgets/solid_login_helper.dart' + show pushReplacement; + +/// A default app bar that is used when user does not define an app bar for +/// the UI +PreferredSizeWidget defaultAppBar( + BuildContext context, + String title, + Color backgroundColor, + Widget child, { + VoidCallback? onNavigateBack, + bool Function()? getResult, +}) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { + // Call the callback if provided. + + onNavigateBack?.call(); + + if (getResult != null) { + // Pop with result from callback. + + Navigator.pop(context, getResult()); + } else { + // Use the original pushReplacement behaviour. + + pushReplacement(context, child); + + // Navigator.pushReplacement( + // context, + // MaterialPageRoute(builder: (context) => child), + // ); + } + }, + ), + backgroundColor: backgroundColor, + title: Text(title), + ); +} diff --git a/lib/src/widgets/file_explorer.dart b/lib/src/widgets/file_explorer.dart new file mode 100644 index 0000000..b6f4925 --- /dev/null +++ b/lib/src/widgets/file_explorer.dart @@ -0,0 +1,430 @@ +/// A simple file explorer for navigating through both own and external PODs +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart'; + +import 'package:solidui/solidui.dart' show normalLoadingScreenHeight; +import 'package:solidui/src/utils/snack_bar.dart'; +import 'package:solidui/src/utils/solid_alert.dart'; +import 'package:solidui/src/widgets/solid_loading_screen.dart'; + +/// A simple file explorer class with two input parameters +class FileExplorerScreen extends StatefulWidget { + const FileExplorerScreen({ + super.key, + required this.folderPath, + required this.child, + required this.isEditable, + required this.ownerWebId, + }); + + @override + State createState() => _FileExplorerScreenState(); + + final String folderPath; + final Widget child; + final bool isEditable; + final String ownerWebId; +} + +class _FileExplorerScreenState extends State { + String currentPath = ''; + List folderList = []; + List fileList = []; + + bool isLoading = true; + String errMsg = ''; + + @override + void initState() { + super.initState(); + _getResources(); + } + + Future _getResources() async { + try { + final res = await getResourcesInContainer(widget.folderPath); + setState(() { + folderList = res.subDirs; + fileList = res.files; + currentPath = widget.folderPath; + isLoading = false; + }); + } on AccessForbiddenException catch (e) { + debugPrint('Exception occured: $e'); + setState(() { + errMsg = 'You do not have access to this directory'; + isLoading = false; + }); + } on AccessFailedException catch (e) { + debugPrint('Exception occured: $e'); + setState(() { + errMsg = 'Reading directory content failed. Please try again'; + isLoading = false; + }); + } catch (e) { + debugPrint('Unknown exception occured: $e'); + setState(() { + errMsg = 'Unkown error occured: $e'; + isLoading = false; + }); + } + } + + PreferredSizeWidget defaltAppBar() { + return AppBar( + title: const Text('Go back'), + leading: currentPath.isNotEmpty + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + // final parent = Directory(currentPath).parent; + // _loadDirectory(parent.path); + + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => widget.child), + ); + }, + ) + : null, + ); + } + + @override + Widget build(BuildContext context) { + if (isLoading) { + return Scaffold(body: loadingScreen(normalLoadingScreenHeight)); + } + + if (errMsg.isNotEmpty) { + return Scaffold( + appBar: defaltAppBar(), + body: Center( + child: Text( + errMsg, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + ), + ); + } + + final List<_Section> sections = []; + + if (folderList.isNotEmpty) { + sections.add( + _Section(title: 'Folders', items: folderList, isFolder: true), + ); + } + + if (fileList.isNotEmpty) { + sections.add(_Section(title: 'Files', items: fileList, isFolder: false)); + } + + // If both are empty, show placeholder + if (sections.isEmpty) { + return Scaffold( + appBar: defaltAppBar(), + body: const Center( + child: Text( + 'No directories or files found.', + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ), + ); + } + + return Scaffold( + appBar: defaltAppBar(), + body: // One scrollable list + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + color: Colors.grey[200], + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + const Icon(Icons.folder_open, color: Colors.blue), + const SizedBox(width: 8), + Expanded( + child: Text( + currentPath, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, // truncate if long + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: sections.length, + itemBuilder: (context, sectionIndex) { + final section = sections[sectionIndex]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section header + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + section.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + // List items + ListView.builder( + itemCount: section.items.length, + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), // Disable inner scroll + itemBuilder: (context, index) { + final item = section.items[index]; + return ListTile( + leading: Icon( + section.isFolder + ? Icons.folder + : Icons.insert_drive_file, + color: + section.isFolder ? Colors.amber : Colors.blue, + ), + trailing: (!section.isFolder && widget.isEditable) + ? IconButton( + icon: const Icon(Icons.edit), + onPressed: () async { + final filePath = + '${widget.folderPath}$item'; + final fileContent = await readExternalPod( + filePath, + ); + + final TextEditingController editController = + TextEditingController( + text: fileContent, + ); + + if (!context.mounted) return; + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text( + 'Edit File Content', + ), + content: SizedBox( + width: double.maxFinite, + child: TextField( + controller: editController, + autofocus: true, + maxLines: + null, // allows multiple lines + minLines: + 3, // starts with 3 visible lines + textInputAction: + TextInputAction.newline, + keyboardType: + TextInputType.multiline, + decoration: const InputDecoration( + labelText: + 'Enter your file content', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of( + context, + ).pop(); // Cancel -> close dialog + }, + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final newContent = + editController.text; + // Check if the content has changed + if (newContent != fileContent) { + try { + await writeExternalPod( + filePath, + newContent, + widget.ownerWebId, + ); + + if (!context.mounted) { + return; + } + + showSnackBar( + context, + 'Changes saved successfully!', + Colors.green, + ); + } on Object catch (e, trace) { + debugPrint('$e'); + debugPrint('$trace'); + if (!context.mounted) { + return; + } + showSnackBar( + context, + 'Something went wrong! Please try again.', + Colors.red, + ); + } + } else { + if (!context.mounted) return; + showSnackBar( + context, + 'Content has not changed', + Colors.orange, + ); + } + + Navigator.of( + context, + ).pop(); // Save -> return value + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ) + : null, + title: Text(item), + onTap: () async { + if (section.isFolder) { + final newFolderPath = + '${widget.folderPath}$item/'; + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => FileExplorerScreen( + folderPath: newFolderPath, + isEditable: widget.isEditable, + ownerWebId: widget.ownerWebId, + child: FileExplorerScreen( + folderPath: widget.folderPath, + isEditable: widget.isEditable, + ownerWebId: widget.ownerWebId, + child: widget.child, + ), + ), + ), + ); + } else { + final filePath = '${widget.folderPath}$item'; + + try { + final fileContent = await readExternalPod( + filePath, + ); + + if (!context.mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('File content'), + content: Stack( + alignment: Alignment.center, + children: [ + Container( + width: double.infinity, + height: 300, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 15, + ), + ), + child: Text(fileContent), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + // Close the dialog + Navigator.of(ctx).pop(); + }, + child: const Text('Ok'), + ), + ], + ), + ); + } on Object catch (e, trace) { + debugPrint(e.toString()); + debugPrint(trace.toString()); + if (!context.mounted) return; + await alert( + context, + 'The file $item could not be found!', + ); + } + } + // handle open file/folder + }, + ); + }, + ), + ], + ); + }, + ), + ), + ], + ), + ); + } +} + +// Helper class for sections +class _Section { + final String title; + final List items; + final bool isFolder; + + _Section({required this.title, required this.items, required this.isFolder}); +} diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart new file mode 100644 index 0000000..a1fc611 --- /dev/null +++ b/lib/src/widgets/grant_permission_form.dart @@ -0,0 +1,435 @@ +/// A button for sharing a resource. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart'; + +import 'package:solidui/solidui.dart' + show + ActionColors, + GrantPermFormLayout, + debugPrintException, + debugPrintFailure, + failureMsg, + 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/widgets/group_webid_input.dart'; +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 +/// +/// A [StatefulWidget] for creating a grant permission form +/// dialog to get recipient and access modes to grant for the +/// provided [resourceName] +/// +/// Parameters: +/// - [resourceName] - The filename or file url of the resource. If [isExternalRes], it should be the url of the resource. +/// - [isExternalRes] - Boolean flag describing whether the resource +/// is externally owned. +/// - [ownerWebId] - WebId of the owner of the resource. Required if the resource is externally owned. +/// - [granterWebId] - WebId of the granter of the resource. Required if the resource is externally owned. +/// - [accessModeList] - List of access mode options to show. +/// - [recipientTypeList] - List of recipient type options to show. +/// - [isFile] - Boolean flag describing whether the resource is a file. If false, the resource is assumed to be a directory. +/// - [updatePermissionsFunction] is the function to be called to refresh the permission table. +/// - [updatePermissionGrantedFunction] - is the function to be called +/// when permissions are granted successfully +/// - [onPermissionGranted] - Callback function called when permissions are granted successfully. + +class GrantPermissionForm extends StatefulWidget { + /// String to assign the webId of the resource owner. + + final String ownerWebId; + + /// String to assign the external webId of the resource granter. + + final String granterWebId; + + /// The name of the file or directory that access is being granted for. + + final String resourceName; + + final bool isExternalRes; + + /// A flag to determine whether the given resource is a file or not. + + final bool isFile; + + /// The list of access modes to show in form. By default + /// all four types of access mode are listed. + + final List accessModeList; + + /// The list of types of recipients to show in form. By default + /// all four types of recipient are listed. + + final List recipientTypeList; + + /// Map of data files on a user's POD used to extract the + /// user's recipient list by the WebIdTextInputScreen. + /// If not provided, the WebIdTextInputScreen will read the + /// user's files in their app data folder on their Pod to + /// fetch the ACLs needed to derive the user's recipient list. + + final Map dataFilesMap; + + /// Function run to update permissions table + + final Function updatePermissionsFunction; + + /// Function when permissions are granted successfully + + final Function updatePermissionGrantedFunction; + + /// Callback function called when permissions are granted successfully. + + final VoidCallback? onPermissionGranted; + + const GrantPermissionForm({ + super.key, + required this.updatePermissionsFunction, + required this.resourceName, + required this.ownerWebId, + required this.granterWebId, + this.accessModeList = const ['read', 'write', 'append', 'control'], + this.recipientTypeList = const ['public', 'indi', 'auth', 'group'], + required this.isExternalRes, + required this.isFile, + required this.updatePermissionGrantedFunction, + this.dataFilesMap = const {}, + this.onPermissionGranted, + }); + + @override + State createState() => _GrantPermissionFormState(); +} + +class _GrantPermissionFormState extends State { + /// Selected recipient + + RecipientType selectedRecipientType = RecipientType.none; + + /// Selected recipient details + + String selectedRecipientDetails = ''; + + /// List of webIds for group permission + + List finalWebIdList = []; + + /// Selected group name + + String selectedGroupName = ''; + + /// Selected list of permissions + + List selectedPermList = []; + + /// Flag to track if permissions were granted successfully. + + bool permissionsGrantedSuccessfully = false; + + /// read permission checked flag + + bool readChecked = false; + + /// write permission checked flag + + bool writeChecked = false; + + /// control permission checked flag + + bool controlChecked = false; + + /// append permission checked flag + + bool appendChecked = false; + + /// Public permission check flag + + bool publicChecked = false; + + /// Define access mode list + + List accessModeList = []; + + @override + void initState() { + super.initState(); + + // Load access mode list to be displayed + for (final accessModeStr in widget.accessModeList) { + accessModeList.add(getAccessMode(accessModeStr)); + } + } + + @override + void dispose() { + super.dispose(); + } + + /// Private function to call alert dialog in share resource button + /// context. This provides an alert dialog over the top of the + /// grant permission form dialog. + Future _alert(String msg) async => alert(context, msg); + + /// Private function to show snackbar in share resource button context + Future _showSnackBar( + String msg, + Color bgColor, { + Duration duration = const Duration(seconds: 4), + }) 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; + }); + + /// Update checked status of access mode boxes to show + /// selected access modes. + void updateCheckbox(bool newValue, AccessMode accessMode) => setState(() { + switch (accessMode) { + case AccessMode.read: + readChecked = newValue; + case AccessMode.write: + writeChecked = newValue; + case AccessMode.control: + controlChecked = newValue; + case AccessMode.append: + appendChecked = newValue; + } + if (newValue) { + selectedPermList.add(accessMode.mode); + } else { + selectedPermList.remove(accessMode.mode); + } + }); + + /// Define button click actions for each recipient type button + + /// 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]; + }); + + /// Select individual recipient + void _setRecipientsToIndividual() => setState(() { + selectedRecipientType = RecipientType.individual; + }); + + /// Select a group of recipients + void _setRecipientsToGroup() => setState(() { + selectedRecipientType = RecipientType.group; + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + insetPadding: GrantPermFormLayout.contentPadding, + title: Text('Share ${widget.resourceName}'), + content: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: SizedBox( + // Use full width on phones, else use a preset narrower width + width: (!isPhone()) + ? GrantPermFormLayout.dialogWidth + : double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + makeSubHeading('Select the recipient/s of file access'), + + // Show Select Recipient Buttons + SelectRecipients( + isExternalRes: widget.isExternalRes, + recipientTypeList: widget.recipientTypeList, + setPublicFunction: _setRecipientsToPublic, + setAuthUsersFunction: _setRecipientsToAuthUsers, + setIndividualFunction: _setRecipientsToIndividual, + setGroupFunction: _setRecipientsToGroup, + ), + + // Select Individual recipient if required + if (selectedRecipientType == RecipientType.individual) ...[ + IndWebIdInputScreen( + onSubmitFunction: updateIndWebIdInput, + dataFilesMap: widget.dataFilesMap, + ), + ] else if (selectedRecipientType == RecipientType.group) ...[ + // Select group of recipients if required + GroupWebIdTextInput(onSubmitFunction: updateGroupWebIdInput), + ], + // 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 + // selection status on click + ...getPermissionCheckBoxes( + accessModeList, + modeSwitches: { + AccessMode.read: readChecked, + AccessMode.write: writeChecked, + AccessMode.control: controlChecked, + AccessMode.append: appendChecked, + }, + onUpdate: updateCheckbox, + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () async { + // Grant Permission and update permission map + // used by permission table + + if (selectedRecipientType.type.isNotEmpty) { + if (selectedPermList.isNotEmpty) { + SolidFunctionCallStatus result; + try { + // Update ACL and permission logs to grant permission + result = await grantPermission( + fileName: widget.resourceName, + isFile: widget.isFile, + permissionList: selectedPermList, + recipientType: selectedRecipientType, + recipientWebIdList: finalWebIdList, + ownerWebId: widget.ownerWebId, + granterWebId: widget.granterWebId, + isExternalRes: widget.isExternalRes, + groupName: selectedGroupName, + ); + + // 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 + await widget.updatePermissionsFunction( + widget.resourceName, //_resourceName, + 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) { + // More detailed error message with troubleshooting tips + _showSnackBar(failureMsg, ActionColors.error); + + // Also log to console for debugging + debugPrintFailure( + widget.resourceName, // _resourceName, + finalWebIdList, + selectedPermList, + ); + } else if (result == SolidFunctionCallStatus.notInitialised) { + _showSnackBar(podNotInitMsg, ActionColors.warning); + } else { + await _alert(updatePermissionMsg); + } + } else { + await _alert( + 'Please select one or more file access permissions', + ); + } + } else { + await _alert('Please select a type of recipient'); + } + }, + child: const Text('Grant Permission'), + ), + TextButton( + onPressed: () { + // Close dialog + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + ], + ); + } +} diff --git a/lib/src/widgets/grant_permission_helpers_ui.dart b/lib/src/widgets/grant_permission_helpers_ui.dart new file mode 100644 index 0000000..73cb212 --- /dev/null +++ b/lib/src/widgets/grant_permission_helpers_ui.dart @@ -0,0 +1,184 @@ +/// UI helper functions and constants for the grant permission workflow. +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage, Jess Moore, Ashley Tang, Dawei Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart' show AccessMode, RecipientType; + +import 'package:solidui/src/constants/ui_layout.dart' show SharingPageLayout; +import 'package:solidui/src/widgets/permission_checkbox.dart'; + +// Constants + +/// Tooltip strings for each recipient type shown in the sharing UI. + +const recipientToolTips = { + RecipientType.public: ''' + **Public:** This file will be publicly + accessible so that even users without a + Data Vault can access the file. + ''', + RecipientType.authUser: ''' +**Users:** The file will be available to +any user who has registered a Data +Vault. When they have logged into their +Data Vault they will be able to access +the file. +''', + RecipientType.individual: ''' +**Individual:** The file will be available +only to the identified individual user. A +WebID is required to identify the +individual who is gratned access to the +file. +''', + RecipientType.group: ''' +**Group:** A collection of WebIDs can be +provided so that as a group they can +access the file. +''', +}; + +const updatePermissionMsg = + 'Please login first to update file access permission'; +const podNotInitMsg = + 'The owner of one or more WebIds you entered have not initialised their PODs yet! They need to login and setup their POD first.'; +const noAclMsg = 'Resource does not have a corresponding ACL file.\n' + 'If the ACL is inherited, provide parent directory as the resource name!'; +const successMsg = 'File access permissions granted successfully!'; +const failureMsg = + 'Permission granting failed. Check console logs for details. Common issues: resource not found, invalid WebID format, or network connectivity.'; + +// Debug helpers + +String getFailureMsg(String fileName) => + '❌ [GrantPermissionUI] Permission granting failed for file: $fileName'; + +String getRecipientMsg(List? finalWebIdList) => + '🎯 [GrantPermissionUI] Recipients: $finalWebIdList'; + +String getPermissionMsg(List permissionList) => + '🔐 [GrantPermissionUI] Permissions: $permissionList'; + +String getExceptionMsg(Object e) => + '💥 [GrantPermissionUI] Exception in grantPermission: $e'; + +String getStackTraceMsg(StackTrace stackTrace) => + '📚 [GrantPermissionUI] Stack trace: $stackTrace'; + +void debugPrintException(Object e, StackTrace stackTrace) { + debugPrint(getExceptionMsg(e)); + debugPrint(getStackTraceMsg(stackTrace)); +} + +void debugPrintFailure( + String fileName, + List? finalWebIdList, + List permissionList, +) { + debugPrint(getFailureMsg(fileName)); + debugPrint(getRecipientMsg(finalWebIdList)); + debugPrint(getPermissionMsg(permissionList)); +} + +// Recipient type lists + +/// Relevant recipients types for resource sharing by the resource owner. +const ownerRecipientTypes = [ + RecipientType.public, + RecipientType.authUser, + RecipientType.individual, + RecipientType.group, +]; + +/// Relevant recipient types for resource sharing by the resource granter +/// (i.e. an entity with control access). +const granterRecipientTypes = [RecipientType.individual, RecipientType.group]; + +/// Get title of sharing page. +String makeSharingTitleStr({String? fileName, bool isFile = false}) => + fileName != null + ? isFile + ? 'Share $fileName' + : 'Share $fileName folder' + : 'Share your data with other user\'s PODs'; + +// Widget builders + +/// Build a list of permission check-box widgets for the given [accessModes]. + +List getPermissionCheckBoxes( + List accessModes, { + required Map modeSwitches, + required Function onUpdate, +}) => + [ + for (final mode in AccessMode.getAllModes()) + if (accessModes.contains(mode)) + permissionCheckbox(mode, modeSwitches[mode]!, onUpdate), + ]; + +/// Build a resource form widget with a text field and a file/directory toggle. + +Widget getResourceForm({ + required TextEditingController formController, + required bool isFile, + required void Function(bool) onResourceTypeChange, +}) => + Padding( + padding: SharingPageLayout.inputPadding, + child: Column( + children: [ + TextFormField( + controller: formController, + decoration: const InputDecoration( + hintText: + 'Data file path (inside your data folder, Eg: personal/about.ttl)', + ), + validator: (value) => + (value == null || value.isEmpty) ? 'Empty field' : null, + ), + const SizedBox(height: 10), + SwitchListTile( + title: const Text( + 'Is a File?', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + subtitle: Text(isFile ? 'Yes' : 'No'), + value: isFile, + onChanged: onResourceTypeChange, + thumbColor: WidgetStateProperty.resolveWith( + (Set states) => + states.contains(WidgetState.selected) ? Colors.green : null, + ), + ), + ], + ), + ); diff --git a/lib/src/widgets/grant_permission_ui.dart b/lib/src/widgets/grant_permission_ui.dart new file mode 100644 index 0000000..f2391c3 --- /dev/null +++ b/lib/src/widgets/grant_permission_ui.dart @@ -0,0 +1,655 @@ +// A screen to demonstrate the data sharing capabilities of PODs. +/// +// Time-stamp: +/// +/// Copyright (C) 2025, 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: Anushka Vidanage, Jess Moore, Ashley Tang, Dawei Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart'; + +import 'package:solidui/solidui.dart'; + +/// A [StatefulWidget] for showing and editing access permissions to a +/// resource. It displays the permission table of users with access, and +/// allows the user to change access permissions: by granting access +/// to others, changing a recipients access permissions or revoking +/// access permissions. +/// +/// Parameters: +/// - [child] - the child widget to return to. +/// - [title] - Page title to show in the app bar. +/// - [backgroundColor] - Background color. +/// - [showAppBar] - Boolean flag describing whether to show app bar. +/// - [isExternalRes] - Boolean flag describing whether the resource +/// is externally owned. +/// - [accessModeList] - List of access mode options to show. +/// - [recipientTypeList] - List of recipient type options to show. +/// - [ownerWebId] - WebId of the owner of the resource. Required if the resource is externally owned. +/// - [granterWebId] - WebId of the granter of the resource. Required if the resource is externall owned. +/// - [resourceName] - The filename or file url of the resource. If [isExternalRes], it should be the url of the resource. +/// - [isFile] - Boolean flag describing whether the resource is a file. If false, the resource is assumed to be a directory. +/// - [customAppBar] - Specify a custom app bar widget. +/// - [onPermissionGranted] - Callback function called when permissions are granted successfully. +/// - [onNavigateBack] - Callback function called when navigating back from the screen. + +class GrantPermissionUi extends StatefulWidget { + const GrantPermissionUi({ + required this.child, + this.title = 'Demonstrating data sharing functionality', + this.backgroundColor = const Color.fromARGB(255, 210, 210, 210), + this.showAppBar = true, + this.isExternalRes = false, + this.accessModeList = const ['read', 'write', 'append', 'control'], + this.recipientTypeList = const ['public', 'indi', 'auth', 'group'], + this.ownerWebId, + this.granterWebId, + this.resourceName, + this.isFile = true, + this.dataFilesMap = const {}, + this.customAppBar, + this.onPermissionGranted, + this.onNavigateBack, + super.key, + }) : assert( + // Requires ownerWebId if resource + // is an externally owned. + isExternalRes == false || ownerWebId != null, + 'ownerWebId must be provided if isExternalRes == true', + ); + + /// The child widget to return to when back button is pressed and/or when + /// page is reloaded after a permission is granted or revoked. + + final Widget child; + + /// The text appearing in the app bar. + + final String title; + + /// The text appearing in the app bar. + + final Color backgroundColor; + + /// The boolean to decide whether to display an app bar or not. + + final bool showAppBar; + + /// The boolean to decide whether the resources is from an external POD or not + + final bool isExternalRes; + + /// String to assign the webId of the resource owner. Must + /// be set if [isExternalRes] is set to true. + + final String? ownerWebId; + + /// String to assign the external webId of the resource granter. Must + /// be set if [isExternalRes] is set to true. + + final String? granterWebId; + + /// The list of access modes to be displayed. By default all four types of + /// access mode are listed. + + final List accessModeList; + + /// The list of types of recipients receiving permission to access the + /// resource. By default all four types of recipient are listed. + + final List recipientTypeList; + + /// The name of the file or directory permission is being set to. This is a + /// non required parameter. If not set there will be a text field to define + /// the file name. If [isExternalRes] is set to true this must be set and the + /// value should be the url of the resource. + + final String? resourceName; + + /// A flag to determine whether the given resource is a file or not. This is + /// a parameter with default value true. In the case where [resourceName] is + /// not set there will be a toggle to define this parameter. + /// If [isExternalRes] is set to true this must be set and the value should + /// be the url of the resource. Also if [resourceName] is set this flag must + /// also be set + + final bool isFile; + + /// Map of data files on a user's POD used to extract the + /// user's recipient list by the WebIdTextInputScreen. + /// If not provided, the WebIdTextInputScreen will read the + /// user's files in their app data folder on their Pod to + /// fetch the ACLs needed to derive the user's recipient list. + + final Map dataFilesMap; + + /// App specific app bar + + final PreferredSizeWidget? customAppBar; + + /// Callback function called when permissions are granted successfully. + + final VoidCallback? onPermissionGranted; + + /// Callback function called when navigating back from the screen. + + final VoidCallback? onNavigateBack; + + @override + GrantPermissionUiState createState() => GrantPermissionUiState(); +} + +/// Class to build a UI for granting permission to a given file + +class GrantPermissionUiState extends State + with SingleTickerProviderStateMixin { + /// Flag to check whether permission table is initialised. + + bool permTableInitialied = false; + + /// Flag to check whether permission history is initialised. + + bool permHistoryInitialied = false; + + /// Define access mode list + + List accessModeList = []; + + /// Define recipient type list + + List recipientTypeList = []; + + /// Filename text controller + + final fileNameController = TextEditingController(); + + /// Permission data map of a file + + Map permDataMap = {}; + + /// Owner WebId + + String _ownerWebId = ''; + + /// Granter WebId + + String _granterWebId = ''; + + /// File name of the current permission data map + + String permDataFile = ''; + + /// Flag to track if permissions were granted successfully. + + bool permissionsGrantedSuccessfully = false; + + /// Pod data list retreived as a Future + + late Future getACLPerm; + + /// Permission history list retreived as a Future + + late Future> getPermHistoryList; + + /// Permission history list + + List permHistoryList = []; + + /// Unfiltered permission history list + + List unFilteredPermHistoryList = []; + + /// Flag to check whether permission history is initialised. + + bool showCurrentPermOnly = false; + + /// A flag to identify if the resource is a file or not + bool isFile = true; + + /// Gets permission details data from ACL on POD server if necessary. + + Future loadACLData( + String resName, { + bool isFile = true, + bool isExternalRes = false, + }) async { + final SolidFunctionCallStatus response = await chkExistsAndHasAcl( + fileName: resName, + isFile: isFile, + isExternalRes: isExternalRes, + ); + + switch (response) { + case SolidFunctionCallStatus.aclFound: + + // Permission map from ACL of resource + final Map result = await readPermission( + fileName: resName, + isFile: isFile, + isExternalRes: isExternalRes, + ); + + // Permission Details object to store permission map from ACL, and owner + // and granter of a resource. + + final permissionDetails = PermissionDetails( + permissionMap: result, + ownerWebId: await getAuthoriser( + isExternalRes: isExternalRes, + webId: widget.ownerWebId, + ), + granterWebId: await getAuthoriser( + isExternalRes: isExternalRes, + isGranter: true, + ), + ); + + return permissionDetails; + + case SolidFunctionCallStatus.notLoggedIn: + await _alert('Please login first to retrieve permission'); + + case SolidFunctionCallStatus.noAclFound: + await _alert(noAclMsg); + + default: + await _alert('Unknown error'); + } + + return null; + } + + @override + void initState() { + super.initState(); + // Load permission map from ACL, owner and granter web ids + if (widget.resourceName != null) { + getACLPerm = loadACLData( + widget.resourceName as String, + isFile: widget.isFile, + isExternalRes: widget.isExternalRes, + ); + getPermHistoryList = + sharedResourcesHistory(resourceName: widget.resourceName as String); + // permHistoryList = []; + } + } + + /// Update the permission data map + + Future _updatePermissions( + String fileName, { + bool isFile = true, + bool isExternalRes = false, + }) async { + final pdata = await loadACLData( + fileName, + isFile: isFile, + isExternalRes: isExternalRes, + ); + final updatedPermHistoryList = + await sharedResourcesHistory(resourceName: fileName); + + assert(pdata != null); + + if (pdata!.permissionMap.isEmpty) { + await _alert('We could not find a resource by the name $fileName'); + } else { + setState(() { + permDataMap = pdata.permissionMap; + permDataFile = fileName; + _ownerWebId = pdata.ownerWebId; + _granterWebId = pdata.granterWebId; + }); + } + + if (updatedPermHistoryList.isEmpty) { + await _alert( + 'We could not find permission log entries for resource by the name $fileName', + ); + } else { + setState(() { + permHistoryList = updatedPermHistoryList; + // Set full unfiltered list to current list from updated + // log fetch + unFilteredPermHistoryList = updatedPermHistoryList; + }); + } + } + + // Search log records + void _searchLogs(String enteredKeyword) { + bool found(it) => it.toLowerCase().contains(enteredKeyword.toLowerCase()); + + List results = []; + if (enteredKeyword.isEmpty) { + // Display all log records if no search string + results = unFilteredPermHistoryList; + // permHistoryList; + } else { + // Display log records with recipient name, granter name, + // permission type, permission matches + results = unFilteredPermHistoryList.where((item) { + return [ + item.recipientName, + item.granterName, + item.permissionType, + item.permissionList, + ].map(found).any((result) => result); + }).toList(); + } + + // Refresh the UI + setState(() { + permHistoryList = results; + }); + } + + /// Filter log records for current/all log records + void getLatestLogRecords() { + List currentLogRecords = []; + List currentRecipients = []; + + // Loop through logs and get the latest for each resource + for (final record in permHistoryList) { + // Store most recent grant record + if ((record.permissionType).contains('grant')) { + final recipientWebId = record.recipientWebId; + + currentRecipients = + currentLogRecords.map((item) => item.recipientWebId).toList(); + + if (currentRecipients.contains(recipientWebId)) { + final int prevMatchIndex = currentLogRecords + .indexWhere((item) => item.recipientWebId == recipientWebId); + final String prevDateTime = + currentLogRecords[prevMatchIndex].dateTimeStr; + // Update record if this record more recent than stored record + if ([0, 1].contains( + DateTime.parse(record.dateTimeStr) + .compareTo(DateTime.parse(prevDateTime)), + )) { + currentLogRecords[prevMatchIndex] = record; + } + } else { + // Store record if no prev record for this recipient + currentLogRecords.add(record); + } + } else { + // Skip revoke records + continue; + } + } + + // Refresh the UI + setState(() { + permHistoryList = currentLogRecords; + }); + } + + /// Private function to call alert dialog in grant permission UI context + Future _alert(String msg) async => alert(context, msg); + + /// Build the main widget + Widget _buildPermPage( + BuildContext context, [ + PermissionDetails? initPermDetails, + List? initPermHistoryList, + ]) { + // Check if future is set or not. If set display the permission map + if (initPermDetails != null && permTableInitialied == false) { + permDataMap = initPermDetails.permissionMap; + _ownerWebId = initPermDetails.ownerWebId; + _granterWebId = initPermDetails.granterWebId; + permDataFile = widget.resourceName!; + permTableInitialied = true; + } + + if (initPermHistoryList != null && permHistoryInitialied == false) { + permHistoryList = initPermHistoryList; + // Set full unfiltered list to current list from initial + // log fetch + unFilteredPermHistoryList = initPermHistoryList; + permHistoryInitialied = true; + } + + final retrievePermissionButton = ElevatedButton( + child: const Text('Retrieve permissions'), + onPressed: () async { + final fileName = fileNameController.text; + if (fileName.isEmpty) { + await _alert('Please enter a file name'); + } else { + await _updatePermissions(fileName, isFile: isFile); + } + }, + ); + + bool getIsFile() => widget.resourceName != null ? widget.isFile : isFile; + + // Use customAppBar if provided + final customAppBar = widget.customAppBar ?? + defaultAppBar( + context, + widget.title, + widget.backgroundColor, + widget.child, + onNavigateBack: () => widget.onNavigateBack?.call(), + getResult: () => permissionsGrantedSuccessfully, + ); + + return LayoutBuilder( + builder: (context, constraints) { + return Scaffold( + // Display app bar if showAppBar selected + // AppBar will be defaultAppBar() if customAppBar() + // not provided + appBar: widget.showAppBar ? customAppBar : null, + + body: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + smallGapV, + // Sharing heading + makeHeading( + makeSharingTitleStr( + fileName: widget.resourceName, + isFile: widget.isFile, + ), + bold: false, + addColor: false, + addPadding: false, + ), + smallGapV, + // Choose resource and show _updatePermissions button + if (widget.resourceName == null) ...[ + getResourceForm( + formController: fileNameController, + isFile: isFile, + onResourceTypeChange: (bool v) => + setState(() => isFile = v), + ), + smallGapV, + retrievePermissionButton, + smallGapV, + ], + ShareResourceButton( + resourceName: widget.resourceName, + fileNameController: fileNameController, + accessModeList: widget.accessModeList, + recipientTypeList: widget.recipientTypeList, + updatePermissionsFunction: _updatePermissions, + ownerWebId: _ownerWebId, + granterWebId: _granterWebId, + isExternalRes: widget.isExternalRes, + isFile: widget.isFile, + dataFilesMap: widget.dataFilesMap, + onPermissionGranted: widget.onPermissionGranted, + ), + + mediumGapV, + makeSubHeading( + showCurrentPermOnly + ? 'People with current access' + : 'Permission history', + addPadding: false, + ), + smallGapV, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 5.0, + children: [ + Expanded( + flex: 3, + // Search logs field + child: showCurrentPermOnly + ? const Text('') + : TextField( + onChanged: (value) => _searchLogs(value), + decoration: const InputDecoration( + labelText: + 'Search access level, permission type, recipient or granter name', + labelStyle: TextStyle(fontSize: 12), + hintText: 'Enter search text', + hintStyle: TextStyle(fontSize: 12), + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: + BorderRadius.all(Radius.circular(25.0)), + ), + ), + ), + ), + // Current/History permission switch + SizedBox( + width: 170.0, + child: MarkdownTooltip( + message: + 'Switch between current people with access and permission history log', + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 5.0, + children: [ + SizedBox( + width: 100, + child: Text( + showCurrentPermOnly + ? 'Current Permissions' + : 'All Permissions', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + Switch( + // This bool value toggles the switch. + value: showCurrentPermOnly, + activeThumbColor: ActionColors.success, + onChanged: (bool value) { + // This is called when the user toggles the switch. + setState(() { + showCurrentPermOnly = value; + }); + // If showing all permission + // default to full permission history + // list + if (!showCurrentPermOnly) { + setState(() { + permHistoryList = unFilteredPermHistoryList; + }); + } + }, + ), + ], + ), + ), + ), + ], + ), + + vSmallGapV, + + // Show permissions from ACL if showCurrentPermOnly + // is selected, else show searchable permission + // history. + + showCurrentPermOnly + ? PermissionTable( + resourceName: permDataFile, + permDataMap: permDataMap, + ownerWebId: _ownerWebId, + granterWebId: _granterWebId, + updatePermissionsFunction: _updatePermissions, + parentWidget: widget.child, + isFile: getIsFile(), + isExternalRes: widget.isExternalRes, + constraints: constraints, + ) + : PermissionHistory( + // Force history rebuild on permission history change. + + key: ValueKey(permHistoryList), + resourceName: widget.resourceName!, + permHistory: permHistoryList, + constraints: constraints, + ), + ], + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) => widget.resourceName == null + ? _buildPermPage(context) + : FutureBuilder( + future: Future.wait([ + // Future that returns List of current access from ACL + getACLPerm, + // Future that returns List from permission log + getPermHistoryList, + ]), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold(body: loadingScreen(normalLoadingScreenHeight)); + } + final PermissionDetails initCurrentPerm = + snapshot.data![0] as PermissionDetails; + final List initPermHistoryList = + snapshot.data![1] as List; + return initCurrentPerm.permissionMap.isEmpty + ? widget.child + : _buildPermPage(context, initCurrentPerm, initPermHistoryList); + }, + ); +} diff --git a/lib/src/widgets/group_webid_input.dart b/lib/src/widgets/group_webid_input.dart new file mode 100644 index 0000000..242d891 --- /dev/null +++ b/lib/src/widgets/group_webid_input.dart @@ -0,0 +1,168 @@ +/// A dialog to input individual WebID. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage + +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:solidui/solidui.dart' + show smallGapV, makeSubHeading, GrantPermFormLayout; +import 'package:solidui/src/utils/solid_alert.dart'; + +/// A [StatefulWidget] dialog for entering group of webIds. +/// +/// Parameters: +/// - [onSubmitFunction] - function to be called on submit. + +class GroupWebIdTextInput extends StatefulWidget { + /// Function run on Submit button press. + final Function onSubmitFunction; + + const GroupWebIdTextInput({super.key, required this.onSubmitFunction}); + + @override + State createState() => _GroupWebIdTextInputState(); +} + +class _GroupWebIdTextInputState extends State { + /// Text controller for webId list field + final formControllerGroupWebIds = TextEditingController(); + + /// Text controller for group name for webId list + + final formControllerGroupName = TextEditingController(); + + @override + void initState() { + super.initState(); + } + + // dispose text controller when the widget is unmounted + @override + void dispose() { + formControllerGroupWebIds.dispose(); + formControllerGroupName.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + smallGapV, + // Explain webId with example + MarkdownTooltip( + message: '$whatIsWebID Eg: $demoWebID', + child: makeSubHeading('Enter recipient group WebIds'), + ), + // Add padding to webid textformfield and suggestion drop down + Container( + padding: GrantPermFormLayout.inputPadding, + child: Column( + children: [ + // Group name. Should be a single string + TextFormField( + controller: formControllerGroupName, + decoration: const InputDecoration( + labelText: 'Group name', + hintText: + 'Multiple words will be combined using the symbol -', + ), + ), + smallGapV, + // List of Web IDs divided by semicolon + TextFormField( + controller: formControllerGroupWebIds, + decoration: const InputDecoration( + labelText: 'List of WebIDs', + hintText: 'Divide multiple WebIDs using the semicolon (;)', + ), + ), + 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'), + ), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/widgets/ind_webid_input.dart b/lib/src/widgets/ind_webid_input.dart new file mode 100644 index 0000000..d341622 --- /dev/null +++ b/lib/src/widgets/ind_webid_input.dart @@ -0,0 +1,255 @@ +/// A dialog to input Group of WebIDs. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage, Jess Moore + +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:solidui/solidui.dart' + show + smallGapV, + makeSubHeading, + 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. +/// +class IndWebIdTextInput extends StatefulWidget { + /// Initialise widget variables. + + const IndWebIdTextInput({ + required this.onSubmitFunction, + this.uniqRecipWebIdList, + super.key, + }); + + /// Function run on Submit button press. + final Function onSubmitFunction; + + /// List of unique recipient webIds + final List? uniqRecipWebIdList; + + @override + State createState() => _IndWebIdTextInputState(); +} + +class _IndWebIdTextInputState extends State { + /// Text controller for WebId field + final formControllerWebId = TextEditingController(); + + /// Capture whether user has started to enter text + bool _textEntered = false; + + /// WebId list + List webIdList = []; + + /// Initialise the matching suggestions list + List suggestionList = []; + String hint = ''; + + // Dispose text controller when the widget is unmounted + @override + void dispose() { + formControllerWebId.dispose(); + super.dispose(); + } + + @override + void initState() { + webIdList = widget.uniqRecipWebIdList ?? []; + 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 suggestions for users based on input matches to + /// current complete recipient list of user + void filterSuggestions(String value) { + suggestionList.clear(); + + if (value.isEmpty) { + setState(() {}); + return; + } + suggestionList = webIdList + .where((e) => e.toLowerCase().contains(value.toLowerCase())) + .toList(); + + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + smallGapV, + // Explain webId with example + MarkdownTooltip( + message: '$whatIsWebID Eg: $demoWebID', + child: makeSubHeading('Enter or select recipient\'s WebId'), + ), + // Add padding to webid textformfield and suggestion drop down + Container( + padding: GrantPermFormLayout.inputPadding, + child: Column( + children: [ + // Web ID text field + TextFormField( + controller: formControllerWebId, + decoration: InputDecoration( + labelText: 'Individual\'s webID', + // Once user has started entering text, use formfield + // error message to advise user how to specify + // valid webId + errorText: _textEntered ? _helpText : null, + ), + onFieldSubmitted: (value) {}, + onChanged: (value) => setState(() { + // User has started entering text + _textEntered = true; + // Filter suggestions + filterSuggestions(value); + }), + ), + smallGapV, + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (webIdList.isNotEmpty) ...[ + if (suggestionList.isNotEmpty || + formControllerWebId.text.isNotEmpty) ...[ + // 20250729 jm: Wrap ListView() in fixed SizeBox() to avoid render problems in AlertDialog() + boxedSuggestionList(context, suggestionList), + ] else ...[ + 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'), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Flexible boxedSuggestionList(BuildContext context, List idList) { + return Flexible( + child: SizedBox( + height: WebIdLayout.dropdownHeight, + child: ListView.builder( + padding: WebIdLayout.listPadding, + itemCount: idList.length, + itemBuilder: (context, index) { + return Card( + elevation: WebIdLayout.dropdownElevation, + child: ListTile( + title: Text(idList[index]), + focusColor: DropdownColors.primary, + hoverColor: DropdownColors.accent, + splashColor: DropdownColors.primary, + onTap: () => setState(() { + // User has started entering text + _textEntered = true; + formControllerWebId.text = idList[index]; + }), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/src/widgets/ind_webid_input_screen.dart b/lib/src/widgets/ind_webid_input_screen.dart new file mode 100644 index 0000000..d623496 --- /dev/null +++ b/lib/src/widgets/ind_webid_input_screen.dart @@ -0,0 +1,127 @@ +/// A screen to retrieve all the webids of recipients of a user's Pod files before loading the webid input dialog. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart' + show getRecipientList, extractRecipWebIdList; + +import 'package:solidui/solidui.dart' show normalLoadingScreenHeight; +import 'package:solidui/src/widgets/ind_webid_input.dart'; +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 {}, + 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 + /// the user's recipient list used in the WebIdTextInputScreen. + final Map dataFilesMap; + + @override + State createState() => _IndWebIdInputScreenState(); +} + +class _IndWebIdInputScreenState extends State { + /// Future comprising the unique recipient WebId list of the user's Pod data. + static Future>? _asyncGetRecipList; + + /// List of unique recipient WebId list of the user's Pod data. + List uniqRecipWebIdList = []; + + @override + void initState() { + // Retrieve files and derive unique recipient WebId list. + if (widget.dataFilesMap.isEmpty) { + _asyncGetRecipList = getRecipientList(); + } else { + // Extract unique recipient WebId list if file data provided. + uniqRecipWebIdList = extractRecipWebIdList(widget.dataFilesMap); + } + super.initState(); + } + + // Load Individual WebId Text Input + Widget _loadIndWebIdTextInput( + Function onSubmitFunction, [ + List uniqRecipWebIdList = const [], + ]) { + return IndWebIdTextInput( + onSubmitFunction: onSubmitFunction, + uniqRecipWebIdList: uniqRecipWebIdList, + ); + } + + @override + Widget build(BuildContext context) { + return (widget.dataFilesMap.isNotEmpty) + ? _loadIndWebIdTextInput(widget.onSubmitFunction, uniqRecipWebIdList) + : FutureBuilder( + future: _asyncGetRecipList, + builder: (context, snapshot) { + Widget returnVal; + if (snapshot.connectionState == ConnectionState.done) { + return snapshot.data == null || + snapshot.data.toString() == 'null' || + snapshot.data == [] + // Load Individual WebId Input Dialog Screen without recipient list + ? returnVal = _loadIndWebIdTextInput( + widget.onSubmitFunction, + ) + // Load Individual WebId Input Dialog Screen with recipient list + : returnVal = _loadIndWebIdTextInput( + widget.onSubmitFunction, + snapshot.data!, + ); + } else { + returnVal = loadingScreen(normalLoadingScreenHeight); + } + return returnVal; + }, + ); + } +} diff --git a/lib/src/widgets/permission_checkbox.dart b/lib/src/widgets/permission_checkbox.dart new file mode 100644 index 0000000..eb53c1b --- /dev/null +++ b/lib/src/widgets/permission_checkbox.dart @@ -0,0 +1,61 @@ +/// A checkbox widget for access modes. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage + +library; + +import 'package:flutter/material.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show AccessMode; + +/// Checkbox widget to display different access mode selections. Function call +/// requires the following inputs +/// [accessMode] is the AccessMode instance for the checkbox +/// [checkboxChecked] is the boolean controller for the checkbox press +/// [updateCheckBox] is the function to update the checkbox data when pressed +/// + +MarkdownTooltip permissionCheckbox( + AccessMode accessMode, + bool checkboxChecked, + Function updateCheckBox, +) { + return MarkdownTooltip( + message: accessMode.description, + child: CheckboxListTile( + title: Text(accessMode.mode), + value: checkboxChecked, + onChanged: (newValue) { + updateCheckBox(newValue, accessMode); + }, + controlAffinity: ListTileControlAffinity.leading, // <-- leading Checkbox + ), + ); +} diff --git a/lib/src/widgets/permission_history.dart b/lib/src/widgets/permission_history.dart new file mode 100644 index 0000000..dadebac --- /dev/null +++ b/lib/src/widgets/permission_history.dart @@ -0,0 +1,183 @@ +/// Table listing permission history of a resource. +/// +// Time-stamp: +/// +/// Copyright (C) 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 + +library; + +import 'package:flutter/material.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show LogRecord; + +import 'package:solidui/src/constants/ui_common.dart' + show ListIconSize, listIconShape; +import 'package:solidui/src/constants/ui_window.dart' + show WindowSize, ListItemSize; + +/// A [StatefulWidget] for listing the permission history of a resource. +/// +/// Parameters: +/// - [resourceName] - The filename or file url of the resource. +/// - [permHistory] is the [List] comprising permission history for +/// the [resourceName]. + +class PermissionHistory extends StatefulWidget { + /// The name of the file or directory for which permissions are being + /// shown. + + final String resourceName; + + /// Map of access permission data being displayed for [resourceName]. + + final List permHistory; + + /// Layout constraints + + final BoxConstraints constraints; + + const PermissionHistory({ + super.key, + required this.resourceName, + required this.permHistory, + required this.constraints, + }); + + @override + State createState() => _PermissionHistoryState(); +} + +class _PermissionHistoryState extends State { + /// Searched/sorted logs + List _permHistory = []; + + /// Aspect ratio (width / height) for gridview + /// cards to display log items + late double cardAspectRatio = 2.0; + + /// Boolean describing whether window is narrow + late bool isNarrow; + + /// Scroll controller for single child scroll view + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + + // By default _permissions is the full list of permissions + _permHistory = widget.permHistory; + + // Create scroll controller + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); // Dispose the ScrollController + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Derive whether window is narrow + isNarrow = WindowSize().isNarrowWindow(widget.constraints); + // Calculate the aspect radio for grid cards + cardAspectRatio = + ListItemSize().calculateCardAspectRatio(widget.constraints); + + return Expanded( + child: GridView.builder( + controller: _scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // Aspect ratio calculated from LayoutBuilder box constraints + crossAxisCount: 1, + childAspectRatio: cardAspectRatio, + ), + padding: const EdgeInsets.all(10), + itemCount: _permHistory.length, //widget.permDataMap.length, + itemBuilder: (context, index) => Card( + child: Center( + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + child: MarkdownTooltip( + message: _permHistory[index].toolTip, + child: ListTile( + // Leading icon denoting agreement of access terms + leading: SizedBox( + width: ListIconSize.width, + child: Center( + child: Ink( + padding: const EdgeInsets.all(8), + decoration: listIconShape, + child: Icon( + _permHistory[index].permissionType == 'grant' + ? Icons.person_add + : Icons.person_remove, + ), + // 20260201 jesscmoore Alternatives tried: + // insert_drive_file, list, lock and lock_open, + // mode_edit,my_library_books, note, notes, person_add, + // person_remove, playlist_add, playlist_remove, public, + // public_off, post_add, receipt, receipt_long, + // recent_actors, + ), + ), + ), + // Permission item title + + title: Text( + _permHistory[index].permissionType == 'grant' + ? '${_permHistory[index].dateTime}: ' + '${_permHistory[index].recipientName} ' + '${_permHistory[index].permissionTypeLabel} ' + '${_permHistory[index].permissionList} ' + 'access' + : '${_permHistory[index].dateTime}: ' + '${_permHistory[index].permissionList} ' + 'access ${_permHistory[index].permissionTypeLabel} to ' + '${_permHistory[index].recipientName}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + 'Granter: ${_permHistory[index].granterName}', + maxLines: 3, // Limit lines + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/permission_table.dart b/lib/src/widgets/permission_table.dart new file mode 100644 index 0000000..b41a493 --- /dev/null +++ b/lib/src/widgets/permission_table.dart @@ -0,0 +1,222 @@ +/// Table listing permissions of a resource. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +library; + +import 'package:flutter/material.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show Permission, permMapToList; + +import 'package:solidui/solidui.dart' + show WindowSize, ListItemSize, ListIconSize, listIconShape; +import 'package:solidui/src/widgets/revoke_permission_button.dart'; + +/// A [StatefulWidget] for listing the permissions of a resource. +/// +/// Parameters: +/// - [resourceName] - The filename or file url of the resource. If [isExternalRes], it should be the url of the resource. +/// - [permDataMap] is the map of permission data for the [resourceName] +/// - [ownerWebId] - WebId of the owner of the resource. Required if the resource is externally owned. +/// - [granterWebId] - WebId of the granter of the resource. Required if the resource is externally owned. +/// - [isFile] - Boolean flag describing whether the resource is a file. If false, the resource is assumed to be a directory. +/// - [isExternalRes] - Boolean flag describing whether the resource +/// is externally owned. +/// - [updatePermissionsFunction] is the function to be called to refresh the permission table. +/// - [parentWidget] is the widget to return to after an action Eg: deletion of a +/// permission +/// + +class PermissionTable extends StatefulWidget { + /// The name of the file or directory for which permissions are being + /// shown. + + final String resourceName; + + /// Map of access permission data being displayed for [resourceName]. + + final Map permDataMap; + + /// WebId of the resource owner. + + final String ownerWebId; + + /// WebId of the user granting/revoking access to the resource. + + final String granterWebId; + + /// A flag denoting whether resource is externally owned. + + final bool isExternalRes; + + /// A flag to determine whether the given resource is a file or not. + + final bool isFile; + + /// Function run to update permissions table + + final Function updatePermissionsFunction; + + /// Parent widget to return to. + + final Widget parentWidget; + + /// Layout constraints + + final BoxConstraints constraints; + + const PermissionTable({ + super.key, + required this.resourceName, + required this.permDataMap, + required this.ownerWebId, + required this.granterWebId, + required this.updatePermissionsFunction, + required this.parentWidget, + required this.isFile, + this.isExternalRes = false, + required this.constraints, + }); + + @override + State createState() => _PermissionTableState(); +} + +class _PermissionTableState extends State { + /// Searched/sorted notes + List _permissions = []; + + /// Aspect ratio (width / height) for gridview + /// cards to display note items + late double cardAspectRatio = 2.0; + + /// Boolean describing whether window is narrow + late bool isNarrow; + + /// Scroll controller for single child scroll view + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + + // Create scroll controller + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); // Dispose the ScrollController + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Derive whether window is narrow + isNarrow = WindowSize().isNarrowWindow(widget.constraints); + // Calculate the aspect radio for grid cards + cardAspectRatio = ListItemSize().calculateCardAspectRatio( + widget.constraints, + ); + + // By default _permissions is the full list of permissions + _permissions = permMapToList(widget.permDataMap); + + return Expanded( + child: GridView.builder( + controller: _scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // Aspect ratio calculated from LayoutBuilder box constraints + crossAxisCount: 1, + childAspectRatio: cardAspectRatio, + ), + padding: const EdgeInsets.all(10), + itemCount: _permissions.length, //widget.permDataMap.length, + itemBuilder: (context, index) => Card( + child: Center( + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + child: MarkdownTooltip( + message: _permissions[index].toolTip, + child: ListTile( + // Leading icon denoting agreement of access terms + leading: SizedBox( + width: ListIconSize.width, + child: Center( + child: Ink( + padding: const EdgeInsets.all(8), + decoration: listIconShape, + child: const Icon(Icons.handshake), + ), + ), + ), + // Permission item title + title: Text( + _permissions[index].recipientName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + 'Recipient type: ${_permissions[index].recipientType} \n' + 'WebId: ${_permissions[index].recipientWebId} \n' + 'Permissions: ${_permissions[index].permList.join(', ')}', + maxLines: 4, // Limit to 4 lines + overflow: TextOverflow.ellipsis, + ), + // Show revoke button for recipientWebId != ownerWebId + trailing: (widget.ownerWebId != + _permissions[index].recipientWebId) + ? SizedBox( + height: ListIconSize.height, + width: ListIconSize.twoIconWidth, + child: RevokePermissionButton( + resourceName: widget.resourceName, + permDataMap: widget.permDataMap, + receiverWebId: _permissions[index].recipientWebId, + ownerWebId: widget.ownerWebId, + granterWebId: widget.granterWebId, + isFile: widget.isFile, + isExternalRes: widget.isExternalRes, + updatePermissionsFunction: + widget.updatePermissionsFunction, + ), + ) + : null, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/revoke_permission_button.dart b/lib/src/widgets/revoke_permission_button.dart new file mode 100644 index 0000000..68e76de --- /dev/null +++ b/lib/src/widgets/revoke_permission_button.dart @@ -0,0 +1,184 @@ +/// A button for revoking permission. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +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; +import 'package:solidui/src/utils/snack_bar.dart'; + +/// A [StatefulWidget] for the revoke permission icon button. Updates +/// owner's ACL for resource, updates owner, granter, recipient logs, +/// and calls updatePermissions() to refresh permission table data. +/// +/// Parameters: +/// - [resourceName] - The filename or file url of the resource. If [isExternalRes], it should be the url of the resource. +/// - [permDataMap] is the map of permission data for the [resourceName] +/// - [ownerWebId] - WebId of the owner of the resource. Required if the resource is externally owned. +/// - [granterWebId] - WebId of the granter of the resource. Required if the resource is externally owned. +/// - [receiverWebId] - WebId with access to the resource, one of ownerWebId, granterWebId or recipientWebId. +/// - [isFile] - Boolean flag describing whether the resource is a file. If false, the resource is assumed to be a directory. +/// - [isExternalRes] - Boolean flag describing whether the resource +/// is externally owned. +/// - [updatePermissionsFunction] is the function to be called to refresh the permission table. +/// + +class RevokePermissionButton extends StatefulWidget { + /// The name of the file or directory for which permissions are being + /// shown. + + final String resourceName; + + /// Map of access permission data being displayed for [resourceName]. + + final Map permDataMap; + + /// WebId with access to resource. + + final String receiverWebId; + + /// WebId of the resource owner. + + final String ownerWebId; + + /// WebId of the user granting/revoking access to the resource. + + final String granterWebId; + + /// A flag denoting whether resource is externally owned. + + final bool isExternalRes; + + /// A flag to determine whether the given resource is a file or not. + + final bool isFile; + + /// Function run to update permissions table + + final Function updatePermissionsFunction; + + const RevokePermissionButton({ + super.key, + required this.resourceName, + required this.permDataMap, + required this.receiverWebId, + required this.ownerWebId, + required this.granterWebId, + required this.updatePermissionsFunction, + required this.isFile, + this.isExternalRes = false, + }); + + @override + State createState() => _RevokePermissionButtonState(); +} + +class _RevokePermissionButtonState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return MarkdownTooltip( + message: 'Revoke all access to this recipient', + child: IconButton( + icon: const Icon( + Icons.delete, + size: 24.0, + color: ActionColors.delete, + ), + onPressed: () { + showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: const Text('Please Confirm'), + content: Text( + 'Are you sure you want to remove the [${(widget.permDataMap[widget.receiverWebId][permStr] as List).join(', ')}] permission/s from ${widget.receiverWebId.replaceAll('.ttl', '')}?', + ), + actions: [ + // The "Yes" button + TextButton( + onPressed: () async { + await revokePermission( + fileName: widget.resourceName, + isFile: widget.isFile, + permissionList: widget.permDataMap[widget.receiverWebId] + [permStr] as List, + recipientIndOrGroupWebId: widget.receiverWebId, + ownerWebId: widget.ownerWebId, + granterWebId: widget.granterWebId, + recipientType: getRecipientType( + widget.permDataMap[widget.receiverWebId][agentStr] + as String, + widget.receiverWebId, + ), + isExternalRes: widget.isExternalRes, + ); + + if (ctx.mounted) { + Navigator.pop(ctx); + } + if (ctx.mounted) { + showSnackBar( + context, + 'Permission revoked successfully!', + ActionColors.success, + ); + } + await widget.updatePermissionsFunction( + widget.resourceName, + isFile: widget.isFile, + ); + }, + child: const Text('Yes'), + ), + TextButton( + onPressed: () { + // Close the dialog + Navigator.of(ctx).pop(); + }, + child: const Text('No'), + ), + ], + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/src/widgets/select_recipients.dart b/lib/src/widgets/select_recipients.dart new file mode 100644 index 0000000..05a8ba2 --- /dev/null +++ b/lib/src/widgets/select_recipients.dart @@ -0,0 +1,182 @@ +/// Button list for selecting recipients. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +library; + +import 'package:flutter/material.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show RecipientType; + +import 'package:solidui/solidui.dart' + show granterRecipientTypes, ownerRecipientTypes, recipientToolTips; + +/// A [StatefulWidget] for a container of buttons for +/// selecting recipients in the grant permission form. +/// +/// Parameters: +/// - [isExternalRes] - Boolean flag describing whether +/// the resource is externally owned. +/// - [recipientTypeList] - List of recipient type options to show. +/// - [setPublicFunction] - a function for setting recipients to +/// the public. +/// - [setAuthUsersFunction] - a function for setting recipients +/// to all authorised users. +/// - [setIndividualFunction] - a function for selecting an +/// individual recipient webId. +/// - [setGroupFunction] - a function for selecting a +/// group of webIds as recipients. + +class SelectRecipients extends StatefulWidget { + /// A flag denoting whether the resource is externally owned. + + final bool isExternalRes; + + /// The list of types of recipients to show in form. By default + /// all four types of recipient are listed. + + final List recipientTypeList; + + /// A function for setting recipients to the public. + + final Function setPublicFunction; + + /// A function for setting recipients to all authorised users. + + final Function setAuthUsersFunction; + + /// A function for selecting an individual recipient webId. + + final Function setIndividualFunction; + + /// A function for selecting a group of webIds as recipients. + + final Function setGroupFunction; + + const SelectRecipients({ + super.key, + required this.isExternalRes, + required this.recipientTypeList, + required this.setPublicFunction, + required this.setAuthUsersFunction, + required this.setIndividualFunction, + required this.setGroupFunction, + }); + + @override + State createState() => _SelectRecipientsState(); +} + +class _SelectRecipientsState extends State { + /// Define recipient type list + + List recipientTypeList = []; + + /// Selected recipient type + + RecipientType? _selectedRecipientType; + + // Allowed recipient types + + List allowedRecipientTypes = []; + + @override + void initState() { + super.initState(); + + // Load recipient type list to be displayed + for (final recTypeStr in widget.recipientTypeList) { + recipientTypeList.add(RecipientType.getInstanceByValue(recTypeStr)); + } + + // jesscmoore 20260118: requires check grant/revoke to + // public/auth works on external resources + // av 20250526: + // Public and Authenticated recipient buttons are + // disabled currently because + // providing public or authenticated permissions to + // external resources is not yet implemented in + // [grantPermission()] function. + widget.isExternalRes + ? allowedRecipientTypes = granterRecipientTypes + : allowedRecipientTypes = ownerRecipientTypes; + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + RadioGroup( + groupValue: _selectedRecipientType, + onChanged: (RecipientType? value) { + setState(() { + _selectedRecipientType = value; + }); + switch (value) { + case RecipientType.public: + widget.setPublicFunction(); + case RecipientType.authUser: + widget.setAuthUsersFunction(); + case RecipientType.individual: + widget.setIndividualFunction(); + case RecipientType.group: + widget.setGroupFunction(); + case RecipientType.none: + return; + case null: + return; + } + }, + child: Column( + children: [ + for (final rtype in ownerRecipientTypes) + if (recipientTypeList.contains(rtype)) + MarkdownTooltip( + message: recipientToolTips[rtype]!, + child: ListTile( + title: Text(rtype.description), + leading: Radio(value: rtype), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/share_resource_button.dart b/lib/src/widgets/share_resource_button.dart new file mode 100644 index 0000000..cff4870 --- /dev/null +++ b/lib/src/widgets/share_resource_button.dart @@ -0,0 +1,217 @@ +/// A button for sharing a resource. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/solidui.dart' show SharingPageLayout; +import 'package:solidui/src/utils/solid_alert.dart'; +import 'package:solidui/src/widgets/grant_permission_form.dart'; + +/// A [StatefulWidget] for sharing a resource, by either creating +/// an access permission for a new recipient or updating the access +/// permission of an existing recipient. +/// +/// Parameters: +/// - [resourceName] - The filename or file url of the resource. If [isExternalRes], it should be the url of the resource. +/// - [fileNameController] - The [TextEditingController] for the filename +/// field. +/// - [isExternalRes] - Boolean flag describing whether the resource +/// is externally owned. +/// - [ownerWebId] - WebId of the owner of the resource. Required if the resource is externally owned. +/// - [granterWebId] - WebId of the granter of the resource. Required if the resource is externally owned. +/// - [accessModeList] - List of access mode options to show. +/// - [recipientTypeList] - List of recipient type options to show. +/// - [isFile] - Boolean flag describing whether the resource is a file. If false, the resource is assumed to be a directory. +/// - [updatePermissionsFunction] is the function to be called to refresh the permission table. +/// +/// - [onPermissionGranted] - Callback function called when permissions are granted successfully. + +class ShareResourceButton extends StatefulWidget { + final TextEditingController fileNameController; + + /// String to assign the webId of the resource owner. + + final String ownerWebId; + + /// String to assign the external webId of the resource granter. + + final String granterWebId; + + /// The name of the file or directory that access is being granted for. + + final String? resourceName; + + final bool isExternalRes; + + /// A flag to determine whether the given resource is a file or not. + + final bool isFile; + + /// The list of access modes to be displayed. By default all four types of + /// access mode are listed. + + final List accessModeList; + + /// The list of types of recipients receiving permission to access the resource. By default all four + /// types of recipient are listed. + + final List recipientTypeList; + + /// Map of data files on a user's POD used to extract the + /// user's recipient list by the WebIdTextInputScreen. + /// If not provided, the WebIdTextInputScreen will read the + /// user's files in their app data folder on their Pod to + /// fetch the ACLs needed to derive the user's recipient list. + + final Map dataFilesMap; + + /// Function run to update permissions table + + final Function updatePermissionsFunction; + + /// Callback function called when permissions are granted successfully. + + final VoidCallback? onPermissionGranted; + + const ShareResourceButton({ + super.key, + required this.fileNameController, + required this.updatePermissionsFunction, + this.resourceName, + required this.ownerWebId, + required this.granterWebId, + this.accessModeList = const ['read', 'write', 'append', 'control'], + this.recipientTypeList = const ['public', 'indi', 'auth', 'group'], + required this.isExternalRes, + required this.isFile, + this.dataFilesMap = const {}, + this.onPermissionGranted, + }); + + @override + State createState() => _ShareResourceButtonState(); +} + +class _ShareResourceButtonState extends State { + /// Filename text controller + + late final TextEditingController _fileNameController; + + /// Owner WebId + + late final String _ownerWebId; + + /// Granter WebId + + late final String _granterWebId; + + /// Selected resource - assigned on Share Resource button press + + String _resourceName = ''; + + /// A flag to identify if the resource is a file or not + + bool isFile = true; + + /// Flag to track if permissions were granted successfully. + + bool permissionsGrantedSuccessfully = false; + + @override + void initState() { + super.initState(); + + _fileNameController = widget.fileNameController; + _ownerWebId = widget.ownerWebId; + _granterWebId = widget.granterWebId; + } + + @override + void dispose() { + _fileNameController.dispose(); // Dispose filename editing controller + super.dispose(); + } + + /// Mark permissions as granted successfully for callback tracking + Future _updatePermissionGrantedStatus() async { + setState(() => permissionsGrantedSuccessfully = true); + } + + /// Private function to call alert dialog in share resource button + /// context. This provides an alert dialog over the top of the + /// grant permission form dialog. + Future _alert(String msg) async => alert(context, msg); + + // Resource is a file if resource selected in GrantPermissionUi() + bool _getIsFile() => widget.resourceName != null ? widget.isFile : isFile; + + @override + Widget build(BuildContext context) { + return Padding( + padding: SharingPageLayout.inputPadding, + child: ElevatedButton.icon( + icon: const Icon(Icons.share), + onPressed: () async { + // Assign dataFile if null (first Grant press) + _resourceName = widget.resourceName ?? _fileNameController.text; + + if (_resourceName != '') { + // Display GrantPermissionForm dialog to enter + // recipient and access modes + await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return GrantPermissionForm( + resourceName: _resourceName, + accessModeList: widget.accessModeList, + recipientTypeList: widget.recipientTypeList, + updatePermissionsFunction: widget.updatePermissionsFunction, + ownerWebId: _ownerWebId, + granterWebId: _granterWebId, + isExternalRes: widget.isExternalRes, + isFile: _getIsFile(), + dataFilesMap: widget.dataFilesMap, + updatePermissionGrantedFunction: + _updatePermissionGrantedStatus, + onPermissionGranted: widget.onPermissionGranted, + ); + }, + ); + } else { + await _alert('Please select one or more recipients'); + } + }, + label: const Text('Share Resource'), + ), + ); + } +} diff --git a/lib/src/widgets/shared_resources_table.dart b/lib/src/widgets/shared_resources_table.dart new file mode 100644 index 0000000..1c6d3d7 --- /dev/null +++ b/lib/src/widgets/shared_resources_table.dart @@ -0,0 +1,231 @@ +/// A table displaying permission data for a given file. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart'; + +import 'package:solidui/src/utils/solid_alert.dart'; +import 'package:solidui/src/widgets/file_explorer.dart'; + +/// Build the permission table widget. Function call requires the +/// following inputs +/// [context] is the BuildContext from which this function is called. +/// [sharedResMap] is the map containing data of shared resources. +/// [parentWidget] is the widget to return to after an action Eg: deletion of a +/// permission + +Widget buildSharedResourcesTable( + BuildContext context, + Map sharedResMap, + Widget parentWidget, +) { + final cWidth = MediaQuery.of(context).size.width * 0.18; + DataColumn buildDataColumn(String title, String tooltip) { + return DataColumn( + label: Expanded(child: Center(child: Text(title))), + tooltip: tooltip, + ); + } + + DataCell buildDataCell(String content) { + return DataCell( + SizedBox( + width: cWidth, + child: Column(children: [SelectableText(content)]), + ), + ); + } + + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + dataRowMaxHeight: double.infinity, + horizontalMargin: 10, + columnSpacing: 10, + columns: [ + buildDataColumn( + 'Resource URL', + 'WebID of the POD receiving permissions', + ), + buildDataColumn('Shared on', 'Shared date and time'), + buildDataColumn('Owner', 'Resource owner WebID'), + buildDataColumn('Granter', 'Permission granter WebID'), + buildDataColumn('Permissions', 'List of permissions given'), + buildDataColumn('View/Open', 'View file'), + ], + rows: sharedResMap.keys.map((index) { + return DataRow( + cells: [ + DataCell( + Container( + padding: const EdgeInsets.fromLTRB(0, 5, 0, 0), + width: cWidth, + child: SelectableText( + index as String, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + DataCell( + Row( + children: [ + Expanded( + child: Text( + getDateTime( + sharedResMap[index][PermissionLogLiteral.logtime] + as String, + ), + ), + ), + ], + ), + ), + buildDataCell( + sharedResMap[index][PermissionLogLiteral.owner] as String, + ), + buildDataCell( + sharedResMap[index][PermissionLogLiteral.granter] as String, + ), + buildDataCell( + sharedResMap[index][PermissionLogLiteral.permissions] + as String, + ), + DataCell( + isDir(index) + ? IconButton( + icon: const Icon( + Icons.folder_open_outlined, + size: 24.0, + color: Colors.blueAccent, + ), + onPressed: () async { + if (!sharedResMap[index] + [PermissionLogLiteral.permissions] + .contains('read')) { + await alert( + context, + 'You do not have read permission to this resource!', + ); + } else { + bool isEditable = [ + AccessMode.write.mode, + AccessMode.control.mode, + ].any( + (mode) => sharedResMap[index] + [PermissionLogLiteral.permissions] + .contains(mode.toLowerCase()), + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => FileExplorerScreen( + folderPath: index, + isEditable: isEditable, + ownerWebId: sharedResMap[index] + [PermissionLogLiteral.owner] + as String, + child: parentWidget, + ), + ), + ); + } + }, + ) + : IconButton( + icon: const Icon( + Icons.visibility, + size: 24.0, + color: Colors.blueAccent, + ), + onPressed: () async { + try { + // Get file content + final fileContent = await readExternalPod( + index, + ); + + if (!context.mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('File content'), + content: Stack( + alignment: Alignment.center, + children: [ + Container( + width: double.infinity, + height: 300, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 15, + ), + ), + child: Text(fileContent), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + // Close the dialog + Navigator.of(ctx).pop(); + }, + child: const Text('Ok'), + ), + ], + ), + ); + } on Object catch (e, trace) { + debugPrint(e.toString()); + debugPrint(trace.toString()); + if (!context.mounted) return; + await alert( + context, + 'The file $index could not be found!', + ); + } + }, + ), + ), + ], + ); + }).toList(), + ), + ), + ), + ], + ); +} diff --git a/lib/src/widgets/shared_resources_ui.dart b/lib/src/widgets/shared_resources_ui.dart new file mode 100644 index 0000000..d935b41 --- /dev/null +++ b/lib/src/widgets/shared_resources_ui.dart @@ -0,0 +1,192 @@ +/// A screen to demonstrate the data sharing capabilities of PODs. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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: Anushka Vidanage + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart'; + +import 'package:solidui/solidui.dart' + show + normalLoadingScreenHeight, + smallGapV, + largeGapV, + makeHeading, + makeSubHeading; +import 'package:solidui/src/widgets/app_bar.dart'; +import 'package:solidui/src/widgets/shared_resources_table.dart'; +import 'package:solidui/src/widgets/solid_loading_screen.dart'; + +/// A widget for the demonstration screen of the application. + +class SharedResourcesUi extends StatefulWidget { + /// Initialise widget variables. + + const SharedResourcesUi({ + required this.child, + this.title = 'Demonstrating retrieve shared data functionality', + this.backgroundColor = const Color.fromARGB(255, 210, 210, 210), + this.showAppBar = true, + this.sourceWebId, + this.fileName, + this.customAppBar, + super.key, + }); + + /// The child widget to return to when back button is pressed. + final Widget child; + + /// The text appearing in the app bar. + final String title; + + /// The text appearing in the app bar. + final Color backgroundColor; + + /// The boolean to decide whether to display an app bar or not + final bool showAppBar; + + /// The webId of the owner of a resource. This is a non required + /// parameter. If not set UI will display all resources + final String? sourceWebId; + + /// The name of the resource being shared. This is a non required + /// parameter. If not set UI will display all resources + final String? fileName; + + /// App specific app bar. If not set default app bar will be displayed. + final PreferredSizeWidget? customAppBar; + + @override + SharedResourcesUiState createState() => SharedResourcesUiState(); +} + +/// Class to build a UI for granting permission to a given file +class SharedResourcesUiState extends State + with SingleTickerProviderStateMixin { + /// Permission data map of a file + Map permDataMap = {}; + + @override + void initState() { + super.initState(); + } + + /// Build the main widget + Widget _buildSharedResourcePage( + BuildContext context, + List? futureObjList, + ) { + // Build the widget. + + var sharedResMap = {}; + if (futureObjList != null) { + sharedResMap = futureObjList.first as Map; + } + + const welcomeHeadingStr = 'Resources shared with you'; + + var subHeadingStr = widget.fileName != null + ? 'Filtered by the ${widget.fileName} file' + : 'No filters'; + + subHeadingStr = widget.sourceWebId != null + ? subHeadingStr.contains('Filtered by') + ? '$subHeadingStr and the WebID ${widget.sourceWebId}' + : 'Filtered by the WebID ${widget.sourceWebId}' + : subHeadingStr; + + return Scaffold( + appBar: (!widget.showAppBar) + ? null + : (widget.customAppBar != null) + ? widget.customAppBar + : defaultAppBar( + context, + widget.title, + widget.backgroundColor, + widget.child, + ), + body: SingleChildScrollView( + child: Column( + children: [ + smallGapV, + Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + makeHeading(welcomeHeadingStr, addPadding: false), + smallGapV, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + largeGapV, + makeSubHeading(subHeadingStr), + buildSharedResourcesTable( + context, + sharedResMap, + widget.child, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Build widget with a Future + final fileName = widget.fileName != null ? widget.fileName as String : null; + final sourceWebId = + widget.sourceWebId != null ? widget.sourceWebId as String : null; + return FutureBuilder( + future: Future.wait([ + sharedResources(fileName, sourceWebId), + AuthDataManager.getWebId(), + ]), + builder: (context, snapshot) { + if (snapshot.hasData) { + if (snapshot.data!.first == SolidFunctionCallStatus.notLoggedIn) { + return widget.child; + } else { + return _buildSharedResourcePage(context, snapshot.data); + } + } else { + return Scaffold(body: loadingScreen(normalLoadingScreenHeight)); + } + }, + ); + } +} diff --git a/lib/src/widgets/show_selected_recipients.dart b/lib/src/widgets/show_selected_recipients.dart new file mode 100644 index 0000000..7881251 --- /dev/null +++ b/lib/src/widgets/show_selected_recipients.dart @@ -0,0 +1,108 @@ +/// A widget for showing selected recipients in the grant permission form. +/// +// Time-stamp: +/// +/// Copyright (C) 2024-2025, 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 + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart' show RecipientType; + +import 'package:solidui/solidui.dart' show smallGapV, RecipientTextStyle; + +/// A [StatelessWidget] for showing selected recipients in the +/// grant permission form. +/// +/// Parameters: +/// - [selectedRecipientType] - Selected type of recipient/s. +/// - [selectedRecipientDetails] - Details of selected recipient/s. +/// - [selectedGroupName] - Name of group, if selected group of recipients. + +class ShowSelectedRecipients extends StatelessWidget { + const ShowSelectedRecipients({ + super.key, + required this.selectedRecipientType, + required this.selectedRecipientDetails, + this.selectedGroupName, + }); + + /// Selected recipient + + final RecipientType selectedRecipientType; + + /// Selected recipient details + + final String selectedRecipientDetails; + + /// Selected group name + + final String? selectedGroupName; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Row( + children: [ + Text( + (selectedRecipientType == RecipientType.individual) + ? 'Recipient: ' + : 'Recipients: ', + style: RecipientTextStyle.label, + ), + Flexible( + // Show recipients if selected + child: Text( + selectedRecipientDetails, + style: RecipientTextStyle.webId, + ), + ), + ], + ), + if (selectedRecipientType == RecipientType.group) ...[ + smallGapV, + Row( + children: [ + const Text('Group name: ', style: RecipientTextStyle.label), + Flexible( + child: Text( + selectedGroupName!, + style: RecipientTextStyle.webId, + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 7a8e742..660408d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,7 @@ dependency_overrides: solidpod: git: url: https://github.com/anusii/solidpod.git - ref: dev + ref: tony/59_ui_migration dev_dependencies: flutter_lints: ^6.0.0