From f3f4b30218c5936ebfe7fd00661beed037062ea9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:28:33 -0400 Subject: [PATCH 1/2] Clarify share link disable behavior --- lib/share/share_link_copy.dart | 16 ++ lib/widgets/dialogs/share_links_dialog.dart | 162 +++++++++++--------- test/share/share_link_copy_test.dart | 11 ++ 3 files changed, 118 insertions(+), 71 deletions(-) create mode 100644 lib/share/share_link_copy.dart create mode 100644 test/share/share_link_copy_test.dart diff --git a/lib/share/share_link_copy.dart b/lib/share/share_link_copy.dart new file mode 100644 index 00000000..20889b73 --- /dev/null +++ b/lib/share/share_link_copy.dart @@ -0,0 +1,16 @@ +abstract final class ShareLinkCopy { + static const dialogDescription = + 'Links stay active until you disable them. Anyone who opens one joins ' + 'this item with the access you choose.'; + + static const disableTitle = 'Disable this link?'; + static const disableDescription = + 'Disabling this link prevents new people from joining. People who ' + 'already joined keep their access.'; + static const disableAction = 'Disable link'; + static const disableFailure = 'Failed to disable share link.'; + + static const disabledStatus = 'DISABLED'; + static const disabledTooltip = 'Link disabled'; + static const disableTooltip = 'Disable link'; +} diff --git a/lib/widgets/dialogs/share_links_dialog.dart b/lib/widgets/dialogs/share_links_dialog.dart index 4d925ffa..7001943e 100644 --- a/lib/widgets/dialogs/share_links_dialog.dart +++ b/lib/widgets/dialogs/share_links_dialog.dart @@ -7,6 +7,7 @@ import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/share/share_link_copy.dart'; import 'package:icarus/share/share_link_format.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -60,11 +61,10 @@ class _ShareLinksDialogState extends ConsumerState { List _links = const []; _LinksStatus _status = _LinksStatus.loading; bool _isCreating = false; - String? _revokingToken; + String? _disablingToken; String _selectedRole = 'viewer'; - int get _activeLinkCount => - _links.where((link) => !link.isRevoked).length; + int get _activeLinkCount => _links.where((link) => !link.isRevoked).length; Future _loadLinks() async { setState(() => _status = _LinksStatus.loading); @@ -119,19 +119,18 @@ class _ShareLinksDialogState extends ConsumerState { } } - Future _revokeLink(String token) async { + Future _disableLink(String token) async { final confirmed = await ConfirmAlertDialog.show( context: context, - title: 'Revoke this link?', - content: 'Anyone who has the link or code loses the ability to join. ' - 'People who already joined keep their access.', - confirmText: 'Revoke', + title: ShareLinkCopy.disableTitle, + content: ShareLinkCopy.disableDescription, + confirmText: ShareLinkCopy.disableAction, isDestructive: true, ); if (!confirmed || !mounted) { return; } - setState(() => _revokingToken = token); + setState(() => _disablingToken = token); try { await ref.read(convexStrategyRepositoryProvider).revokeShareLink( targetType: widget.targetType, @@ -141,12 +140,12 @@ class _ShareLinksDialogState extends ConsumerState { await _loadLinks(); } catch (_) { Settings.showToast( - message: 'Failed to revoke share link.', + message: ShareLinkCopy.disableFailure, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } finally { if (mounted) { - setState(() => _revokingToken = null); + setState(() => _disablingToken = null); } } } @@ -160,10 +159,7 @@ class _ShareLinksDialogState extends ConsumerState { _shareDialogHeadline(widget.title), softWrap: true, ), - description: const Text( - 'Links never expire. Anyone who opens one joins this item with the ' - 'access you choose.', - ), + description: const Text(ShareLinkCopy.dialogDescription), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(), @@ -183,6 +179,7 @@ class _ShareLinksDialogState extends ConsumerState { children: [ Expanded( child: ShadSelect( + key: const ValueKey('share-link-role-select'), initialValue: _selectedRole, selectedOptionBuilder: (context, value) => Text( value == 'editor' ? 'Can edit' : 'View only', @@ -212,6 +209,7 @@ class _ShareLinksDialogState extends ConsumerState { ), const SizedBox(width: 8), ShadButton( + key: const ValueKey('share-link-create-and-copy'), onPressed: _isCreating ? null : _createLink, leading: _isCreating ? SizedBox( @@ -292,8 +290,8 @@ class _ShareLinksDialogState extends ConsumerState { final link = _links[index]; return _ShareLinkTile( link: link, - isRevoking: _revokingToken == link.token, - onRevoke: () => _revokeLink(link.token), + isDisabling: _disablingToken == link.token, + onDisable: () => _disableLink(link.token), ); }, ), @@ -367,7 +365,7 @@ class _RoleBadge extends StatelessWidget { if (isRevoked) { background = theme.colorScheme.destructive.withValues(alpha: 0.14); foreground = theme.colorScheme.destructive; - label = 'REVOKED'; + label = ShareLinkCopy.disabledStatus; } else if (role == 'editor') { background = theme.colorScheme.primary.withValues(alpha: 0.16); foreground = theme.colorScheme.primary; @@ -399,13 +397,13 @@ class _RoleBadge extends StatelessWidget { class _ShareLinkTile extends StatefulWidget { const _ShareLinkTile({ required this.link, - required this.isRevoking, - required this.onRevoke, + required this.isDisabling, + required this.onDisable, }); final ShareLinkSummary link; - final bool isRevoking; - final VoidCallback onRevoke; + final bool isDisabling; + final VoidCallback onDisable; @override State<_ShareLinkTile> createState() => _ShareLinkTileState(); @@ -478,6 +476,7 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { ), const SizedBox(width: 8), _CopyIconButton( + buttonKey: ValueKey('share-link-copy-link-${link.token}'), tooltip: 'Copy link', icon: LucideIcons.link, enabled: !link.isRevoked, @@ -485,35 +484,47 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { toastMessage: 'Share link copied to clipboard.', ), _CopyIconButton( + buttonKey: ValueKey('share-link-copy-code-${link.token}'), tooltip: 'Copy code', icon: LucideIcons.hash, enabled: !link.isRevoked, textToCopy: link.token, toastMessage: 'Share code copied to clipboard.', ), - Tooltip( - message: link.isRevoked ? 'Revoked' : 'Revoke link', - child: ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: link.isRevoked || widget.isRevoking - ? null - : widget.onRevoke, - child: widget.isRevoking - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.mutedForeground, + Semantics( + label: link.isRevoked + ? ShareLinkCopy.disabledTooltip + : ShareLinkCopy.disableTooltip, + button: true, + enabled: !link.isRevoked && !widget.isDisabling, + excludeSemantics: true, + child: Tooltip( + message: link.isRevoked + ? ShareLinkCopy.disabledTooltip + : ShareLinkCopy.disableTooltip, + child: ShadButton.ghost( + key: ValueKey('share-link-disable-${link.token}'), + size: ShadButtonSize.sm, + onPressed: link.isRevoked || widget.isDisabling + ? null + : widget.onDisable, + child: widget.isDisabling + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.mutedForeground, + ), + ) + : Icon( + LucideIcons.link2Off, + size: 16, + color: link.isRevoked + ? theme.colorScheme.mutedForeground + : theme.colorScheme.destructive, ), - ) - : Icon( - LucideIcons.trash2, - size: 16, - color: link.isRevoked - ? theme.colorScheme.mutedForeground - : theme.colorScheme.destructive, - ), + ), ), ), ], @@ -526,6 +537,7 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { class _CopyIconButton extends StatefulWidget { const _CopyIconButton({ + required this.buttonKey, required this.tooltip, required this.icon, required this.enabled, @@ -533,6 +545,7 @@ class _CopyIconButton extends StatefulWidget { required this.toastMessage, }); + final Key buttonKey; final String tooltip; final IconData icon; final bool enabled; @@ -572,34 +585,41 @@ class _CopyIconButtonState extends State<_CopyIconButton> { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return Tooltip( - message: widget.tooltip, - child: ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: widget.enabled ? _copy : null, - child: AnimatedSwitcher( - duration: _hoverDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - transitionBuilder: (child, animation) => ScaleTransition( - scale: animation, - child: FadeTransition(opacity: animation, child: child), + return Semantics( + label: widget.tooltip, + button: true, + enabled: widget.enabled, + excludeSemantics: true, + child: Tooltip( + message: widget.tooltip, + child: ShadButton.ghost( + key: widget.buttonKey, + size: ShadButtonSize.sm, + onPressed: widget.enabled ? _copy : null, + child: AnimatedSwitcher( + duration: _hoverDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ), + child: _copied + ? Icon( + LucideIcons.check, + key: const ValueKey('check'), + size: 16, + color: theme.colorScheme.primary, + ) + : Icon( + widget.icon, + key: const ValueKey('idle'), + size: 16, + color: widget.enabled + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, + ), ), - child: _copied - ? Icon( - LucideIcons.check, - key: const ValueKey('check'), - size: 16, - color: theme.colorScheme.primary, - ) - : Icon( - widget.icon, - key: const ValueKey('idle'), - size: 16, - color: widget.enabled - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, - ), ), ), ); diff --git a/test/share/share_link_copy_test.dart b/test/share/share_link_copy_test.dart new file mode 100644 index 00000000..42a0bce6 --- /dev/null +++ b/test/share/share_link_copy_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/share/share_link_copy.dart'; + +void main() { + test('disable copy states the boundary without overpromising', () { + expect(ShareLinkCopy.disableAction, 'Disable link'); + expect(ShareLinkCopy.disableDescription, contains('prevents new people')); + expect(ShareLinkCopy.disableDescription, contains('keep their access')); + expect(ShareLinkCopy.dialogDescription, contains('until you disable them')); + }); +} From a956974362fd7c6404738eab511eb33a5e2d53a7 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:42:52 -0400 Subject: [PATCH 2/2] Keep share actions accessible through semantics --- lib/widgets/dialogs/share_links_dialog.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/widgets/dialogs/share_links_dialog.dart b/lib/widgets/dialogs/share_links_dialog.dart index 7001943e..561c4279 100644 --- a/lib/widgets/dialogs/share_links_dialog.dart +++ b/lib/widgets/dialogs/share_links_dialog.dart @@ -497,6 +497,9 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { : ShareLinkCopy.disableTooltip, button: true, enabled: !link.isRevoked && !widget.isDisabling, + onTap: link.isRevoked || widget.isDisabling + ? null + : widget.onDisable, excludeSemantics: true, child: Tooltip( message: link.isRevoked @@ -589,6 +592,7 @@ class _CopyIconButtonState extends State<_CopyIconButton> { label: widget.tooltip, button: true, enabled: widget.enabled, + onTap: widget.enabled ? _copy : null, excludeSemantics: true, child: Tooltip( message: widget.tooltip,