diff --git a/lib/features/compose/presentation/widgets/compose_fab.dart b/lib/features/compose/presentation/widgets/compose_fab.dart new file mode 100644 index 0000000..2abffd3 --- /dev/null +++ b/lib/features/compose/presentation/widgets/compose_fab.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; + +class ComposeFab extends StatelessWidget { + const ComposeFab({Key? key, required this.heroTag, required this.tooltip, required this.onPressed, this.shape}) + : _fabKey = key, + super(key: null); + + final Key? _fabKey; + final Object heroTag; + final String tooltip; + final VoidCallback? onPressed; + final ShapeBorder? shape; + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + key: _fabKey, + heroTag: heroTag, + tooltip: tooltip, + onPressed: onPressed, + shape: shape, + child: const Icon(Icons.add), + ).animateIfAllowed( + context, + effects: const [ + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), + ], + ); + } +} diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index cf60f63..24698cd 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -2,14 +2,12 @@ import 'package:poptart_core/poptart_core.dart'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/l10n/l10n.dart'; -import 'package:lazurite/core/theme/animation_tokens.dart'; -import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/compose/presentation/widgets/compose_fab.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; @@ -173,24 +171,16 @@ class _HomeFeedScreenState extends State { child: const Icon(Icons.arrow_upward, size: 18), ); - final composeButton = - FloatingActionButton( - heroTag: 'home-compose-fab', - tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', - onPressed: isOffline ? null : () => context.push('/compose'), - shape: const CircleBorder(), - child: const Icon(Icons.add), - ).animateIfAllowed( - context, - effects: const [ - FadeEffect(duration: Anim.feedItem, curve: Anim.enter), - ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), - ], - ); + final composeButton = ComposeFab( + heroTag: 'home-compose-fab', + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', + onPressed: isOffline ? null : () => context.push('/compose'), + shape: const CircleBorder(), + ); return Row( mainAxisSize: MainAxisSize.max, - children: [const SizedBox(width: 24), jumpToTopButton, const Spacer(), composeButton], + children: [const SizedBox(width: 24), jumpToTopButton, const Spacer(), composeButton, const SizedBox(width: 24)], ); } diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index c2ec9f9..9055209 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -4,7 +4,6 @@ import 'package:bluesky_poptart/app/bsky/graph/defs.dart' as bsky_graph; import 'package:lazurite/features/moderation/domain/moderation_models.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -13,8 +12,6 @@ import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_web_links.dart'; import 'package:lazurite/core/router/app_shell.dart'; -import 'package:lazurite/core/theme/animation_tokens.dart'; -import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/core/theme/color_filters.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/core/theme/spacing.dart'; @@ -22,6 +19,7 @@ import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; +import 'package:lazurite/features/compose/presentation/widgets/compose_fab.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; @@ -673,7 +671,13 @@ class _ProfileScreenState extends State with TickerProviderStateM return Row( mainAxisSize: MainAxisSize.max, - children: [const SizedBox(width: 24), jumpToTopButton, const Spacer(), _buildComposeFab(context)], + children: [ + const SizedBox(width: 24), + jumpToTopButton, + const Spacer(), + _buildComposeFab(context), + const SizedBox(width: 24), + ], ); } @@ -1203,20 +1207,13 @@ class _ProfileScreenState extends State with TickerProviderStateM final initialText = isOwnProfile ? null : '@${profile.handle} '; final isOffline = context.select((cubit) => cubit.state.isOffline); - return FloatingActionButton( + return ComposeFab( key: const ValueKey('profile-compose-fab'), heroTag: 'profile-compose-fab', tooltip: isOffline ? context.l10n.formatOfflineReconnectAction('compose a post') : context.l10n.buttonCompose, onPressed: isOffline ? null : () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), - child: const Icon(Icons.add), - ).animateIfAllowed( - context, - effects: const [ - FadeEffect(duration: Anim.feedItem, curve: Anim.enter), - ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), - ], ); }, ); diff --git a/test/features/feed/presentation/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart index eda0ebd..d9eb438 100644 --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -364,6 +364,32 @@ void main() { expect(find.byType(FloatingActionButton), findsNWidgets(2)); }); + testWidgets('keeps floating buttons equally inset from horizontal edges', (tester) async { + final feedPreferencesCubit = MockFeedPreferencesCubit(); + final feedRepository = MockFeedRepository(); + + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); + whenListen(feedPreferencesCubit, const Stream.empty(), initialState: _homeFeedState); + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); + when( + () => feedRepository.getTimeline( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => FeedResult(posts: const [])); + + await tester.pumpWidget( + buildHomeSubject(feedPreferencesCubit: feedPreferencesCubit, feedRepository: feedRepository), + ); + await tester.pumpAndSettle(); + + final jumpRect = tester.getRect(find.byTooltip('Jump to top')); + final composeRect = tester.getRect(find.byTooltip('Compose')); + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + + expect(jumpRect.left, moreOrLessEquals(screenWidth - composeRect.right, epsilon: 0.1)); + }); + testWidgets('re-tapping selected feed tab reloads the feed', (tester) async { final feedPreferencesCubit = MockFeedPreferencesCubit(); final feedRepository = MockFeedRepository(); diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index 5c4b564..0ae3d04 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -467,6 +467,10 @@ void main() { tester.widget(find.byKey(const ValueKey('profile-compose-fab'))).heroTag, 'profile-compose-fab', ); + final jumpRect = tester.getRect(find.byKey(const ValueKey('profile-jump-top-fab'))); + final composeRect = tester.getRect(find.byKey(const ValueKey('profile-compose-fab'))); + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(jumpRect.left, moreOrLessEquals(screenWidth - composeRect.right, epsilon: 0.1)); await tester.tap(find.byKey(const ValueKey('profile-compose-fab'))); await tester.pumpAndSettle();