mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Unify mobile loading spinners (#3314)
## What - add the shared desktop-style arc spinner for mobile - replace app loading indicators with the shared component - preserve a static pose when reduced motion is enabled ## Stack - follows #3313 ## Validation - `just mobile-check` - focused spinner and pairing widget tests --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
+6
-1
@@ -16,6 +16,7 @@ import 'shared/auth/auth.dart';
|
||||
import 'shared/deeplink/pending_deep_link_provider.dart';
|
||||
import 'shared/relay/relay.dart';
|
||||
import 'shared/theme/theme.dart';
|
||||
import 'shared/widgets/buzz_loading_indicator.dart';
|
||||
|
||||
class App extends HookConsumerWidget {
|
||||
const App({super.key});
|
||||
@@ -113,6 +114,10 @@ class _SplashScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: BuzzLoadingIndicator(size: 56, semanticLabel: 'Starting Buzz'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
|
||||
@@ -17,7 +17,12 @@ class _RemindersList extends ConsumerWidget {
|
||||
];
|
||||
|
||||
if (remindersAsync.isLoading && reminders.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: 'Loading reminders',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (reminders.isEmpty) {
|
||||
return const _EmptySurface(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../shared/theme/theme.dart';
|
||||
import '../../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../date_formatters.dart';
|
||||
import 'observer_models.dart';
|
||||
@@ -189,13 +190,10 @@ class _EmptyState extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
BuzzLoadingIndicator(
|
||||
size: 28,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
semanticLabel: 'Waiting for agent activity',
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
|
||||
@@ -264,10 +264,9 @@ class _MessageList extends HookConsumerWidget {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: Grid.xs),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 24,
|
||||
semanticLabel: 'Loading older messages',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../shared/community/community_icon_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/skeleton.dart';
|
||||
|
||||
@@ -66,7 +66,12 @@ class _CommunitySwitcherSheet extends HookConsumerWidget {
|
||||
child: communitiesAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 120,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 40,
|
||||
semanticLabel: 'Loading communities',
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
|
||||
@@ -642,10 +642,11 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
|
||||
),
|
||||
child: SizedBox.square(
|
||||
dimension: 16,
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 16,
|
||||
semanticLabel:
|
||||
'Creating conversation',
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -686,7 +687,12 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
|
||||
isSearchTransitionPending) {
|
||||
return const SizedBox(
|
||||
height: 280,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: 'Loading people',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (directoryAsync.hasError) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:nostr/nostr.dart' as nostr;
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
|
||||
@@ -430,12 +430,10 @@ class _AttachmentStrip extends StatelessWidget {
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 34,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
BuzzLoadingIndicator(
|
||||
size: 34,
|
||||
color: context.colors.primary,
|
||||
semanticLabel: label,
|
||||
),
|
||||
if (uploadingCount > 1)
|
||||
PositionedDirectional(
|
||||
|
||||
@@ -198,9 +198,10 @@ class _CameraPlaceholder extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: isInitializing
|
||||
? const CircularProgressIndicator(
|
||||
? const BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
semanticLabel: 'Starting camera',
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -25,10 +25,13 @@ class _IOSAttachmentPopoverCallbacks {
|
||||
}
|
||||
|
||||
class _IOSAttachmentPopoverCoordinator {
|
||||
static final Object _cancelledSupportCheck = Object();
|
||||
|
||||
final MethodChannel _channel;
|
||||
|
||||
Object? _activeOwner;
|
||||
_IOSAttachmentPopoverCallbacks? _callbacks;
|
||||
Completer<void>? _pendingSupportCancellation;
|
||||
bool _didPresent = false;
|
||||
bool _handlerInstalled = false;
|
||||
|
||||
@@ -44,9 +47,15 @@ class _IOSAttachmentPopoverCoordinator {
|
||||
required VoidCallback onFiles,
|
||||
}) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
|
||||
if (_activeOwner != null) return true;
|
||||
if (_activeOwner case final activeOwner?) {
|
||||
if (identical(activeOwner, owner)) return true;
|
||||
if (!_didPresent) _clearOwner(activeOwner);
|
||||
return _didPresent;
|
||||
}
|
||||
|
||||
final supportCancellation = Completer<void>();
|
||||
_activeOwner = owner;
|
||||
_pendingSupportCancellation = supportCancellation;
|
||||
_callbacks = _IOSAttachmentPopoverCallbacks(
|
||||
onCapture: onCapture,
|
||||
onChoosePhotos: onChoosePhotos,
|
||||
@@ -57,9 +66,16 @@ class _IOSAttachmentPopoverCoordinator {
|
||||
_ensureHandler();
|
||||
|
||||
try {
|
||||
final supported =
|
||||
await _channel.invokeMethod<bool>('isSupported') ?? false;
|
||||
final supportResult = await Future.any<Object?>([
|
||||
_channel.invokeMethod<bool>('isSupported'),
|
||||
supportCancellation.future.then<Object?>((_) => _cancelledSupportCheck),
|
||||
]);
|
||||
if (identical(supportResult, _cancelledSupportCheck)) return true;
|
||||
if (identical(_pendingSupportCancellation, supportCancellation)) {
|
||||
_pendingSupportCancellation = null;
|
||||
}
|
||||
if (!identical(_activeOwner, owner)) return false;
|
||||
final supported = supportResult == true;
|
||||
if (!supported || !sourceContext.mounted) {
|
||||
_clearOwner(owner);
|
||||
return false;
|
||||
@@ -102,6 +118,10 @@ class _IOSAttachmentPopoverCoordinator {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('dismiss');
|
||||
} on PlatformException {
|
||||
// The native bridge is unavailable, so there is nothing left to dismiss.
|
||||
} on MissingPluginException {
|
||||
// The native bridge is unavailable, so there is nothing left to dismiss.
|
||||
} finally {
|
||||
_clearOwner(owner);
|
||||
}
|
||||
}
|
||||
@@ -146,6 +166,11 @@ class _IOSAttachmentPopoverCoordinator {
|
||||
|
||||
void _clearOwner(Object owner) {
|
||||
if (!identical(_activeOwner, owner)) return;
|
||||
final supportCancellation = _pendingSupportCancellation;
|
||||
_pendingSupportCancellation = null;
|
||||
if (supportCancellation != null && !supportCancellation.isCompleted) {
|
||||
supportCancellation.complete();
|
||||
}
|
||||
_activeOwner = null;
|
||||
_callbacks = null;
|
||||
_didPresent = false;
|
||||
|
||||
@@ -183,9 +183,10 @@ class _IOSInlinePhotoPicker extends HookWidget {
|
||||
child: isPreparingSelection.value
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
semanticLabel: 'Preparing selected photos',
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
@@ -201,9 +202,10 @@ class _IOSInlinePhotoPicker extends HookWidget {
|
||||
const ColoredBox(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.28),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
color: Colors.white,
|
||||
semanticLabel: 'Preparing selected photos',
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -222,7 +224,9 @@ class _NativePhotoPickerLoading extends StatelessWidget {
|
||||
key: const ValueKey('ios-inline-photo-picker-loading'),
|
||||
height: _attachmentExpandedHeight,
|
||||
width: double.infinity,
|
||||
child: const Center(child: CircularProgressIndicator(strokeWidth: 3)),
|
||||
child: const Center(
|
||||
child: BuzzLoadingIndicator(size: 44, semanticLabel: 'Opening Photos'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,12 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
|
||||
Widget buildGalleryBody() {
|
||||
if (recentSnapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator(strokeWidth: 3));
|
||||
return const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: 'Loading recent photos',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (recentSnapshot.hasError) {
|
||||
return const _PhotoGalleryMessage(
|
||||
@@ -209,12 +214,10 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
key: const ValueKey('photo-gallery-action'),
|
||||
onPressed: isResolving.value ? null : choosePhotos,
|
||||
icon: isResolving.value
|
||||
? SizedBox.square(
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
? BuzzLoadingIndicator(
|
||||
size: 22,
|
||||
color: context.colors.primary,
|
||||
semanticLabel: 'Opening all photos',
|
||||
)
|
||||
: const Icon(LucideIcons.images, size: 18),
|
||||
label: Text(actionLabel),
|
||||
@@ -223,12 +226,10 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
key: const ValueKey('photo-gallery-action'),
|
||||
onPressed: isResolving.value ? null : choosePhotos,
|
||||
icon: isResolving.value
|
||||
? const SizedBox.square(
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
? const BuzzLoadingIndicator(
|
||||
size: 22,
|
||||
color: Colors.white,
|
||||
semanticLabel: 'Preparing selected photos',
|
||||
)
|
||||
: const Icon(LucideIcons.plus, size: 18),
|
||||
label: Text(actionLabel),
|
||||
|
||||
@@ -27,13 +27,10 @@ class _SendButton extends StatelessWidget {
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: isSending
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.onPrimary,
|
||||
),
|
||||
? BuzzLoadingIndicator(
|
||||
size: 18,
|
||||
color: context.colors.onPrimary,
|
||||
semanticLabel: 'Sending message',
|
||||
)
|
||||
: Icon(
|
||||
LucideIcons.arrowUp,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channel_mutes/channel_mutes_provider.dart';
|
||||
@@ -272,7 +273,12 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 40,
|
||||
semanticLabel: 'Loading channel details',
|
||||
),
|
||||
),
|
||||
error: (error, _) => Text(
|
||||
error.toString(),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import 'media_viewer_hero.dart';
|
||||
|
||||
export 'media_viewer_hero.dart';
|
||||
@@ -790,9 +791,10 @@ class _VideoLoadingPoster extends StatelessWidget {
|
||||
_videoPlaceholder(context),
|
||||
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)),
|
||||
const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
color: Colors.white,
|
||||
semanticLabel: 'Loading video',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../profile/user_status.dart';
|
||||
@@ -150,7 +151,12 @@ class MembersSheet extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: 'Loading members',
|
||||
),
|
||||
),
|
||||
error: (error, _) => Center(
|
||||
child: Text(
|
||||
error.toString(),
|
||||
@@ -240,13 +246,10 @@ class _MemberTile extends ConsumerWidget {
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: context.appColors.success,
|
||||
),
|
||||
BuzzLoadingIndicator(
|
||||
size: 14,
|
||||
color: context.appColors.success,
|
||||
semanticLabel: 'Agent working',
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../channels/channel.dart';
|
||||
import '../channels/compose_bar.dart';
|
||||
@@ -61,7 +62,12 @@ class ForumPostsView extends HookConsumerWidget {
|
||||
body: postsAsync.when(
|
||||
loading: () => Padding(
|
||||
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
child: const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: 'Loading posts',
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../channels/compose_bar.dart';
|
||||
@@ -77,7 +78,12 @@ class ForumThreadPage extends HookConsumerWidget {
|
||||
body: threadAsync.when(
|
||||
loading: () => Padding(
|
||||
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
child: const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: 'Loading thread',
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../pairing/pairing_page.dart';
|
||||
import 'invite_join_provider.dart';
|
||||
|
||||
@@ -113,10 +114,13 @@ class InviteJoinSheet extends ConsumerWidget {
|
||||
.read(inviteJoinProvider.notifier)
|
||||
.confirmJoin(),
|
||||
icon: isClaiming
|
||||
? const SizedBox(
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 16,
|
||||
semanticLabel: 'Joining community',
|
||||
),
|
||||
)
|
||||
: const Icon(LucideIcons.check),
|
||||
label: Text(isClaiming ? 'Joining…' : 'Join'),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/tappable_flapping_bee.dart';
|
||||
import 'pairing_provider.dart';
|
||||
import 'pairing_qr_scanner.dart';
|
||||
@@ -255,13 +256,10 @@ class _SasVerificationView extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
BuzzLoadingIndicator(
|
||||
size: 24,
|
||||
color: context.colors.primary,
|
||||
semanticLabel: 'Connecting',
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Text(
|
||||
|
||||
@@ -87,9 +87,10 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
color: _onboardingCtaLabel,
|
||||
semanticLabel: 'Opening scanner',
|
||||
),
|
||||
)
|
||||
: const Text('Scan a QR code'),
|
||||
@@ -173,12 +174,11 @@ class _PairingWelcomeView extends StatelessWidget {
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color:
|
||||
_onboardingCtaLabel,
|
||||
),
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
color: _onboardingCtaLabel,
|
||||
semanticLabel: 'Connecting',
|
||||
),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../channels/message_content.dart';
|
||||
@@ -76,10 +77,15 @@ class ComposeNotePage extends HookConsumerWidget {
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
child: isSending.value
|
||||
? const SizedBox(
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 16,
|
||||
semanticLabel: _isReply
|
||||
? 'Sending reply'
|
||||
: 'Publishing post',
|
||||
),
|
||||
)
|
||||
: Text(_isReply ? 'Reply' : 'Post'),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/filter_chip_bar.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
@@ -365,7 +366,12 @@ class _SearchBody extends ConsumerWidget {
|
||||
if (state.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(Grid.sm),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 36,
|
||||
semanticLabel: 'Loading more search results',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:math' show pi;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../theme/theme.dart';
|
||||
|
||||
/// The shared mobile loading indicator, matching the desktop arc spinner.
|
||||
class BuzzLoadingIndicator extends HookConsumerWidget {
|
||||
/// The spinner diameter.
|
||||
final double size;
|
||||
|
||||
/// An optional spinner color. Defaults to the active accent color.
|
||||
final Color? color;
|
||||
|
||||
/// The accessibility announcement for this loading state.
|
||||
final String semanticLabel;
|
||||
|
||||
/// Creates a looping arc loading indicator.
|
||||
const BuzzLoadingIndicator({
|
||||
this.size = 40,
|
||||
this.color,
|
||||
this.semanticLabel = 'Loading',
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final animation = useAnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (reducedMotion) {
|
||||
animation
|
||||
..stop()
|
||||
..value = 0;
|
||||
} else {
|
||||
animation.repeat();
|
||||
}
|
||||
return animation.stop;
|
||||
}, [animation, reducedMotion]);
|
||||
|
||||
final spinnerColor = color ?? context.colors.primary;
|
||||
final strokeWidth = (size / 6).clamp(2.0, 4.0);
|
||||
|
||||
return Semantics(
|
||||
liveRegion: true,
|
||||
label: semanticLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: RotationTransition(
|
||||
key: const ValueKey('buzz-loading-indicator-spinner'),
|
||||
turns: animation,
|
||||
child: CustomPaint(
|
||||
size: Size.square(size),
|
||||
painter: _ArcSpinnerPainter(
|
||||
color: spinnerColor,
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArcSpinnerPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
|
||||
const _ArcSpinnerPainter({required this.color, required this.strokeWidth});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final radius = (size.shortestSide - strokeWidth) / 2;
|
||||
final bounds = Rect.fromCircle(center: center, radius: radius);
|
||||
final trackPaint = Paint()
|
||||
..color = color.withValues(alpha: color.a * 0.1)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
final arcPaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
|
||||
canvas
|
||||
..drawCircle(center, radius, trackPaint)
|
||||
..drawArc(bounds, -pi / 2, pi / 2, false, arcPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ArcSpinnerPainter oldDelegate) {
|
||||
return color != oldDelegate.color || strokeWidth != oldDelegate.strokeWidth;
|
||||
}
|
||||
}
|
||||
@@ -610,6 +610,265 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a pending native popover does not claim another composer tap',
|
||||
(tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final supportResult = Completer<bool>();
|
||||
var supportCalls = 0;
|
||||
var presentCalls = 0;
|
||||
_setMockNativeAttachmentPopoverHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'isSupported':
|
||||
supportCalls += 1;
|
||||
return supportResult.future;
|
||||
case 'present':
|
||||
presentCalls += 1;
|
||||
return true;
|
||||
case 'dismiss':
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
pickGalleryImage: () async => null,
|
||||
pickGalleryImages: () async => const [],
|
||||
pickGalleryVideo: () async => null,
|
||||
);
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildNativePopoverOwnershipHarness(
|
||||
uploadService: uploadService,
|
||||
includeFirstComposer: true,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byTooltip('Add attachment').hitTestable().at(0),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(
|
||||
find.byTooltip('Add attachment').hitTestable().at(1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(supportCalls, 1);
|
||||
expect(presentCalls, 0);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('first-composer')),
|
||||
matching: find.byTooltip('Close attachments'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('second-composer')),
|
||||
matching: find.byTooltip('Close attachments'),
|
||||
),
|
||||
findsWidgets,
|
||||
);
|
||||
|
||||
supportResult.complete(true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentCalls, 0);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('first-composer')),
|
||||
matching: find.byTooltip('Close attachments'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
} finally {
|
||||
if (!supportResult.isCompleted) supportResult.complete(false);
|
||||
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
_setMockNativeAttachmentPopoverHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a repeated owner tap keeps its pending native presentation', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final supportResult = Completer<bool>();
|
||||
var supportCalls = 0;
|
||||
var presentCalls = 0;
|
||||
_setMockNativeAttachmentPopoverHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'isSupported':
|
||||
supportCalls += 1;
|
||||
return supportResult.future;
|
||||
case 'present':
|
||||
presentCalls += 1;
|
||||
return true;
|
||||
case 'dismiss':
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
pickGalleryImage: () async => null,
|
||||
pickGalleryImages: () async => const [],
|
||||
pickGalleryVideo: () async => null,
|
||||
);
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildNativePopoverOwnershipHarness(
|
||||
uploadService: uploadService,
|
||||
includeFirstComposer: false,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable());
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(supportCalls, 1);
|
||||
expect(presentCalls, 0);
|
||||
|
||||
supportResult.complete(true);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(presentCalls, 1);
|
||||
expect(find.byTooltip('Close attachments'), findsNothing);
|
||||
} finally {
|
||||
if (!supportResult.isCompleted) supportResult.complete(false);
|
||||
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
_setMockNativeAttachmentPopoverHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('disposing the native popover owner releases ownership', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
var presentCalls = 0;
|
||||
var dismissCalls = 0;
|
||||
_setMockNativeAttachmentPopoverHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'isSupported':
|
||||
return true;
|
||||
case 'present':
|
||||
presentCalls += 1;
|
||||
return true;
|
||||
case 'dismiss':
|
||||
dismissCalls += 1;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
pickGalleryImage: () async => null,
|
||||
pickGalleryImages: () async => const [],
|
||||
pickGalleryVideo: () async => null,
|
||||
);
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildNativePopoverOwnershipHarness(
|
||||
uploadService: uploadService,
|
||||
includeFirstComposer: true,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable().at(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentCalls, 1);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildNativePopoverOwnershipHarness(
|
||||
uploadService: uploadService,
|
||||
includeFirstComposer: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissCalls, 1);
|
||||
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentCalls, 2);
|
||||
} finally {
|
||||
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
_setMockNativeAttachmentPopoverHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('missing native dismiss bridge still releases ownership', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
var presentCalls = 0;
|
||||
_setMockNativeAttachmentPopoverHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'isSupported':
|
||||
return true;
|
||||
case 'present':
|
||||
presentCalls += 1;
|
||||
return true;
|
||||
case 'dismiss':
|
||||
throw MissingPluginException('dismiss is unavailable');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
pickGalleryImage: () async => null,
|
||||
pickGalleryImages: () async => const [],
|
||||
pickGalleryVideo: () async => null,
|
||||
);
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
_buildNativePopoverOwnershipHarness(
|
||||
uploadService: uploadService,
|
||||
includeFirstComposer: true,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable().at(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentCalls, 1);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildNativePopoverOwnershipHarness(
|
||||
uploadService: uploadService,
|
||||
includeFirstComposer: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('Add attachment').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentCalls, 2);
|
||||
} finally {
|
||||
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
_setMockNativeAttachmentPopoverHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('uploads an image and sends markdown plus imeta tags', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/pairing/pairing_page.dart';
|
||||
import 'package:buzz/features/pairing/pairing_provider.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
|
||||
import 'package:buzz/shared/widgets/tappable_flapping_bee.dart';
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
@@ -154,7 +155,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
|
||||
// Connect text should be replaced by spinner.
|
||||
expect(find.text('Connect'), findsNothing);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
|
||||
|
||||
Widget _testable({required bool disableAnimations}) {
|
||||
return ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData().copyWith(
|
||||
disableAnimations: disableAnimations,
|
||||
),
|
||||
child: const Scaffold(
|
||||
body: BuzzLoadingIndicator(semanticLabel: 'Loading photos'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('animates the shared arc spinner', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
|
||||
await tester.pumpWidget(_testable(disableAnimations: false));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
|
||||
final spinner = tester.widget<RotationTransition>(
|
||||
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
|
||||
);
|
||||
expect(spinner.turns.value, greaterThan(0));
|
||||
expect(find.bySemanticsLabel('Loading photos'), findsOneWidget);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('holds a static pose when reduced motion is enabled', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_testable(disableAnimations: true));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
|
||||
final spinner = tester.widget<RotationTransition>(
|
||||
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
|
||||
);
|
||||
expect(spinner.turns.value, 0);
|
||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user