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:
klopez4212
2026-07-28 19:12:54 +01:00
committed by GitHub
parent 00ede2e7aa
commit a77212875a
30 changed files with 582 additions and 89 deletions
+6 -1
View File
@@ -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'),
),
+7 -1
View File
@@ -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);
});
}