diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 13382da620..098ea05813 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -198,8 +199,9 @@ class ActivityPage extends HookConsumerWidget { Future openItem(InboxItem item) async { final channelId = item.item.channelId; if (channelId == null) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar(content: Text("This item isn't linked to a channel.")), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('No linked channel')), + kind: ToastKind.error, ); return; } @@ -236,16 +238,18 @@ class ActivityPage extends HookConsumerWidget { } catch (error) { if (!isCurrentScope()) return; if (!context.mounted) return; - ScaffoldMessenger.maybeOf(context)?.showSnackBar( + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( SnackBar(content: Text('Could not reopen conversation: $error')), + kind: ToastKind.error, ); return; } } if (channel == null) { if (!context.mounted) return; - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar(content: Text('Channel not found in this workspace.')), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Channel not found here')), + kind: ToastKind.error, ); return; } @@ -278,10 +282,9 @@ class ActivityPage extends HookConsumerWidget { void openDraft(ComposeDraft draft) { final channel = channelById[draft.channelId]; if (channel == null) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Channel for this draft is no longer available.'), - ), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Draft’s channel unavailable')), + kind: ToastKind.error, ); return; } @@ -300,19 +303,17 @@ class ActivityPage extends HookConsumerWidget { void openReminder(Reminder reminder) { final target = reminder.target; if (target == null) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text("This reminder isn't linked to a message."), - ), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Reminder has no linked message')), + kind: ToastKind.error, ); return; } final channel = channelById[target.channelId]; if (channel == null) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Channel for this reminder is no longer available.'), - ), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Reminder’s channel unavailable')), + kind: ToastKind.error, ); return; } diff --git a/mobile/lib/features/agents/remote_agent_model_control.dart b/mobile/lib/features/agents/remote_agent_model_control.dart index 66cf31c698..a913fe4226 100644 --- a/mobile/lib/features/agents/remote_agent_model_control.dart +++ b/mobile/lib/features/agents/remote_agent_model_control.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -114,12 +115,13 @@ class RemoteAgentModelControl extends HookConsumerWidget { isCurrent(), ); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( SnackBar( content: Text( saved ? 'Model updated' : controller.failure, ), ), + kind: saved ? ToastKind.success : ToastKind.error, ); } }, diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index d5bdd33d89..3f8cb24548 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -241,7 +242,7 @@ class ChannelActionsSheet extends HookConsumerWidget { copyToClipboard( context, currentChannel.name, - message: 'Channel name copied to clipboard', + message: 'Channel name copied', ); }, ), @@ -253,7 +254,7 @@ class ChannelActionsSheet extends HookConsumerWidget { copyToClipboard( context, channel.id, - message: 'Channel ID copied to clipboard', + message: 'Channel ID copied', ); }, ), @@ -503,12 +504,13 @@ Future _confirmAndRun( if (pageContext.mounted) Navigator.of(pageContext).pop(true); } catch (error) { if (!pageContext.mounted) return; - ScaffoldMessenger.of(pageContext).showSnackBar( + ScaffoldMessenger.of(pageContext).showFeedbackSnackBar( SnackBar( content: Text( 'Couldn’t ${confirmLabel.toLowerCase()} channel. Try again.', ), ), + kind: ToastKind.error, ); } } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 9af19b9f26..3f90c616c9 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'message_swipe_reply.dart'; import 'huddle_timeline.dart'; import '../../shared/widgets/modal_presentation.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 50957fd051..79ddbd9bac 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -263,8 +263,9 @@ IosNavigationAction _huddleNavigationAction( ); } catch (error) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( SnackBar(content: Text(_huddleActionError(error))), + kind: ToastKind.error, ); } }, @@ -442,9 +443,10 @@ Future _startMobileHuddleFromChannel({ ); } catch (error) { if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(_huddleActionError(error)))); + ScaffoldMessenger.of(context).showFeedbackSnackBar( + SnackBar(content: Text(_huddleActionError(error))), + kind: ToastKind.error, + ); } } @@ -456,8 +458,9 @@ void _openMobileHuddle({ final config = ref.read(relayConfigProvider); final nsec = config.nsec; if (nsec == null || nsec.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( const SnackBar(content: Text('A paired identity is required.')), + kind: ToastKind.generic, ); return; } @@ -555,9 +558,10 @@ Future _startReplacementHuddle({ ); } catch (error) { if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(_huddleActionError(error)))); + ScaffoldMessenger.of(context).showFeedbackSnackBar( + SnackBar(content: Text(_huddleActionError(error))), + kind: ToastKind.error, + ); } } @@ -770,10 +774,13 @@ class _MobileHuddleCallPage extends ConsumerWidget { ) .catchError((Object error) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of( + context, + ).showFeedbackSnackBar( const SnackBar( content: Text('Reaction failed'), ), + kind: ToastKind.error, ); }), ); diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index c51c770b4d..83f6e68095 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -255,8 +255,9 @@ class ChannelDetailsPage extends HookConsumerWidget { if (context.mounted) Navigator.of(context).pop(false); } catch (_) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( const SnackBar(content: Text('Couldn’t join channel. Try again.')), + kind: ToastKind.error, ); } } finally { @@ -497,7 +498,7 @@ class ChannelDetailsPage extends HookConsumerWidget { copyToClipboard( context, resolvedChannel.name, - message: 'Channel name copied to clipboard', + message: 'Channel name copied', ); }, ), @@ -508,7 +509,7 @@ class ChannelDetailsPage extends HookConsumerWidget { copyToClipboard( context, resolvedChannel.id, - message: 'Channel ID copied to clipboard', + message: 'Channel ID copied', ); }, ), diff --git a/mobile/lib/features/channels/channel_link_navigation.dart b/mobile/lib/features/channels/channel_link_navigation.dart index 7aa32c4df4..9fcae9713b 100644 --- a/mobile/lib/features/channels/channel_link_navigation.dart +++ b/mobile/lib/features/channels/channel_link_navigation.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -24,8 +25,9 @@ void openChannelLink({ } if (targetChannel == null) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( const SnackBar(content: Text('Channel could not be opened')), + kind: ToastKind.error, ); return; } diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 0d97b01f88..03279c57c7 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'channel_unread.dart'; import '../../shared/widgets/collapsible_section_body.dart'; import '../../shared/widgets/compose_morph_icon.dart'; diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index a3e032c997..b5592c683d 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -99,8 +99,9 @@ Future _confirmRemoveCommunity( Navigator.of(context).pop(); } } catch (e) { - messenger.showSnackBar( + messenger.showFeedbackSnackBar( SnackBar(content: Text('Failed to remove community: $e')), + kind: ToastKind.error, ); } } diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 1155c5d976..e558c24e09 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,5 @@ +import 'package:buzz/shared/widgets/buzz_toast.dart'; +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'composer_send_origin.dart'; import 'dart:async'; import 'dart:collection'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index a950b7ceff..e64a6f4639 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -561,7 +561,10 @@ class ComposeBar extends HookConsumerWidget { ), ); } on FormatException catch (error) { - messenger?.showSnackBar(SnackBar(content: Text(error.message))); + messenger?.showFeedbackSnackBar( + SnackBar(content: Text(error.message)), + kind: ToastKind.error, + ); return; } final outgoing = _OutgoingMentions(selectedMentions); @@ -579,17 +582,14 @@ class ComposeBar extends HookConsumerWidget { ); } catch (_) { // Nothing was sent and the draft is untouched, so Retry sends it again. - messenger?.showSnackBar( - SnackBar( - content: const Text( - 'Message not sent: could not check who is in this channel', - ), - action: SnackBarAction( - label: 'Retry', - onPressed: () => unawaited(send()), - ), - ), - ); + if (context.mounted) { + showBuzzToast( + context, + 'Not sent. Couldn’t check members.', + kind: ToastKind.error, + onRetry: () => unawaited(send()), + ); + } return; } @@ -913,7 +913,7 @@ class ComposeBar extends HookConsumerWidget { .read(mediaUploadServiceProvider) .pickGalleryImages(); queueImages(photos); - }, errorMessage: 'Unable to open your photo library.'), + }, errorMessage: 'Couldn’t open photo library'), onVideo: () => chooseAttachment(() { final service = ref.read(mediaUploadServiceProvider); return pickThenQueue( diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index f4202b08ee..2e30d300e2 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -38,10 +38,9 @@ Future _sendTextOnlyDraft({ // A draft that changed while adding ran is not the draft the user sent. // Keep it and send nothing. if (context.mounted && draftRevision.value != submittedDraftRevision) { - messenger?.showSnackBar( - const SnackBar( - content: Text('Not sent: the message changed while adding people.'), - ), + messenger?.showFeedbackSnackBar( + const SnackBar(content: Text('Not sent. Message changed.')), + kind: ToastKind.error, ); return; } @@ -65,8 +64,9 @@ Future _sendTextOnlyDraft({ // The caller runs unawaited, so surface publish failures and restore the // sent draft unless the user has already started a new one. restoreClearedDraft(); - messenger?.showSnackBar( + messenger?.showFeedbackSnackBar( SnackBar(content: Text(_composeSendErrorMessage(error))), + kind: ToastKind.error, ); } } diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 31d183ebfc..9515373dda 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -418,8 +418,9 @@ void _sendTypingIndicator( /// identity change that causes the failure also resets that state on the next /// frame, so [messenger] must be resolved before the send's first `await`. void _reportSendCancelledByCommunitySwitch(ScaffoldMessengerState? messenger) { - messenger?.showSnackBar( - const SnackBar(content: Text('Message not sent: the community changed')), + messenger?.showFeedbackSnackBar( + const SnackBar(content: Text('Not sent. Community changed.')), + kind: ToastKind.error, ); } @@ -641,8 +642,9 @@ class _OutgoingMentions { ); demote(outcome.notAdded); if (outcome.errors.isNotEmpty) { - messenger?.showSnackBar( + messenger?.showFeedbackSnackBar( SnackBar(content: Text(outcome.errors.join(' '))), + kind: ToastKind.error, ); } } diff --git a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart index 0b94aad550..460358615b 100644 --- a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart +++ b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart @@ -55,13 +55,13 @@ class _IOSInlinePhotoPicker extends HookWidget { case 'didFail': isPreparingSelection.value = false; if (!context.mounted) return; - ScaffoldMessenger.maybeOf(context)?.showSnackBar( + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( SnackBar( content: Text( - call.arguments as String? ?? - 'Unable to prepare the selected photos.', + call.arguments as String? ?? 'Couldn’t prepare photos', ), ), + kind: ToastKind.error, ); } }); @@ -103,8 +103,9 @@ class _IOSInlinePhotoPicker extends HookWidget { } } catch (_) { if (context.mounted) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar(content: Text('Unable to open your photo library.')), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t open photo library')), + kind: ToastKind.error, ); } } finally { diff --git a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart index 65139a9d1a..7c1e3158b1 100644 --- a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -86,8 +86,8 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { } catch (_) { if (context.mounted) { actionError.value = selection.value.isEmpty - ? 'Unable to open your photo library.' - : 'Unable to prepare the selected photos.'; + ? 'Couldn’t open photo library' + : 'Couldn’t prepare photos'; } } finally { if (context.mounted) isResolving.value = false; diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index cc7f7cd24e..7867e29c41 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -135,12 +136,11 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); consumed = true; } - ScaffoldMessenger.maybeOf(context)?.showSnackBar( + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( const SnackBar( - content: Text( - 'Could not open setup. Re-open the link to try again.', - ), + content: Text('Couldn’t open setup. Reopen the link.'), ), + kind: ToastKind.error, ); } } finally { @@ -165,16 +165,14 @@ class _DeepLinkDispatcherState extends ConsumerState { return; case DeepLinkCommunityPreparation.unavailable: ref.read(pendingDeepLinkProvider.notifier).consume(); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Notification community is no longer available'), - ), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Community unavailable')), + kind: ToastKind.error, ); case DeepLinkCommunityPreparation.failed: - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Could not open the notification community'), - ), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t open community')), + kind: ToastKind.error, ); } } @@ -260,15 +258,17 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); if (failure != null) { debugPrint('deep-link: channel $channelId lookup failed: $failure'); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( const SnackBar(content: Text('Could not open the channel')), + kind: ToastKind.error, ); } else { debugPrint( 'deep-link: channel $channelId not found in workspace; dropping link', ); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar(content: Text('Channel not found in this workspace')), + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Channel not found here')), + kind: ToastKind.error, ); } } @@ -327,19 +327,19 @@ class _DeepLinkDispatcherState extends ConsumerState { } } } else if (status == InviteJoinStatus.switchedExisting) { - messenger?.showSnackBar( - const SnackBar(content: Text('Switched to this community')), + messenger?.showFeedbackSnackBar( + const SnackBar(content: Text('Community switched')), + kind: ToastKind.generic, ); } } catch (error) { debugPrint('deep-link: failed to prepare invite: $error'); if (navigatorContext.mounted) { - messenger?.showSnackBar( + messenger?.showFeedbackSnackBar( const SnackBar( - content: Text( - 'Could not open this invite. Re-open the invite link to try again.', - ), + content: Text('Couldn’t open invite. Reopen the link.'), ), + kind: ToastKind.error, ); } } finally { diff --git a/mobile/lib/features/channels/default_agent_launcher.dart b/mobile/lib/features/channels/default_agent_launcher.dart index f7c40e776e..343a191cbc 100644 --- a/mobile/lib/features/channels/default_agent_launcher.dart +++ b/mobile/lib/features/channels/default_agent_launcher.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -100,10 +101,11 @@ class DefaultAgentLauncher extends HookConsumerWidget { ); } catch (error) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( SnackBar( content: Text('Could not open the agent conversation. $error'), ), + kind: ToastKind.error, ); } } finally { diff --git a/mobile/lib/features/channels/ios_add_members_sheet.dart b/mobile/lib/features/channels/ios_add_members_sheet.dart index dac34bde15..477a6dd6cc 100644 --- a/mobile/lib/features/channels/ios_add_members_sheet.dart +++ b/mobile/lib/features/channels/ios_add_members_sheet.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'dart:collection'; @@ -149,8 +150,9 @@ class _IosAddMembersSheetState extends State { void _failed() { if (!mounted || _closed) return; _closed = true; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Couldn't open Add members. Try again.")), + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t open Add members')), + kind: ToastKind.error, ); widget.onClose(); } diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 1089490c8a..e58568c908 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import 'dart:async'; @@ -113,10 +114,9 @@ Future showManageChannelSheet({ } catch (_) { if (!context.mounted || !canPresent()) return false; retry = true; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("We couldn't save this change. Try again."), - ), + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t save changes')), + kind: ToastKind.error, ); } } @@ -257,7 +257,7 @@ class ManageChannelSheet extends HookConsumerWidget { Navigator.of(context).pop(false); } catch (_) { if (context.mounted) { - error.value = "We couldn't save this change. Try again."; + error.value = 'Couldn’t save changes'; } } finally { if (context.mounted) saving.value = false; diff --git a/mobile/lib/features/channels/me/me_chat_assistant.dart b/mobile/lib/features/channels/me/me_chat_assistant.dart index 2dc03200d7..736b8f3c46 100644 --- a/mobile/lib/features/channels/me/me_chat_assistant.dart +++ b/mobile/lib/features/channels/me/me_chat_assistant.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -201,10 +202,11 @@ useMeChatAssistant( if (context.mounted) selection.value = (key, picked); } catch (error) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( SnackBar( content: Text(error.toString().replaceFirst('Bad state: ', '')), ), + kind: ToastKind.error, ); } } finally { diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index a0aab7e990..2ad9980e74 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -98,10 +99,11 @@ class MePage extends HookConsumerWidget { await _offerMeRecovery(context, ref.read(meSyncProvider)); } if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( SnackBar( content: Text(error.toString().replaceFirst('Bad state: ', '')), ), + kind: ToastKind.error, ); } } finally { diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index 7dad3358f6..d4b7aa4827 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/buzz_toast.dart'; import 'dart:async'; import 'dart:io'; import 'dart:ui' show ImageFilter; diff --git a/mobile/lib/features/channels/media_viewer_page/video_controls.dart b/mobile/lib/features/channels/media_viewer_page/video_controls.dart index f136c4de21..c6ea0c9b5d 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_controls.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_controls.dart @@ -88,18 +88,15 @@ class _VideoTransportBar extends HookConsumerWidget { } catch (error) { debugPrint('[VideoViewer] seek failed: $error'); if (context.mounted && isCurrent()) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Could not seek in this video.'), - action: SnackBarAction( - label: 'Retry', - onPressed: () { - if (!isCurrent()) return; - beginScrub(next); - unawaited(finishScrub(next)); - }, - ), - ), + showBuzzToast( + context, + 'Could not seek in this video.', + kind: ToastKind.error, + onRetry: () { + if (!isCurrent()) return; + beginScrub(next); + unawaited(finishScrub(next)); + }, ); } } finally { diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index a6e5180bb6..f49d625072 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'dart:io'; import 'dart:ui' as ui; @@ -408,12 +409,14 @@ Future _saveImage( } final image = await _downloadImage(ref, imageUrl); await PhotoManager.editor.saveImage(image.bytes, filename: image.filename); - messenger?.showSnackBar( - const SnackBar(content: Text('Image saved to Photos')), + messenger?.showFeedbackSnackBar( + const SnackBar(content: Text('Saved to Photos')), + kind: ToastKind.success, ); } catch (_) { - messenger?.showSnackBar( + messenger?.showFeedbackSnackBar( const SnackBar(content: Text('Could not save image')), + kind: ToastKind.error, ); } } @@ -436,8 +439,9 @@ Future _shareImage( ShareParams(files: [XFile(file.path)], sharePositionOrigin: shareOrigin), ); } catch (_) { - messenger?.showSnackBar( + messenger?.showFeedbackSnackBar( const SnackBar(content: Text('Could not share image')), + kind: ToastKind.error, ); } } @@ -893,8 +897,9 @@ void _confirmDelete({ .deleteMessage(channelId: channelId, eventId: messageId); onDeleted?.call(); } catch (error) { - messenger.showSnackBar( + messenger.showFeedbackSnackBar( SnackBar(content: Text('Failed to delete message: $error')), + kind: ToastKind.error, ); } }, diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 7bbcb038ed..8efe3df23d 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import '../../shared/widgets/ios_media_carousel.dart'; import '../../shared/widgets/message_attachment_corners.dart'; @@ -715,8 +716,9 @@ class MessageContent extends HookConsumerWidget { ); } catch (_) { if (!context.mounted) return; - ScaffoldMessenger.maybeOf(context)?.showSnackBar( + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( const SnackBar(content: Text('Could not open attachment')), + kind: ToastKind.error, ); } }, @@ -914,7 +916,7 @@ class _MessageCodeBlock extends HookWidget { final isCopied = useState(false); Future handleCopy() async { - await copyToClipboard(context, code, message: 'Copied code to clipboard'); + await copyToClipboard(context, code, message: 'Code copied'); if (!context.mounted) return; isCopied.value = true; Future.delayed(const Duration(seconds: 2), () { diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 5d58ecb510..d5e79fc4ed 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -183,6 +183,46 @@ class HomePage extends HookConsumerWidget { : mobileTabBarBottomGap; return Scaffold( resizeToAvoidBottomInset: false, + // Register the floating controls with Scaffold so snackbars clear them. + extendBody: true, + bottomNavigationBar: Padding( + padding: EdgeInsets.only(bottom: bottom), + child: SizedBox( + height: mobileTabBarHeight, + child: + defaultTargetPlatform != TargetPlatform.iOS || + nativeControlsVisible + ? Center( + child: MessagesNavigationBar( + onSearch: openSearch, + agent: defaultAgentBuilder(context), + creationActions: [ + ( + label: 'New chat', + symbol: 'square.and.pencil', + onPressed: newChat.value, + ), + ( + label: 'New workspace', + symbol: 'folder.badge.plus', + onPressed: newWorkspace.value, + ), + ( + label: 'New DM', + symbol: 'bubble.left', + onPressed: newDm.value, + ), + ( + label: 'New channel', + symbol: 'number', + onPressed: newChannel.value, + ), + ], + ), + ) + : null, + ), + ), body: Stack( children: [ MediaQuery( @@ -219,41 +259,6 @@ class HomePage extends HookConsumerWidget { ), ), ), - if (defaultTargetPlatform != TargetPlatform.iOS || - nativeControlsVisible) - Positioned( - left: 0, - right: 0, - bottom: bottom, - child: Center( - child: MessagesNavigationBar( - onSearch: openSearch, - agent: defaultAgentBuilder(context), - creationActions: [ - ( - label: 'New chat', - symbol: 'square.and.pencil', - onPressed: newChat.value, - ), - ( - label: 'New workspace', - symbol: 'folder.badge.plus', - onPressed: newWorkspace.value, - ), - ( - label: 'New DM', - symbol: 'bubble.left', - onPressed: newDm.value, - ), - ( - label: 'New channel', - symbol: 'number', - onPressed: newChannel.value, - ), - ], - ), - ), - ), ], ), ); diff --git a/mobile/lib/features/invites/invite_create_page.dart b/mobile/lib/features/invites/invite_create_page.dart index 07803c0878..30812a9e0b 100644 --- a/mobile/lib/features/invites/invite_create_page.dart +++ b/mobile/lib/features/invites/invite_create_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:flutter/material.dart'; diff --git a/mobile/lib/features/invites/invite_create_page/invite_link_section.dart b/mobile/lib/features/invites/invite_create_page/invite_link_section.dart index 6539d8571d..a9ca97f4be 100644 --- a/mobile/lib/features/invites/invite_create_page/invite_link_section.dart +++ b/mobile/lib/features/invites/invite_create_page/invite_link_section.dart @@ -57,8 +57,9 @@ class _InviteLinkSection extends HookConsumerWidget { await ref.read(shareCommunityInviteProvider)(url, origin); } catch (_) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( const SnackBar(content: Text('Could not share invite link')), + kind: ToastKind.error, ); } } diff --git a/mobile/lib/features/invites/invite_create_page/person_invite_section.dart b/mobile/lib/features/invites/invite_create_page/person_invite_section.dart index 42482c002d..0527328a53 100644 --- a/mobile/lib/features/invites/invite_create_page/person_invite_section.dart +++ b/mobile/lib/features/invites/invite_create_page/person_invite_section.dart @@ -73,8 +73,9 @@ class _PersonInviteSection extends HookConsumerWidget { candidatePubkey.value = null; searchController.clear(); ref.invalidate(communityMembershipProvider); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Invited to the community')), + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Invite sent')), + kind: ToastKind.success, ); } catch (error) { if (context.mounted) { diff --git a/mobile/lib/features/onboarding/hosted_reconnect_banner.dart b/mobile/lib/features/onboarding/hosted_reconnect_banner.dart index 9ce55baf70..05506d411f 100644 --- a/mobile/lib/features/onboarding/hosted_reconnect_banner.dart +++ b/mobile/lib/features/onboarding/hosted_reconnect_banner.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/builderlab_account.dart'; +import '../../shared/widgets/buzz_toast.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/hosted/hosted_relay_auth.dart'; import '../../shared/relay/relay_provider.dart'; @@ -21,59 +22,65 @@ class HostedReconnectBanner extends HookConsumerWidget { final auth = ref.watch(hostedRelayAuthProvider); useListenable(auth); final config = ref.watch(relayConfigProvider); - if (auth.failedRelay != config.baseUrl || - auth.failedPubkey != pubkeyFromNsec(config.nsec) || - auth.failure == null) { - return const SizedBox.shrink(); - } - final signIn = auth.failure == AccountFailure.signedOut; + final active = + auth.failedRelay == config.baseUrl && + auth.failedPubkey == pubkeyFromNsec(config.nsec) && + auth.failure != null; + final signIn = active && auth.failure == AccountFailure.signedOut; final changeAccount = auth.failure == AccountFailure.denied; - return Positioned( - left: Grid.sm, - right: Grid.sm, - bottom: 0, - child: SafeArea( - child: Material( - elevation: 4, - borderRadius: BorderRadius.circular(Radii.lg), - child: Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - signIn - ? 'Sign in to Builderlab to reconnect' - : auth.failure == AccountFailure.denied - ? 'This account cannot reconnect to this community with its saved key.' - : 'Could not reconnect to Builderlab.', - ), - if (changeAccount) - TextButton( - onPressed: () => unawaited( - ref.read(relaySessionProvider.notifier).reconnect(), - ), - child: const Text('Retry'), - ), - TextButton( - onPressed: () => unawaited( - signIn || changeAccount - ? flow.reconnectAccount(changeAccount: changeAccount) - : ref.read(relaySessionProvider.notifier).reconnect(), - ), - child: Text( - changeAccount - ? 'Use a different account' - : signIn - ? 'Sign in' - : 'Retry', + return Stack( + children: [ + ReconnectCapsule( + visible: signIn, + onSignIn: () => unawaited(flow.reconnectAccount()), + ), + if (active && !signIn) + Positioned( + left: Grid.sm, + right: Grid.sm, + bottom: 0, + child: SafeArea( + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(Radii.lg), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + auth.failure == AccountFailure.denied + ? 'This account cannot reconnect to this community with its saved key.' + : 'Could not reconnect to Builderlab.', + ), + if (changeAccount) + TextButton( + onPressed: () => unawaited( + ref.read(relaySessionProvider.notifier).reconnect(), + ), + child: const Text('Retry'), + ), + TextButton( + onPressed: () => unawaited( + changeAccount + ? flow.reconnectAccount( + changeAccount: changeAccount, + ) + : ref + .read(relaySessionProvider.notifier) + .reconnect(), + ), + child: Text( + changeAccount ? 'Use a different account' : 'Retry', + ), + ), + ], ), ), - ], + ), ), ), - ), - ), + ], ); } } diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index b31d862558..6ec7d052b4 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'community_link_focus.dart'; import '../../shared/widgets/ios_community_link_input.dart'; import 'dart:async'; @@ -144,8 +145,9 @@ class PairingPage extends HookConsumerWidget { } if (identityRecoveryOnly && Uri.tryParse(code)?.queryParameters['mode'] != 'recover') { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( const SnackBar(content: Text('Scan a desktop recovery code.')), + kind: ToastKind.generic, ); return; } diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 6b2199cdc1..7388be3a2d 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -132,10 +133,9 @@ class ProfileEditPage extends HookConsumerWidget { !(ModalRoute.of(context)?.isCurrent ?? true)) { return; } - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("We couldn't save this change. Try again."), - ), + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t save changes')), + kind: ToastKind.error, ); }, ); @@ -663,7 +663,7 @@ class _ProfileTextEditSheet extends HookWidget { } on ProfileCommunityChangedException { if (context.mounted) Navigator.of(context).pop(); } catch (_) { - error.value = "We couldn't save this change. Try again."; + error.value = 'Couldn’t save changes'; } finally { if (context.mounted) isSaving.value = false; } diff --git a/mobile/lib/features/profile/profile_text_edit_sheet.dart b/mobile/lib/features/profile/profile_text_edit_sheet.dart index 9cbb89d286..5d982716aa 100644 --- a/mobile/lib/features/profile/profile_text_edit_sheet.dart +++ b/mobile/lib/features/profile/profile_text_edit_sheet.dart @@ -61,7 +61,7 @@ class ProfileTextEditSheet extends HookWidget { if (context.mounted) await closeAfterSave(); } catch (_) { if (context.mounted) { - error.value = "We couldn't save this change. Try again."; + error.value = 'Couldn’t save changes'; } } finally { if (context.mounted) isSaving.value = false; diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 496f5407d3..1f0b2492a2 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/misc.dart' show ProviderListenable; import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../shared/animated_avatar.dart'; +import '../../shared/widgets/buzz_toast.dart'; import '../../shared/identity_names/identity_names.dart'; import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/relay/relay.dart'; @@ -157,7 +158,7 @@ class UserProfileSheet extends HookConsumerWidget { await Clipboard.setData(ClipboardData(text: npub)); if (!context.mounted) return; copied.value = true; - _showProfileCopyToast(context); + showBuzzToast(context, 'Public key copied', kind: ToastKind.success); unawaited( Future.delayed(const Duration(seconds: 2), () { if (context.mounted) copied.value = false; @@ -374,47 +375,6 @@ class UserProfileSheet extends HookConsumerWidget { } } -/// Shows clipboard feedback above the bottom-sheet route. A regular SnackBar -/// belongs to the page Scaffold, which sits behind the modal sheet. -void _showProfileCopyToast(BuildContext context) { - final overlay = Overlay.of(context, rootOverlay: true); - final colors = context.colors; - final textStyle = context.textTheme.bodyMedium?.copyWith( - color: colors.onInverseSurface, - ); - late final OverlayEntry entry; - entry = OverlayEntry( - builder: (overlayContext) => Positioned( - left: Grid.gutter, - right: Grid.gutter, - bottom: MediaQuery.viewPaddingOf(overlayContext).bottom + Grid.xs, - child: Material( - color: colors.inverseSurface, - elevation: 6, - borderRadius: BorderRadius.circular(Radii.lg), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.twelve, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(BuzzIcons.check, size: 18, color: colors.onInverseSurface), - const SizedBox(width: Grid.xxs), - Text('Public key copied', style: textStyle), - ], - ), - ), - ), - ), - ); - overlay.insert(entry); - Future.delayed(const Duration(seconds: 2), () { - if (entry.mounted) entry.remove(); - }); -} - /// The read-only version of the presence control in Settings. A viewed profile /// reflects its owner's availability but does not allow another user to change /// it. diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 991b16580d..4e4418b0ca 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -672,10 +673,9 @@ Future _openSearchChannel(BuildContext context, Channel channel) async { ); } catch (_) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Couldn’t open this conversation. Try again.'), - ), + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t open conversation')), + kind: ToastKind.error, ); } } diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index b1645926c7..15d0bb8dae 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:buzz/shared/crypto/event_signing.dart'; @@ -13,7 +14,6 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/identity_transfer/nsec_export_sheet.dart'; import '../../shared/clipboard_utils.dart'; -import '../../shared/success_haptic.dart'; import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/push/push_bootstrap.dart'; import '../../shared/relay/relay.dart'; diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index a0be6d0485..f58f3a91c0 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -49,9 +49,10 @@ class _ConnectionSection extends ConsumerWidget { if (!context.mounted) return; final message = ref.read(pairingProvider).errorMessage; if (message != null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context).showFeedbackSnackBar( + SnackBar(content: Text(message)), + kind: ToastKind.error, + ); } return; } @@ -61,12 +62,11 @@ class _ConnectionSection extends ConsumerWidget { final resumed = await _waitForResumedFrame(); if (!resumed) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(context).showFeedbackSnackBar( const SnackBar( - content: Text( - 'Buzz did not return to the foreground. Try again.', - ), + content: Text('Couldn’t resume Buzz. Try again.'), ), + kind: ToastKind.error, ); } return; @@ -147,9 +147,8 @@ class _IdentityRow extends StatelessWidget { await copyToClipboard( context, npub, - message: 'Public key (npub) copied', + message: 'Public key copied', ); - await successHaptic(); }, ), ); diff --git a/mobile/lib/shared/clipboard_utils.dart b/mobile/lib/shared/clipboard_utils.dart index d08ab95060..302fb69eab 100644 --- a/mobile/lib/shared/clipboard_utils.dart +++ b/mobile/lib/shared/clipboard_utils.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'widgets/buzz_toast.dart'; + Future copyToClipboard( BuildContext context, String text, { - String message = 'Copied to clipboard', + String message = 'Copied', }) async { await Clipboard.setData(ClipboardData(text: text)); if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), duration: const Duration(seconds: 2)), - ); + showBuzzToast(context, message, kind: ToastKind.success); } diff --git a/mobile/lib/shared/reminders/remind_me_later_sheet.dart b/mobile/lib/shared/reminders/remind_me_later_sheet.dart index 0afcabf20d..dfd721479d 100644 --- a/mobile/lib/shared/reminders/remind_me_later_sheet.dart +++ b/mobile/lib/shared/reminders/remind_me_later_sheet.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -19,8 +20,9 @@ void showRemindMeLaterSheet({ final service = ref.read(reminderServiceProvider); final messenger = ScaffoldMessenger.of(context); if (service == null) { - messenger.showSnackBar( + messenger.showFeedbackSnackBar( const SnackBar(content: Text('Sign in to set reminders')), + kind: ToastKind.generic, ); return; } @@ -28,12 +30,16 @@ void showRemindMeLaterSheet({ Future submit(int notBefore) async { try { await service.createReminder(target: target, notBefore: notBefore); - messenger.showSnackBar(const SnackBar(content: Text('Reminder set'))); + messenger.showFeedbackSnackBar( + const SnackBar(content: Text('Reminder set')), + kind: ToastKind.success, + ); } catch (error) { // Stable user-facing copy; the relay/crypto detail goes to the log. debugPrint('[RemindMeLater] createReminder failed: $error'); - messenger.showSnackBar( + messenger.showFeedbackSnackBar( const SnackBar(content: Text('Couldn’t set reminder. Try again.')), + kind: ToastKind.error, ); } } @@ -119,9 +125,10 @@ Future _pickCustomDateTime(BuildContext context) async { final timestamp = combineCustomDateTime(date, time.hour, time.minute); if (timestamp == null && context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Pick a time in the future'))); + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Pick a time in the future')), + kind: ToastKind.error, + ); } return timestamp; } diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index e4bbc6d32a..bad5e0a5f9 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -343,11 +343,24 @@ class AppTheme { space: 1, ), - // Snackbar + // Shared feedback surface for success, errors, and clipboard notices. snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, + backgroundColor: scheme.inverseSurface, + contentTextStyle: textTheme.bodyMedium?.copyWith( + color: scheme.onInverseSurface, + ), + actionTextColor: scheme.inversePrimary, + closeIconColor: scheme.onInverseSurface, + elevation: 6, + insetPadding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + Grid.xs, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: BorderRadius.circular(Radii.lg), ), ), ); diff --git a/mobile/lib/shared/toast_haptic.dart b/mobile/lib/shared/toast_haptic.dart new file mode 100644 index 0000000000..9320dd990b --- /dev/null +++ b/mobile/lib/shared/toast_haptic.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// The outcome communicated by a notice, independent of its wording. +enum ToastKind { generic, success, error } + +/// Plays outcome-specific feedback without blocking the notice. +Future toastHaptic(ToastKind kind) async { + try { + if (kind != ToastKind.generic && + !kIsWeb && + defaultTargetPlatform == TargetPlatform.iOS) { + await const MethodChannel('buzz/haptics').invokeMethod(kind.name); + return; + } + switch (kind) { + case ToastKind.generic: + await HapticFeedback.lightImpact(); + case ToastKind.success: + await HapticFeedback.mediumImpact(); + case ToastKind.error: + await HapticFeedback.heavyImpact(); + } + } on PlatformException { + // Haptics are optional; feedback must still be displayed. + } on MissingPluginException { + // Some platforms and test embedders do not provide haptics. + } +} diff --git a/mobile/lib/shared/widgets/buzz_toast.dart b/mobile/lib/shared/widgets/buzz_toast.dart new file mode 100644 index 0000000000..4f47066101 --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_toast.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../theme/theme.dart'; +import '../toast_haptic.dart'; +import 'toast_capsule_content.dart'; + +export '../toast_haptic.dart' show ToastKind; + +part 'buzz_toast_island.dart'; +part 'reconnect_capsule.dart'; + +final _toastCoordinators = Expando<_ToastCoordinator>(); + +_ToastCoordinator _toastCoordinator(BuildContext context) { + final overlay = Overlay.of(context, rootOverlay: true); + return _toastCoordinators[overlay] ??= _ToastCoordinator(); +} + +class _ToastCoordinator extends ValueNotifier { + _ToastCoordinator() : super(false); + + _ToastHandle? active; +} + +/// Shows brief feedback above routes and modal sheets. +/// A new notice replaces the current one instead of building a stale queue. +/// Supported portrait iPhones morph from the Dynamic Island automatically. +void showBuzzToast( + BuildContext context, + String message, { + ToastKind kind = ToastKind.generic, + VoidCallback? onRetry, + Rect? islandOrigin, + double islandSpeed = 0.75, +}) { + final overlay = Overlay.of(context, rootOverlay: true); + final coordinator = _toastCoordinator(context); + coordinator.active?.remove(); + late final _ToastHandle handle; + final entry = OverlayEntry( + builder: (_) => _ToastBubble( + message: message, + islandOrigin: islandOrigin, + islandSpeed: islandSpeed, + onDismiss: handle.remove, + onRetry: onRetry == null + ? null + : () { + handle.remove(); + if (context.mounted) onRetry(); + }, + ), + ); + handle = _ToastHandle(entry, () { + if (identical(coordinator.active, handle)) { + coordinator.active = null; + coordinator.value = false; + } + }); + coordinator.active = handle; + coordinator.value = true; + overlay.insert(entry); + unawaited(toastHaptic(kind)); +} + +class _ToastHandle { + _ToastHandle(this.entry, this.onRemove); + + final OverlayEntry entry; + final VoidCallback onRemove; + bool removed = false; + + void remove() { + if (removed) return; + removed = true; + entry.remove(); + entry.dispose(); + onRemove(); + } +} + +class _ToastBubble extends HookWidget { + const _ToastBubble({ + required this.message, + required this.onDismiss, + this.onRetry, + this.islandOrigin, + this.islandSpeed = 0.75, + this.visible = true, + this.persistent = false, + this.actionLabel = 'Retry', + }); + + final String message; + final VoidCallback? onRetry; + final VoidCallback onDismiss; + final Rect? islandOrigin; + final double islandSpeed; + final bool visible; + final bool persistent; + final String actionLabel; + + @override + Widget build(BuildContext context) { + final origin = islandOrigin ?? _toastIslandOrigin(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final accessible = MediaQuery.accessibleNavigationOf(context); + final program = useFuture( + useMemoized( + () => origin != null && !reduceMotion ? _loadIslandProgram() : null, + [origin != null, reduceMotion], + ), + ); + final shader = useMemoized(() => program.data?.fragmentShader(), [ + program.data, + ]); + useEffect( + () => + () => shader?.dispose(), + [shader], + ); + final waitingForIsland = + origin != null && + !reduceMotion && + program.connectionState != ConnectionState.done; + final island = reduceMotion || shader == null ? null : origin; + final speed = island == null ? 1.0 : islandSpeed.clamp(1 / 3, 1.0); + final entranceMs = (280 / speed).round(); + final controller = useAnimationController( + duration: Duration(milliseconds: entranceMs), + reverseDuration: Duration(milliseconds: (240 / speed).round()), + ); + useEffect( + () { + if (waitingForIsland) return null; + if (persistent) { + if (reduceMotion) { + controller.value = visible ? 1 : 0; + } else if (visible) { + controller.forward(); + } else { + controller.reverse(); + } + return null; + } + if (reduceMotion) { + controller.value = 1; + } else { + controller.forward(); + } + final timer = accessible && onRetry != null + ? null + : Timer( + Duration( + milliseconds: + (accessible || onRetry != null ? 6000 : 3000) + + (reduceMotion ? 0 : entranceMs), + ), + () async { + if (!reduceMotion) { + try { + await controller.reverse().orCancel; + } on TickerCanceled { + return; + } + } + onDismiss(); + }, + ); + return () => timer?.cancel(); + }, + [ + controller, + reduceMotion, + accessible, + persistent, + visible, + waitingForIsland, + ], + ); + final value = useAnimation(controller); + if (waitingForIsland || (persistent && value == 0)) { + return const SizedBox.shrink(); + } + final exiting = controller.status == AnimationStatus.reverse; + // The exit collapses immediately while upward travel accelerates later. + // Reversing the entry width curve leaves the text fully exposed too long. + final progress = exiting + ? Curves.easeOutQuad.transform(value) + : const Cubic(0.2, 1.1, 0.4, 1).transform(value); + final reveal = exiting + ? Curves.easeInCubic.transform(value) + : Curves.easeOutCubic.transform(value); + final contentOpacity = exiting + ? value * value + : island == null + ? 1.0 + : const Interval(0.25, 0.8).transform(value); + final top = MediaQuery.viewPaddingOf(context).top + Grid.xxs; + // Follow the system preference even when the current route overrides Theme. + final dark = MediaQuery.platformBrightnessOf(context) == Brightness.dark; + final background = island != null + ? Colors.black + : dark + ? darkColorScheme.surfaceContainer + : lightColorScheme.inverseSurface; + final foreground = dark + ? darkColorScheme.onSurface + : lightColorScheme.onInverseSurface; + return Positioned( + top: top, + left: Grid.gutter, + right: Grid.gutter, + child: IgnorePointer( + ignoring: onRetry == null || exiting, + child: Align( + alignment: Alignment.topCenter, + child: FractionalTranslation( + translation: Offset( + 0, + -(island == null ? 1 : 0.5) * (1 - progress), + ), + child: Transform.translate( + offset: Offset( + 0, + ((island?.center.dy ?? 0) - top) * (1 - progress), + ), + child: Semantics( + liveRegion: true, + onDismiss: persistent ? null : onDismiss, + // Lay text out once at its full width. Only the capsule's + // painted bounds expand, revealing rather than scaling text. + child: CustomPaint( + painter: island == null || shader == null + ? null + : _ToastIslandSurface( + shader: shader, + reveal: reveal, + pixelRatio: MediaQuery.devicePixelRatioOf(context), + island: island, + top: top, + progress: progress, + ), + child: PhysicalShape( + clipper: _ToastRevealClipper(reveal), + clipBehavior: Clip.antiAlias, + color: island != null && shader != null + ? Colors.transparent + : background, + elevation: island != null && shader != null ? 0 : 4, + shadowColor: Colors.black.withValues(alpha: 0.18), + child: Opacity( + opacity: contentOpacity, + child: ToastCapsuleContent( + message: message, + foreground: foreground, + actionLabel: actionLabel, + onAction: onRetry, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ToastRevealClipper extends CustomClipper { + const _ToastRevealClipper(this.progress); + + final double progress; + + @override + Path getClip(Size size) { + final collapsedWidth = size.height.clamp(0.0, size.width); + final width = collapsedWidth + (size.width - collapsedWidth) * progress; + return RoundedSuperellipseBorder( + borderRadius: BorderRadius.circular(size.height / 2), + ).getOuterPath( + Rect.fromCenter( + center: size.center(Offset.zero), + width: width, + height: size.height, + ), + ); + } + + @override + bool shouldReclip(_ToastRevealClipper oldClipper) => + oldClipper.progress != progress; +} diff --git a/mobile/lib/shared/widgets/buzz_toast_island.dart b/mobile/lib/shared/widgets/buzz_toast_island.dart new file mode 100644 index 0000000000..5eee58f863 --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_toast_island.dart @@ -0,0 +1,103 @@ +part of 'buzz_toast.dart'; + +// Portrait Dynamic Island iPhones expose a top safe area above 50 points. +// Read the live inset so an early cold-start window cannot hide the effect. +Rect? _toastIslandOrigin(BuildContext context) { + final media = MediaQuery.of(context); + if (defaultTargetPlatform != TargetPlatform.iOS || + media.orientation != Orientation.portrait || + media.size.shortestSide >= 600 || + media.viewPadding.top <= 50) { + return null; + } + return Rect.fromLTWH( + (media.size.width - 120) / 2, + 11 + (media.viewPadding.top - 59).clamp(0.0, double.infinity), + 120, + 36, + ); +} + +Future? _islandProgram; + +Future _loadIslandProgram() => + _islandProgram ??= () async { + try { + return await ui.FragmentProgram.fromAsset('shaders/toast_island.frag'); + } catch (error) { + debugPrint('Could not load toast island effect: $error'); + return null; + } + }(); + +// Paint the island and moving capsule together. Text/actions are a separate, +// sharp layer, with no shadow or opaque capsule dividing the merged surface. +class _ToastIslandSurface extends CustomPainter { + const _ToastIslandSurface({ + required this.shader, + required this.island, + required this.top, + required this.progress, + required this.reveal, + required this.pixelRatio, + }); + + final ui.FragmentShader shader; + final Rect island; + final double top; + final double progress; + final double reveal; + final double pixelRatio; + + @override + void paint(Canvas canvas, Size size) { + final bubbleTop = + top + (island.center.dy - top - size.height / 2) * (1 - progress); + final localIsland = Rect.fromCenter( + center: Offset(size.width / 2, island.center.dy - bubbleTop), + width: island.width, + height: island.height, + ); + final t = reveal.clamp(0.0, 1.0); + final travel = progress.clamp(0.0, 1.0); + final toast = Rect.fromCenter( + center: size.center(Offset.zero), + // Start compact even when the message is narrower than the island. + // The stationary island keeps its own full-width outline above. + width: island.height.clamp(0.0, size.width) * (1 - t) + size.width * t, + height: island.height * (1 - t) + size.height * t, + ); + final bounds = localIsland.expandToInclude(toast).inflate(24); + final origin = bounds.topLeft; + final values = [ + localIsland.center.dx - origin.dx, + localIsland.center.dy - origin.dy, + localIsland.width / 2, + localIsland.height / 2, + toast.center.dx - origin.dx, + toast.center.dy - origin.dy, + toast.width / 2, + toast.height / 2, + // Follow travel, not width: the outro narrows early but must keep + // merging until it actually reaches the island. Endpoints stay flat. + 128 * travel * (1 - travel), + 0.75 / pixelRatio, + ]; + for (var i = 0; i < values.length; i++) { + shader.setFloat(i, values[i]); + } + canvas.save(); + canvas.translate(origin.dx, origin.dy); + canvas.drawRect(Offset.zero & bounds.size, Paint()..shader = shader); + canvas.restore(); + } + + @override + bool shouldRepaint(_ToastIslandSurface oldDelegate) => + shader != oldDelegate.shader || + island != oldDelegate.island || + top != oldDelegate.top || + progress != oldDelegate.progress || + reveal != oldDelegate.reveal || + pixelRatio != oldDelegate.pixelRatio; +} diff --git a/mobile/lib/shared/widgets/feedback_snackbar.dart b/mobile/lib/shared/widgets/feedback_snackbar.dart new file mode 100644 index 0000000000..099dddab72 --- /dev/null +++ b/mobile/lib/shared/widgets/feedback_snackbar.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../toast_haptic.dart'; + +export '../toast_haptic.dart' show ToastKind; + +/// Applies the shared feedback pulse to the remaining actionable snackbars. +extension FeedbackSnackBar on ScaffoldMessengerState { + /// Shows the original snackbar, preserving its actions, queue, and lifetime. + ScaffoldFeatureController + showFeedbackSnackBar(SnackBar snackBar, {required ToastKind kind}) { + final controller = showSnackBar(snackBar); + unawaited(toastHaptic(kind)); + return controller; + } +} diff --git a/mobile/lib/shared/widgets/reconnect_capsule.dart b/mobile/lib/shared/widgets/reconnect_capsule.dart new file mode 100644 index 0000000000..62f9ffeda1 --- /dev/null +++ b/mobile/lib/shared/widgets/reconnect_capsule.dart @@ -0,0 +1,35 @@ +part of 'buzz_toast.dart'; + +/// Parent-owned persistent feedback with the same motion as transient toasts. +class ReconnectCapsule extends HookWidget { + const ReconnectCapsule({ + super.key, + required this.onSignIn, + this.visible = true, + this.islandOrigin, + this.islandSpeed = 0.75, + }); + + final VoidCallback onSignIn; + final bool visible; + final Rect? islandOrigin; + final double islandSpeed; + + @override + Widget build(BuildContext context) { + final transientVisible = useValueListenable(_toastCoordinator(context)); + // Remove immediately so neither text nor island surfaces overlap. + // Remounting after dismissal restarts the persistent entrance if needed. + if (transientVisible) return const SizedBox.shrink(); + return _ToastBubble( + message: 'Reconnect to Builderlab', + actionLabel: 'Sign in', + onRetry: onSignIn, + onDismiss: () {}, + persistent: true, + visible: visible, + islandOrigin: islandOrigin, + islandSpeed: islandSpeed, + ); + } +} diff --git a/mobile/lib/shared/widgets/text_editor.dart b/mobile/lib/shared/widgets/text_editor.dart index f27d2d8777..81b32bea26 100644 --- a/mobile/lib/shared/widgets/text_editor.dart +++ b/mobile/lib/shared/widgets/text_editor.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -68,8 +69,9 @@ Future showTextEditor({ } void _showSaveError(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("We couldn't save this change. Try again.")), + ScaffoldMessenger.of(context).showFeedbackSnackBar( + const SnackBar(content: Text('Couldn’t save changes')), + kind: ToastKind.error, ); } @@ -111,7 +113,7 @@ class _TextEditSheet extends HookWidget { Navigator.of(context).pop(); return; } - error.value = "We couldn't save this change. Try again."; + error.value = 'Couldn’t save changes'; } finally { if (context.mounted) isSaving.value = false; } diff --git a/mobile/lib/shared/widgets/toast_capsule_content.dart b/mobile/lib/shared/widgets/toast_capsule_content.dart new file mode 100644 index 0000000000..e9ea24eaad --- /dev/null +++ b/mobile/lib/shared/widgets/toast_capsule_content.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// Shared text and inset action pill for temporary and persistent feedback. +class ToastCapsuleContent extends StatelessWidget { + const ToastCapsuleContent({ + super.key, + required this.message, + required this.foreground, + this.actionLabel = 'Retry', + this.onAction, + }); + + final String message; + final Color foreground; + final String actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final text = Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.twelve, + ), + child: Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ); + if (onAction == null) return text; + return IntrinsicWidth( + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: Center(widthFactor: 1, child: text)), + Padding( + padding: const EdgeInsets.only(top: 6, right: 6, bottom: 6), + child: FilledButton( + onPressed: onAction, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + shape: const RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(Radius.circular(999)), + ), + minimumSize: const Size(48, 36), + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(actionLabel), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f6d0b5fcd5..07b3959521 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -73,6 +73,7 @@ flutter: enable-swift-package-manager: false uses-material-design: true shaders: + - shaders/toast_island.frag - shaders/onboarding_wordmark.frag assets: - assets/images/welcome-bee.svg diff --git a/mobile/shaders/toast_island.frag b/mobile/shaders/toast_island.frag new file mode 100644 index 0000000000..e402b28666 --- /dev/null +++ b/mobile/shaders/toast_island.frag @@ -0,0 +1,32 @@ +#include + +uniform vec2 uIslandCenter; +uniform vec2 uIslandHalfSize; +uniform vec2 uToastCenter; +uniform vec2 uToastHalfSize; +uniform float uBlend; +uniform float uPixel; +out vec4 fragColor; + +float capsule(vec2 p, vec2 center, vec2 halfSize) { + float radius = min(halfSize.x, halfSize.y); + vec2 q = abs(p - center) - halfSize + radius; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; +} + +void main() { + vec2 p = FlutterFragCoord().xy; + float a = capsule(p, uIslandCenter, uIslandHalfSize); + float b = capsule(p, uToastCenter, uToastHalfSize); + // Smooth union: both surfaces deform into a single contour near contact. + // Its finite reach leaves the resting toast and hardware island separate. + float distance = b; + if (uBlend > 0.001) { + float h = max(uBlend - abs(a - b), 0.0) / uBlend; + distance = min(a, b) - h * h * uBlend * 0.25; + } + // At rest only paint the toast. The real island supplies its own exact + // outline; our approximate scanner frame must not enlarge it. + float alpha = 1.0 - smoothstep(-uPixel, uPixel, distance); + fragColor = vec4(0.0, 0.0, 0.0, alpha); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 5014df925d..b11ba1f488 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -8101,7 +8101,12 @@ void main() { await tester.tap(find.byKey(const ValueKey('huddle-minimize'))); await tester.pumpAndSettle(); - expect(hapticCalls, hasLength(4)); + expect( + hapticCalls.where( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + hasLength(4), + ); expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); expect(find.widgetWithText(FilledButton, 'Open'), findsOneWidget); expect( @@ -8162,14 +8167,24 @@ void main() { find.byKey(const ValueKey('huddle-drawer-speaker-toggle')), ); await tester.pump(); - expect(hapticCalls, hasLength(5)); + expect( + hapticCalls.where( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + hasLength(5), + ); expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); await tester.tap( find.byKey(const ValueKey('huddle-drawer-mute-toggle')), ); await tester.pump(); - expect(hapticCalls, hasLength(6)); + expect( + hapticCalls.where( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + hasLength(6), + ); expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); expect( tester @@ -8214,7 +8229,12 @@ void main() { } await tester.tap(find.byKey(const ValueKey('huddle-drawer-expand'))); await tester.pumpAndSettle(); - expect(hapticCalls, hasLength(7)); + expect( + hapticCalls.where( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + hasLength(7), + ); expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); expect(find.byKey(const ValueKey('huddle-minimize')), findsOneWidget); expect( @@ -8230,11 +8250,21 @@ void main() { await tester.tap(find.byKey(const ValueKey('huddle-minimize'))); await tester.pumpAndSettle(); - expect(hapticCalls, hasLength(8)); + expect( + hapticCalls.where( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + hasLength(8), + ); expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); await tester.tap(find.byKey(const ValueKey('huddle-drawer-leave'))); await tester.pump(); - expect(hapticCalls, hasLength(9)); + expect( + hapticCalls.where( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + hasLength(9), + ); expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); expect( tester diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 09f48a5869..9ecc68f73a 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -2049,7 +2049,7 @@ void main() { await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); await tester.pumpAndSettle(); - expect(find.text('Unable to open your photo library.'), findsOneWidget); + expect(find.text('Couldn’t open photo library'), findsOneWidget); } finally { await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); await tester.pumpWidget(const SizedBox.shrink()); @@ -3446,10 +3446,7 @@ void main() { // that state on the very next frame, so the surface must outlive the // switch. expect( - find.widgetWithText( - SnackBar, - 'Message not sent: the community changed', - ), + find.widgetWithText(SnackBar, 'Not sent. Community changed.'), findsOneWidget, ); // Characterization, not a fix: the unsent text is already retained in @@ -4781,10 +4778,7 @@ void main() { await tester.tap(find.byIcon(BuzzIcons.arrowUp)); await tester.pumpAndSettle(); - expect( - find.text('Message not sent: could not check who is in this channel'), - findsNothing, - ); + expect(find.text('Not sent. Couldn’t check members.'), findsNothing); expect(sentRecipients, contains(pat)); expect(sentTags, isNot(contains(orderedEquals(['mention', pat])))); }); @@ -4813,10 +4807,7 @@ void main() { await mentionMaryAndSend(tester); expect(sendCount, 0); - expect( - find.text('Message not sent: could not check who is in this channel'), - findsOneWidget, - ); + expect(find.text('Not sent. Couldn’t check members.'), findsOneWidget); final field = tester.widget(find.byType(TextField)); expect(field.controller!.text, 'hello @Mary Jane'); diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 5c69d38674..e810aa7cab 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -186,10 +186,7 @@ void main() { expect(pending.consumeCalls, 1); if (visibility == 'private') { expect(find.byType(_CapturedDestination), findsNothing); - expect( - find.text('Channel not found in this workspace'), - findsOneWidget, - ); + expect(find.text('Channel not found here'), findsOneWidget); return; } final destination = tester.widget<_CapturedDestination>( @@ -248,7 +245,7 @@ void main() { expect(find.byType(_CapturedDestination), findsNothing); expect(pending.consumeCalls, 0); - expect(find.text('Channel not found in this workspace'), findsNothing); + expect(find.text('Channel not found here'), findsNothing); // Setup closes: the parked link opens without a new link or channel event. dispatch.value = true; @@ -700,12 +697,7 @@ void main() { expect(container.read(pendingDeepLinkProvider), same(link)); expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle); - expect( - find.text( - 'Could not open this invite. Re-open the invite link to try again.', - ), - findsOneWidget, - ); + expect(find.text('Couldn’t open invite. Reopen the link.'), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 31a601eb36..8b0abe1176 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -66,6 +66,45 @@ void main() { ); } + testWidgets('feedback clears floating navigation and wraps large text', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 34); + addTearDown(tester.view.reset); + await tester.pumpWidget(await buildHome()); + await tester.pumpAndSettle(); + final navigation = find.byType(MessagesNavigationBar); + final originalTop = tester.getTopLeft(navigation).dy; + final context = tester.element(find.text('General')); + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar(const SnackBar(content: Text('Copied'))); + await tester.pumpAndSettle(); + expect( + tester.getBottomLeft(find.byType(SnackBar)).dy, + lessThanOrEqualTo(originalTop), + ); + expect(tester.getTopLeft(navigation).dy, originalTop); + messenger.removeCurrentSnackBar(); + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Could not share this image. Please try again in a moment.', + textScaler: TextScaler.linear(2), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.getBottomLeft(find.byType(SnackBar)).dy, + lessThanOrEqualTo(tester.getTopLeft(navigation).dy), + ); + expect(tester.takeException(), isNull); + messenger.removeCurrentSnackBar(); + await tester.pumpAndSettle(); + }); + testWidgets( 'Messages filters its inventory and opens Search and Settings above it', (tester) async { diff --git a/mobile/test/features/invites/invite_create_page_test.dart b/mobile/test/features/invites/invite_create_page_test.dart index b3784cc87d..79b0b06a12 100644 --- a/mobile/test/features/invites/invite_create_page_test.dart +++ b/mobile/test/features/invites/invite_create_page_test.dart @@ -199,11 +199,14 @@ void main() { ); await tester.tap(find.byKey(const Key('community-invite-copy-link'))); await tester.pumpAndSettle(); - expect( - hapticCalls.map((call) => call.arguments), - everyElement('HapticFeedbackType.lightImpact'), - ); - expect(hapticCalls, hasLength(2)); + expect(hapticCalls.map((call) => call.arguments), [ + 'HapticFeedbackType.mediumImpact', + 'HapticFeedbackType.lightImpact', + 'HapticFeedbackType.lightImpact', + 'HapticFeedbackType.mediumImpact', + ]); + // Invite confirmation, Share tap, Copy tap, and Copy confirmation. + expect(hapticCalls, hasLength(4)); }); testWidgets('pasted npub remains inviteable without profile metadata', ( diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index c1f1e317ff..0d57deae77 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -1,3 +1,5 @@ +import 'package:buzz/shared/widgets/buzz_toast.dart'; +import 'package:buzz/features/onboarding/hosted_reconnect_banner.dart'; import 'package:buzz/shared/hosted/hosted_community_creation.dart'; import 'package:buzz/shared/community/community_grid_option.dart'; import 'package:buzz/shared/hosted/account_identity_store.dart'; @@ -309,7 +311,7 @@ void main() { expect(f.sent('/exchange'), isEmpty); expect(f.sent('/current'), isEmpty); expect(f.sent('/nsec-backups/status'), isEmpty); - expect(find.text('Sign in to Builderlab to reconnect'), findsNothing); + expect(find.text('Reconnect to Builderlab'), findsNothing); }, requiresBuilderlab: false, ); @@ -337,30 +339,52 @@ void main() { final session = f.container.read(relaySessionProvider.notifier); await f.settle(tester); if (!HostedEnvironment.requiresAuth(Uri.parse(community.relayUrl).host)) { - expect(find.text('Sign in to Builderlab to reconnect'), findsNothing); + expect(find.text('Reconnect to Builderlab'), findsNothing); expect(f.socketHeaders, isNotEmpty); expect(f.socketHeaders, everyElement(isEmpty)); expect(f.sent('/assertions'), isEmpty); expect(f.container.read(relayConfigProvider).nsec, community.nsec); return; } - expect(find.text('Sign in to Builderlab to reconnect'), findsOneWidget); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); expect(f.socketHeaders, isEmpty); + final feedbackContext = tester.element( + find.byType(HostedReconnectBanner), + ); + showBuzzToast(feedbackContext, 'Copied'); + await f.settle(tester); + expect(find.text('Copied'), findsOneWidget); + expect(find.text('Reconnect to Builderlab'), findsNothing); + expect(find.text('Sign in'), findsNothing); + var retried = false; + showBuzzToast( + feedbackContext, + 'Couldn’t save changes', + onRetry: () { + retried = true; + }, + ); + await f.settle(tester); + expect(find.text('Copied'), findsNothing); + expect(find.text('Reconnect to Builderlab'), findsNothing); + await f.tap(tester, 'Retry'); + expect(retried, isTrue); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); final browser = Completer(); f.beforeBrowserReturn = () => browser.future; await f.tap(tester, 'Sign in'); await f.tap(tester, 'Cancel sign-in'); browser.complete(); await f.settle(tester); - expect(find.text('Sign in to Builderlab to reconnect'), findsOneWidget); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); expect(f.socketHeaders, isEmpty); f.beforeBrowserReturn = null; await f.tap(tester, 'Sign in'); await f.settle(tester); expect(f.socketHeaders, isNotEmpty); - expect(find.text('Sign in to Builderlab to reconnect'), findsNothing); + expect(find.text('Reconnect to Builderlab'), findsNothing); await session.queryRelay(const []); expect(f.sent('/query'), hasLength(1)); final proofRequest = f.sent('/identity/assertions').single; @@ -416,7 +440,7 @@ void main() { throwsA(AccountFailure.signedOut), ); await f.settle(tester); - expect(find.text('Sign in to Builderlab to reconnect'), findsOneWidget); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); final beforeWrongAccount = f.sent('/identity/assertions').length; f.bound = f.secondKeys.public; f.subject = 'wrong-owner'; diff --git a/mobile/test/features/settings/connection_section_test.dart b/mobile/test/features/settings/connection_section_test.dart index fd83e2a855..6a702cc38e 100644 --- a/mobile/test/features/settings/connection_section_test.dart +++ b/mobile/test/features/settings/connection_section_test.dart @@ -17,25 +17,29 @@ import '../../helpers/widget_helpers.dart'; void main() { testWidgets('shows a compact copyable identity row', (tester) async { - var successFeedback = false; + var hapticCount = 0; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('buzz/haptics'), (call) async { expect(call.method, 'success'); - successFeedback = true; + hapticCount++; return null; }, ); - addTearDown(() { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('buzz/haptics'), null, - ); - }); + ), + ); + MethodCall? clipboardCall; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'Clipboard.setData') clipboardCall = call; + if (call.method == 'HapticFeedback.vibrate') { + fail('Success must use native notification feedback on iOS'); + } return null; }); addTearDown( @@ -123,10 +127,10 @@ void main() { await tester.tap(find.text('Copy public key (npub)')); await tester.pump(); - expect(successFeedback, isTrue); + expect(hapticCount, 1); expect(clipboardCall?.method, 'Clipboard.setData'); expect(clipboardCall?.arguments, {'text': expectedNpub}); - expect(find.text('Public key (npub) copied'), findsOneWidget); + expect(find.text('Public key copied'), findsOneWidget); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); testWidgets('an out-of-range identity shows as unavailable', (tester) async { @@ -237,10 +241,7 @@ void main() { expect(pairing.resetCalls, 1); expect(find.text('Identity recovery'), findsNothing); - expect( - find.text('Buzz did not return to the foreground. Try again.'), - findsOneWidget, - ); + expect(find.text('Couldn’t resume Buzz. Try again.'), findsOneWidget); }); testWidgets('clears authorization when disposed during authentication', ( diff --git a/mobile/test/features/settings/settings_overlay_test.dart b/mobile/test/features/settings/settings_overlay_test.dart index 20749f42d9..3c7b02ce54 100644 --- a/mobile/test/features/settings/settings_overlay_test.dart +++ b/mobile/test/features/settings/settings_overlay_test.dart @@ -211,23 +211,23 @@ void main() { await tester.tap(find.text('Copy public key (npub)')); await tester.pumpAndSettle(); expect(copied, startsWith('npub1')); - final snackbar = find.byType(SnackBar); - expect(snackbar, findsOneWidget); + expect(find.byType(SnackBar), findsNothing); final settings = find.byType(SettingsPage); - expect( - find.descendant(of: settings, matching: snackbar), - findsOneWidget, - ); - final message = find.text('Public key (npub) copied'); - expect(message.hitTestable(), findsOneWidget); + final message = find.text('Public key copied'); + expect(message, findsOneWidget); + // The shared toast is above the route, rather than inside its Scaffold. + expect(find.descendant(of: settings, matching: message), findsNothing); final toastSurface = find - .ancestor(of: message, matching: find.byType(Material)) + .ancestor(of: message, matching: find.byType(PhysicalShape)) .first; - expect(tester.getRect(toastSurface).bottom, lessThanOrEqualTo(810)); + expect(tester.getRect(toastSurface).top, greaterThanOrEqualTo(0)); + expect(tester.getRect(toastSurface).bottom, lessThan(160)); await _capture(tester, '${platform.name}-copied'); Navigator.of(tester.element(settings)).pop(); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNothing); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + expect(message, findsNothing); expect(tester.takeException(), isNull); debugDefaultTargetPlatformOverride = null; }, diff --git a/mobile/test/shared/widgets/buzz_toast_test.dart b/mobile/test/shared/widgets/buzz_toast_test.dart new file mode 100644 index 0000000000..833c463251 --- /dev/null +++ b/mobile/test/shared/widgets/buzz_toast_test.dart @@ -0,0 +1,401 @@ +import 'dart:ui' show SemanticsAction; +import 'package:buzz/shared/clipboard_utils.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/buzz_toast.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'iPhone toast automatically expands from the island and returns', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = FakeViewPadding(top: 59); + tester.view.viewPadding = FakeViewPadding(top: 59); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + const origin = Rect.fromLTWH(135, 11, 120, 36); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showBuzzToast(context, 'Island preview'), + child: const Text('Show'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Show')); + await tester.pump(); + final surface = find.ancestor( + of: find.text('Island preview'), + matching: find.byType(PhysicalShape), + ); + // Shader readiness may span frames. No fallback circle is painted while + // waiting, and animation time does not advance before the surface exists. + for (var frame = 0; frame < 20 && surface.evaluate().isEmpty; frame++) { + expect(find.text('Island preview'), findsNothing); + await tester.pump(); + } + expect(tester.widget(surface).color, Colors.transparent); + expect(tester.getRect(surface).center.dy, closeTo(origin.center.dy, 1)); + await tester.pump(const Duration(milliseconds: 140)); + expect(tester.getRect(surface).center.dy, greaterThan(origin.center.dy)); + await tester.pumpAndSettle(); + // The shader owns the unified background; no opaque second capsule or + // independent shadow may divide its silhouette. + expect(tester.widget(surface).color, Colors.transparent); + expect(tester.widget(surface).elevation, 0); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(milliseconds: 120)); + expect(tester.getRect(surface).top, lessThan(67)); + await tester.pumpAndSettle(); + expect(find.text('Island preview'), findsNothing); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('Retry capsule stays inset and invokes the action once', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final semantics = tester.ensureSemantics(); + var retries = 0; + for (final brightness in Brightness.values) { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData( + platformBrightness: brightness, + accessibleNavigation: true, + disableAnimations: true, + textScaler: TextScaler.linear(2), + ), + child: Scaffold( + body: Builder( + builder: (context) => Stack( + children: [ + ReconnectCapsule(onSignIn: () {}), + Center( + child: TextButton( + onPressed: () => showBuzzToast( + context, + 'Couldn’t save changes', + kind: ToastKind.error, + onRetry: () => retries++, + ), + child: const Text('Show error'), + ), + ), + ], + ), + ), + ), + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + platformBrightness: brightness, + accessibleNavigation: true, + disableAnimations: true, + textScaler: TextScaler.linear(2), + ), + child: child!, + ), + ), + ); + await tester.tap(find.text('Show error')); + await tester.pumpAndSettle(); + final button = find.widgetWithText(FilledButton, 'Retry'); + final shape = find + .ancestor(of: button, matching: find.byType(PhysicalShape)) + .first; + final outer = tester.getRect(shape); + final inner = tester.getRect(button); + expect(inner.top - outer.top, 6); + expect(outer.right - inner.right, 6); + expect(outer.bottom - inner.bottom, 6); + final style = tester.widget(button).style!; + expect(style.backgroundColor!.resolve({}), Colors.white); + expect(style.foregroundColor!.resolve({}), Colors.black); + await tester.pump(const Duration(seconds: 20)); + expect(button, findsOneWidget); + await tester.tap(button); + await tester.pump(); + expect(button, findsNothing); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); + await tester.tap(find.text('Show error')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 20)); + expect(find.text('Reconnect to Builderlab'), findsNothing); + final dismissible = find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.onDismiss != null, + ); + final node = tester.getSemantics(dismissible); + node.owner!.performAction(node.id, SemanticsAction.dismiss); + await tester.pumpAndSettle(); + expect(button, findsNothing); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); + expect(tester.takeException(), isNull); + } + expect(retries, 2); + semantics.dispose(); + }); + + testWidgets( + 'toast kinds request matching platform feedback', + (tester) async { + final nativeCalls = []; + final impactCalls = []; + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(const MethodChannel('buzz/haptics'), ( + call, + ) async { + nativeCalls.add(call.method); + return null; + }); + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + impactCalls.add(call.arguments as String); + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler( + const MethodChannel('buzz/haptics'), + null, + ); + messenger.setMockMethodCallHandler(SystemChannels.platform, null); + }); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Column( + children: [ + for (final kind in ToastKind.values) + TextButton( + onPressed: () => + showBuzzToast(context, kind.name, kind: kind), + child: Text('Show ${kind.name}'), + ), + ], + ); + }, + ), + ), + ), + ); + for (final kind in ToastKind.values) { + await tester.tap(find.text('Show ${kind.name}')); + await tester.pumpAndSettle(); + expect(find.text(kind.name), findsOneWidget); + } + if (Theme.of(tester.element(find.byType(Scaffold))).platform == + TargetPlatform.iOS) { + expect(nativeCalls, ['success', 'error']); + expect(impactCalls, ['HapticFeedbackType.lightImpact']); + } else { + expect(nativeCalls, isEmpty); + expect(impactCalls, [ + 'HapticFeedbackType.lightImpact', + 'HapticFeedbackType.mediumImpact', + 'HapticFeedbackType.heavyImpact', + ]); + } + await tester.pumpWidget(const SizedBox.shrink()); + }, + variant: TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + + testWidgets('copy feedback floats above sheets, replaces, and exits upward', ( + tester, + ) async { + var hapticCount = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') { + expect(call.arguments, 'HapticFeedbackType.mediumImpact'); + hapticCount++; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(top: 59, bottom: 34); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) { + return Stack( + children: [ + ReconnectCapsule(onSignIn: () {}), + Center( + child: TextButton( + onPressed: () => showModalBottomSheet( + context: context, + builder: (context) => SizedBox( + height: 200, + child: TextButton( + onPressed: () => + copyToClipboard(context, 'example'), + child: const Text('Copy'), + ), + ), + ), + child: const Text('Open sheet'), + ), + ), + ], + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Copy')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 140)); + expect(hapticCount, 1); + expect(find.text('Reconnect to Builderlab'), findsNothing); + final enteringY = tester.getTopLeft(find.text('Copied')).dy; + final textWidth = tester.getSize(find.text('Copied')).width; + final shapeFinder = find + .ancestor(of: find.text('Copied'), matching: find.byType(PhysicalShape)) + .first; + final shape = tester.widget(shapeFinder); + final shapeSize = tester.getSize(shapeFinder); + final revealingWidth = shape.clipper.getClip(shapeSize).getBounds().width; + expect(revealingWidth, lessThan(shapeSize.width)); + + await tester.pump(const Duration(milliseconds: 160)); + final settledY = tester.getTopLeft(find.text('Copied')).dy; + expect((settledY - enteringY).abs(), lessThan(5)); + expect(tester.getSize(find.text('Copied')).width, textWidth); + expect( + tester + .widget(shapeFinder) + .clipper + .getClip(shapeSize) + .getBounds() + .width, + shapeSize.width, + ); + + expect(settledY, greaterThan(59)); + expect(settledY, lessThan(120)); + expect(tester.getSize(find.text('Copied')).width, lessThan(350)); + expect(find.byType(SnackBar), findsNothing); + final toastMaterial = find + .ancestor(of: find.text('Copied'), matching: find.byType(PhysicalShape)) + .first; + expect( + tester.widget(toastMaterial).color, + lightColorScheme.inverseSurface, + ); + + // Feedback remains above the sheet, and a new copy restarts its lifetime. + await tester.pump(const Duration(seconds: 2)); + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + expect(find.text('Copied'), findsOneWidget); + expect(hapticCount, 2); + expect(find.text('Reconnect to Builderlab'), findsNothing); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Copied'), findsOneWidget); + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 40)); + final exitingShape = tester.widget(shapeFinder); + expect( + exitingShape.clipper.getClip(shapeSize).getBounds().width, + lessThan(shapeSize.width * 0.9), + ); + final opacity = tester.widget( + find + .ancestor(of: find.text('Copied'), matching: find.byType(Opacity)) + .first, + ); + expect(opacity.opacity, lessThan(0.8)); + expect(tester.getSize(find.text('Copied')).width, textWidth); + expect(tester.getTopLeft(find.text('Copied')).dy, lessThan(settledY)); + await tester.pump(const Duration(milliseconds: 220)); + expect(find.text('Copied'), findsNothing); + await tester.pumpAndSettle(); + expect(find.text('Reconnect to Builderlab'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'system dark mode, reduced motion, and large text are supported', + (tester) async { + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: true, + textScaler: const TextScaler.linear(2), + ), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showBuzzToast(context, 'Channel name copied'), + child: const Text('Copy'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Copy')); + await tester.pump(); + final text = find.text('Channel name copied'); + final y = tester.getTopLeft(text).dy; + await tester.pump(const Duration(milliseconds: 150)); + expect(tester.getTopLeft(text).dy, y); + final material = find + .ancestor(of: text, matching: find.byType(PhysicalShape)) + .first; + expect( + tester.widget(material).color, + darkColorScheme.surfaceContainer, + ); + expect(tester.takeException(), isNull); + await tester.pump(const Duration(seconds: 3)); + expect(text, findsNothing); + // Removing the app while a toast is alive must cancel its timer. + await tester.tap(find.text('Copy')); + await tester.pump(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 4)); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/mobile/test/shared/widgets/toast_island_shader_test.dart b/mobile/test/shared/widgets/toast_island_shader_test.dart new file mode 100644 index 0000000000..1eb0b27f6b --- /dev/null +++ b/mobile/test/shared/widgets/toast_island_shader_test.dart @@ -0,0 +1,39 @@ +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('settled island shader paints only the exact toast capsule', () async { + final program = await ui.FragmentProgram.fromAsset( + 'shaders/toast_island.frag', + ); + final shader = program.fragmentShader(); + addTearDown(shader.dispose); + // Island bottom 48, toast top 68: the resting 20-point separation. + final uniforms = [100, 30, 60, 18, 100, 98, 90, 30, 0, 0.25]; + for (var i = 0; i < uniforms.length; i++) { + shader.setFloat(i, uniforms[i]); + } + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.drawRect( + const ui.Rect.fromLTWH(0, 0, 200, 160), + ui.Paint()..shader = shader, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(200, 160); + final pixels = (await image.toByteData())!; + int alpha(int x, int y) => pixels.getUint8((y * 200 + x) * 4 + 3); + // No synthetic hardware island, and no hump above the flat toast top. + expect(alpha(100, 30), 0); + for (var x = 50; x <= 150; x += 10) { + expect(alpha(x, 66), 0); + expect(alpha(x, 70), 255); + } + expect(alpha(100, 98), 255); + image.dispose(); + picture.dispose(); + }); +}