diff --git a/docs/TODO.md b/docs/TODO.md index e74acb1..c11f284 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -70,3 +70,8 @@ feature parity with the official app. 3. Lists 4. Starter Packs 5. Download images & videos + +--- + +- Last read position +- Autothreading of posts over char limit; splitting posts diff --git a/docs/tasks/phase-8.md b/docs/tasks/phase-8.md index 3f1414d..be0d330 100644 --- a/docs/tasks/phase-8.md +++ b/docs/tasks/phase-8.md @@ -12,17 +12,17 @@ Completed [2026-04-11](../../CHANGELOG.md#2026-04-11) Spec: [animate.md](../specs/animate.md) - [x] Add `flutter_animate` dependency -- [ ] Create `lib/core/theme/animation_tokens.dart` with centralised durations, curves, stagger constants -- [ ] Add reduced-motion utility (`animateIfAllowed` extension) - - [ ] Add setting for users to turn off animations -- [ ] Feed & list items: staggered fade-in + slide-up on first appearance (track seen items to avoid re-animation) -- [ ] Action feedback: scale-bounce on like / repost / bookmark tap -- [ ] Screen transitions: fade-through `TransitionPage` wrapper for GoRouter -- [ ] Shimmer loading: replace static skeleton placeholders with `.shimmer()` sweep -- [ ] Bottom nav bar: scale + crossfade on active icon change -- [ ] Snackbar / toast: slide-up entrance, fade-out dismiss -- [ ] FAB / action buttons: scale-in on appear, scale-out on disappear -- [ ] Pull-to-refresh: rotate + fade-out on completion -- [ ] Profile header: parallax banner on scroll -- [ ] Empty state: gentle fade + scale entrance -- [ ] Widget tests for all animated widgets (pumpAndSettle, reduced-motion branch) +- [x] Create `lib/core/theme/animation_tokens.dart` with centralised durations, curves, stagger constants +- [x] Add reduced-motion utility (`animateIfAllowed` extension) + - [x] Add setting for users to turn off animations +- [x] Feed & list items: staggered fade-in + slide-up on first appearance (track seen items to avoid re-animation) +- [x] Action feedback: scale-bounce on like / repost / bookmark tap +- [x] Screen transitions: fade-through `TransitionPage` wrapper for GoRouter +- [x] Shimmer loading: replace static skeleton placeholders with `.shimmer()` sweep +- [x] Bottom nav bar: scale + crossfade on active icon change +- [x] Snackbar / toast: slide-up entrance, fade-out dismiss +- [x] FAB / action buttons: scale-in on appear, scale-out on disappear +- [x] Pull-to-refresh: rotate + fade-out on completion +- [x] Profile header: parallax banner on scroll +- [x] Empty state: gentle fade + scale entrance +- [x] Widget tests for all animated widgets (pumpAndSettle, reduced-motion branch) diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1fe2d74..817e752 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,61 +1,57 @@ import 'dart:async'; import 'package:atproto_core/atproto_core.dart' show AtUri; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:bluesky/bluesky.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; -import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/core/network/constellation_client.dart'; import 'package:lazurite/core/router/app_shell.dart'; -import 'package:lazurite/features/auth/presentation/login_screen.dart'; +import 'package:lazurite/core/router/fade_through_page.dart'; import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/presentation/login_screen.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/compose/presentation/compose_screen.dart'; import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; -import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; -import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; +import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; import 'package:lazurite/features/feed/presentation/media/video_player_screen.dart'; -import 'package:lazurite/features/logs/presentation/logs_screen.dart'; -import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; -import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; -import 'package:lazurite/features/notifications/data/notification_repository.dart'; -import 'package:lazurite/features/profile/presentation/profile_screen.dart'; -import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; +import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; -import 'package:lazurite/features/search/presentation/search_screen.dart'; -import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; -import 'package:lazurite/features/search/data/hashtag_utils.dart'; -import 'package:lazurite/features/search/data/search_repository.dart'; -import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; -import 'package:lazurite/features/messages/bloc/message_bloc.dart'; -import 'package:lazurite/features/messages/data/convo_repository.dart'; -import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; -import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; import 'package:lazurite/features/lists/bloc/list_bloc.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/lists/presentation/list_detail_screen.dart'; import 'package:lazurite/features/lists/presentation/list_members_screen.dart'; import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; +import 'package:lazurite/features/logs/presentation/logs_screen.dart'; +import 'package:lazurite/features/messages/bloc/message_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; -import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; -import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; -import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; -import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; -import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; -import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; +import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; +import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; -import 'package:lazurite/features/profile/data/profile_context_repository.dart'; +import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; +import 'package:lazurite/features/profile/data/profile_context_repository.dart'; +import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; -import 'package:lazurite/core/network/constellation_client.dart'; +import 'package:lazurite/features/profile/presentation/profile_screen.dart'; +import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; +import 'package:lazurite/features/search/data/hashtag_utils.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; +import 'package:lazurite/features/search/presentation/search_screen.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; import 'package:lazurite/features/settings/data/video_repository.dart'; @@ -64,6 +60,11 @@ import 'package:lazurite/features/settings/presentation/privacy_policy_screen.da import 'package:lazurite/features/settings/presentation/settings_screen.dart'; import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; +import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; +import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; +import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; +import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; ComposeRouteArgs parseComposeRouteExtra(Object? extra) { if (extra is ComposeRouteArgs) { @@ -128,6 +129,9 @@ class AppRouter { final GlobalKey _notificationsNavigatorKey = GlobalKey(debugLabel: 'notifications'); final GlobalKey _profileNavigatorKey = GlobalKey(debugLabel: 'profile'); + Page _page(BuildContext context, GoRouterState state, Widget child) => + buildFadeThroughPage(context: context, state: state, child: child); + GoRouter get router => GoRouter( navigatorKey: _rootNavigatorKey, refreshListenable: GoRouterRefreshStream(authBloc.stream), @@ -150,36 +154,40 @@ class AppRouter { return null; }, routes: [ - GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), - GoRoute(path: '/terms', builder: (context, state) => const TermsOfServiceScreen()), - GoRoute(path: '/privacy', builder: (context, state) => const PrivacyPolicyScreen()), + GoRoute(path: '/login', pageBuilder: (context, state) => _page(context, state, const LoginScreen())), + GoRoute(path: '/terms', pageBuilder: (context, state) => _page(context, state, const TermsOfServiceScreen())), + GoRoute(path: '/privacy', pageBuilder: (context, state) => _page(context, state, const PrivacyPolicyScreen())), GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'), GoRoute(path: '/messages', redirect: (_, _) => '/alerts/messages'), GoRoute( path: '/compose', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { + pageBuilder: (context, state) { final args = parseComposeRouteExtra(state.extra); - return BlocProvider( - create: (_) => ComposeBloc( - composeRepository: ComposeRepository(bluesky: context.read()), - database: context.read(), - accountDid: context.read(), - ), - child: ComposeScreen( - replyParentUri: args.replyParentUri, - replyParentCid: args.replyParentCid, - replyRootUri: args.replyRootUri, - replyRootCid: args.replyRootCid, - replyAuthorHandle: args.replyAuthorHandle, - quoteUri: args.quoteUri, - quoteCid: args.quoteCid, - quoteAuthorHandle: args.quoteAuthorHandle, - draftId: args.draftId, - initialText: args.initialText, - editPostUri: args.editPostUri, - editPostCid: args.editPostCid, - editRecord: args.editRecord, + return _page( + context, + state, + BlocProvider( + create: (_) => ComposeBloc( + composeRepository: ComposeRepository(bluesky: context.read()), + database: context.read(), + accountDid: context.read(), + ), + child: ComposeScreen( + replyParentUri: args.replyParentUri, + replyParentCid: args.replyParentCid, + replyRootUri: args.replyRootUri, + replyRootCid: args.replyRootCid, + replyAuthorHandle: args.replyAuthorHandle, + quoteUri: args.quoteUri, + quoteCid: args.quoteCid, + quoteAuthorHandle: args.quoteAuthorHandle, + draftId: args.draftId, + initialText: args.initialText, + editPostUri: args.editPostUri, + editPostCid: args.editPostCid, + editRecord: args.editRecord, + ), ), ); }, @@ -187,85 +195,97 @@ class AppRouter { GoRoute( path: '/post', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { + pageBuilder: (context, state) { final uri = state.uri.queryParameters['uri'] ?? ''; - return PostThreadScreen(postUri: Uri.decodeComponent(uri)); + return _page(context, state, PostThreadScreen(postUri: Uri.decodeComponent(uri))); }, ), GoRoute( path: '/hashtag', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { + pageBuilder: (context, state) { final normalizedTag = normalizeHashtag(state.uri.queryParameters['tag'] ?? ''); - return BlocProvider( - key: ValueKey('hashtag-$normalizedTag'), - create: (_) => HashtagCubit(searchRepository: context.read(), tag: normalizedTag), - child: HashtagScreen(tag: normalizedTag), + return _page( + context, + state, + BlocProvider( + key: ValueKey('hashtag-$normalizedTag'), + create: (_) => HashtagCubit(searchRepository: context.read(), tag: normalizedTag), + child: HashtagScreen(tag: normalizedTag), + ), ); }, ), GoRoute( path: '/images', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { + pageBuilder: (context, state) { final args = state.extra as ImageViewerRouteArgs; - return ImageViewerScreen(args: args); + return _page(context, state, ImageViewerScreen(args: args)); }, ), GoRoute( path: '/video', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) { + pageBuilder: (context, state) { final args = state.extra as VideoPlayerRouteArgs; - return VideoPlayerScreen(args: args); + return _page(context, state, VideoPlayerScreen(args: args)); }, ), GoRoute( path: '/saved', - builder: (context, state) => SavedPostsScreen(accountDid: context.read()), + pageBuilder: (context, state) => _page(context, state, SavedPostsScreen(accountDid: context.read())), ), - GoRoute(path: '/lists', builder: (context, state) => const MyListsScreen()), + GoRoute(path: '/lists', pageBuilder: (context, state) => _page(context, state, const MyListsScreen())), GoRoute( path: '/create-starter-pack', - builder: (context, state) { - return BlocProvider( - create: (_) => StarterPackBloc(starterPackRepository: context.read()), - child: CreateStarterPackScreen(userDid: context.read()), + pageBuilder: (context, state) { + return _page( + context, + state, + BlocProvider( + create: (_) => StarterPackBloc(starterPackRepository: context.read()), + child: CreateStarterPackScreen(userDid: context.read()), + ), ); }, ), GoRoute( path: '/starter-pack', - builder: (context, state) { + pageBuilder: (context, state) { final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); final packUri = AtUri.parse(uriStr); - return StarterPackDetailScreen(packUri: packUri); + return _page(context, state, StarterPackDetailScreen(packUri: packUri)); }, ), GoRoute( path: '/starter-packs', - builder: (context, state) { + pageBuilder: (context, state) { final actor = state.uri.queryParameters['actor'] ?? ''; - return ActorStarterPacksScreen(actor: actor); + return _page(context, state, ActorStarterPacksScreen(actor: actor)); }, ), GoRoute( path: '/list', - builder: (context, state) { + pageBuilder: (context, state) { final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); final listUri = AtUri.parse(uriStr); - return ListDetailScreen(listUri: listUri); + return _page(context, state, ListDetailScreen(listUri: listUri)); }, routes: [ GoRoute( path: 'members', - builder: (context, state) { + pageBuilder: (context, state) { final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); final listUri = AtUri.parse(uriStr); - return BlocProvider( - create: (_) => - ListBloc(listRepository: context.read())..add(ListRequested(listUri: listUri)), - child: ListMembersScreen(listUri: listUri), + return _page( + context, + state, + BlocProvider( + create: (_) => + ListBloc(listRepository: context.read())..add(ListRequested(listUri: listUri)), + child: ListMembersScreen(listUri: listUri), + ), ); }, ), @@ -273,7 +293,7 @@ class AppRouter { ), GoRoute( path: '/profile-context', - builder: (context, state) { + pageBuilder: (context, state) { final did = state.uri.queryParameters['did'] ?? ''; final handle = state.uri.queryParameters['handle'] ?? ''; final isOwnProfile = did == context.read(); @@ -283,9 +303,13 @@ class AppRouter { publicBluesky: Bluesky.anonymous(service: profileContextPublicAppViewService), constellationClient: ConstellationClient(baseUrl: constellationUrl), ); - return BlocProvider( - create: (_) => ProfileContextCubit(repository: repository, did: did, isOwnProfile: isOwnProfile), - child: ProfileContextScreen(handle: handle), + return _page( + context, + state, + BlocProvider( + create: (_) => ProfileContextCubit(repository: repository, did: did, isOwnProfile: isOwnProfile), + child: ProfileContextScreen(handle: handle), + ), ); }, ), @@ -322,45 +346,60 @@ class AppRouter { routes: [ GoRoute( path: '/', - builder: (context, state) => const HomeFeedScreen(), + pageBuilder: (context, state) => _page(context, state, const HomeFeedScreen()), routes: [ - GoRoute(path: 'feeds', builder: (context, state) => const FeedManagementScreen()), + GoRoute( + path: 'feeds', + pageBuilder: (context, state) => _page(context, state, const FeedManagementScreen()), + ), GoRoute( path: 'settings', - builder: (context, state) => const SettingsScreen(), + pageBuilder: (context, state) => _page(context, state, const SettingsScreen()), routes: [ GoRoute( path: 'moderation', - builder: (context, state) => const ModerationSettingsScreen(), + pageBuilder: (context, state) => _page(context, state, const ModerationSettingsScreen()), routes: [ GoRoute( path: 'detail', - builder: (context, state) => - LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? ''), + pageBuilder: (context, state) => + _page(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), ), ], ), - GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), - GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), + GoRoute( + path: 'about', + pageBuilder: (context, state) => _page(context, state, const AboutScreen()), + ), + GoRoute(path: 'logs', pageBuilder: (context, state) => _page(context, state, const LogsScreen())), GoRoute( path: 'clean-follows', - builder: (context, state) => BlocProvider( - create: (_) => FollowAuditCubit( - repository: FollowAuditRepository(bluesky: context.read()), - ownDid: context.read(), + pageBuilder: (context, state) => _page( + context, + state, + BlocProvider( + create: (_) => FollowAuditCubit( + repository: FollowAuditRepository(bluesky: context.read()), + ownDid: context.read(), + ), + child: const FollowAuditScreen(), ), - child: const FollowAuditScreen(), ), ), GoRoute( path: 'devtools', - builder: (context, state) => DevToolsScreen(initialQuery: state.uri.queryParameters['query']), + pageBuilder: (context, state) => + _page(context, state, DevToolsScreen(initialQuery: state.uri.queryParameters['query'])), ), GoRoute( path: 'video-limits', - builder: (context, state) => BlocProvider( - create: (_) => VideoUploadLimitsCubit(repository: context.read()), - child: const VideoUploadLimitsScreen(), + pageBuilder: (context, state) => _page( + context, + state, + BlocProvider( + create: (_) => VideoUploadLimitsCubit(repository: context.read()), + child: const VideoUploadLimitsScreen(), + ), ), ), ], @@ -371,31 +410,41 @@ class AppRouter { ), StatefulShellBranch( navigatorKey: _searchNavigatorKey, - routes: [GoRoute(path: '/search', builder: (context, state) => const SearchScreen())], + routes: [ + GoRoute(path: '/search', pageBuilder: (context, state) => _page(context, state, const SearchScreen())), + ], ), StatefulShellBranch( navigatorKey: _notificationsNavigatorKey, routes: [ GoRoute( path: '/alerts', - builder: (context, state) => _buildAlertsRoute(context, const AlertsScreen()), + pageBuilder: (context, state) => + _page(context, state, _buildAlertsRoute(context, const AlertsScreen())), routes: [ GoRoute( path: 'messages', - builder: (context, state) => - _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), + pageBuilder: (context, state) => _page( + context, + state, + _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), + ), routes: [ GoRoute( path: ':id', - builder: (context, state) { + pageBuilder: (context, state) { final convoId = state.pathParameters['id']!; final args = state.extra as MessageThreadRouteArgs?; - return BlocProvider( - create: (_) => MessageBloc( - convoRepository: context.read(), - currentUserDid: context.read(), + return _page( + context, + state, + BlocProvider( + create: (_) => MessageBloc( + convoRepository: context.read(), + currentUserDid: context.read(), + ), + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), ), - child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), ); }, ), @@ -403,8 +452,11 @@ class AppRouter { ), GoRoute( path: 'requests', - builder: (context, state) => - _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), + pageBuilder: (context, state) => _page( + context, + state, + _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), + ), ), ], ), @@ -415,12 +467,15 @@ class AppRouter { routes: [ GoRoute( path: '/profile', - builder: (context, state) => const ProfileScreen(), + pageBuilder: (context, state) => _page(context, state, const ProfileScreen()), routes: [ GoRoute( path: 'view', - builder: (context, state) => - ProfileScreen(actor: state.uri.queryParameters['actor'], showBackButton: true), + pageBuilder: (context, state) => _page( + context, + state, + ProfileScreen(actor: state.uri.queryParameters['actor'], showBackButton: true), + ), ), ], ), diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index f7f7046..32799de 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -1,6 +1,9 @@ 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/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; @@ -85,31 +88,87 @@ class _AppShellState extends State { } List get _destinations => [ - NavigationDestination( - icon: const Icon(Icons.home_outlined), - selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.home)), + const NavigationDestination( + icon: _AnimatedNavIcon(selected: false, outlined: Icons.home_outlined, filled: Icons.home), + selectedIcon: _AnimatedNavIcon(selected: true, outlined: Icons.home_outlined, filled: Icons.home), label: 'HOME', ), - NavigationDestination( - icon: const Icon(Icons.search_outlined), - selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.search)), + const NavigationDestination( + icon: _AnimatedNavIcon(selected: false, outlined: Icons.search_outlined, filled: Icons.search), + selectedIcon: _AnimatedNavIcon(selected: true, outlined: Icons.search_outlined, filled: Icons.search), label: 'SEARCH', ), - NavigationDestination( - icon: const _NotificationDestinationIcon(selected: false), - selectedIcon: Transform.scale(scale: 1.15, child: const _NotificationDestinationIcon(selected: true)), + const NavigationDestination( + icon: _AnimatedNotificationNavIcon(selected: false), + selectedIcon: _AnimatedNotificationNavIcon(selected: true), label: 'ALERTS', ), - NavigationDestination( - icon: const Icon(Icons.person_outline), - selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.person)), + const NavigationDestination( + icon: _AnimatedNavIcon(selected: false, outlined: Icons.person_outline, filled: Icons.person), + selectedIcon: _AnimatedNavIcon(selected: true, outlined: Icons.person_outline, filled: Icons.person), label: 'PROFILE', ), ]; } +class _AnimatedNavIcon extends StatelessWidget { + const _AnimatedNavIcon({required this.selected, required this.outlined, required this.filled}); + + final bool selected; + final IconData outlined; + final IconData filled; + + @override + Widget build(BuildContext context) { + final icon = Icon(selected ? filled : outlined, key: ValueKey(selected), size: selected ? 26 : 24); + final transitioned = AnimatedSwitcher( + duration: Anim.fast, + switchInCurve: Anim.enter, + switchOutCurve: Anim.exit, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition(scale: animation, child: child), + ), + child: icon, + ); + + return transitioned.animateIfAllowed( + context, + effects: selected + ? const [ScaleEffect(begin: Offset(1, 1), end: Offset(1.15, 1.15), duration: Anim.fast, curve: Anim.enter)] + : const [], + ); + } +} + +class _AnimatedNotificationNavIcon extends StatelessWidget { + const _AnimatedNotificationNavIcon({required this.selected}); + + final bool selected; + + @override + Widget build(BuildContext context) { + final icon = _NotificationDestinationIcon(selected: selected, key: ValueKey(selected)); + return AnimatedSwitcher( + duration: Anim.fast, + switchInCurve: Anim.enter, + switchOutCurve: Anim.exit, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition(scale: animation, child: child), + ), + child: icon, + ).animateIfAllowed( + context, + effects: selected + ? const [ScaleEffect(begin: Offset(1, 1), end: Offset(1.15, 1.15), duration: Anim.fast, curve: Anim.enter)] + : const [], + ); + } +} + class _NotificationDestinationIcon extends StatelessWidget { - const _NotificationDestinationIcon({required this.selected}); + const _NotificationDestinationIcon({super.key, required this.selected}); final bool selected; diff --git a/lib/core/router/fade_through_page.dart b/lib/core/router/fade_through_page.dart new file mode 100644 index 0000000..f03b914 --- /dev/null +++ b/lib/core/router/fade_through_page.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; + +Page buildFadeThroughPage({required BuildContext context, required GoRouterState state, required Widget child}) { + final reducedMotion = !animationsAllowed(context); + final duration = reducedMotion ? Anim.fast : Anim.screenTransition; + + return CustomTransitionPage( + key: state.pageKey, + transitionDuration: duration, + reverseTransitionDuration: Anim.fast, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + if (reducedMotion) { + return FadeTransition(opacity: animation, child: child); + } + + final fade = CurvedAnimation( + parent: animation, + curve: const Interval(0.2, 1, curve: Anim.enter), + ); + + final scale = Tween(begin: 0.96, end: 1).animate(CurvedAnimation(parent: animation, curve: Anim.enter)); + return FadeTransition( + opacity: fade, + child: ScaleTransition(scale: scale, child: child), + ); + }, + ); +} diff --git a/lib/core/theme/animation_tokens.dart b/lib/core/theme/animation_tokens.dart new file mode 100644 index 0000000..9c36ef8 --- /dev/null +++ b/lib/core/theme/animation_tokens.dart @@ -0,0 +1,26 @@ +import 'package:flutter/animation.dart'; + +abstract final class Anim { + static const Duration fast = Duration(milliseconds: 150); + static const Duration normal = Duration(milliseconds: 250); + static const Duration slow = Duration(milliseconds: 400); + + static const Duration feedItem = Duration(milliseconds: 200); + static const Duration screenTransition = Duration(milliseconds: 300); + static const Duration shimmerCycle = Duration(milliseconds: 1200); + static const Duration actionBounceIn = Duration(milliseconds: 120); + static const Duration actionBounceOut = Duration(milliseconds: 100); + static const Duration refreshComplete = Duration(milliseconds: 200); + + static const Curve enter = Curves.easeOut; + static const Curve exit = Curves.easeIn; + static const Curve emphasis = Curves.easeOutBack; + + static const Duration staggerOffset = Duration(milliseconds: 50); + static const int maxStaggerItems = 10; + + static Duration staggerFor(int index) { + final clampedIndex = index.clamp(0, maxStaggerItems - 1); + return Duration(milliseconds: staggerOffset.inMilliseconds * clampedIndex); + } +} diff --git a/lib/core/theme/animation_utils.dart b/lib/core/theme/animation_utils.dart new file mode 100644 index 0000000..2435b93 --- /dev/null +++ b/lib/core/theme/animation_utils.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; + +bool animationsEnabledByUser(BuildContext context) { + try { + return context.select((cubit) => cubit.state.animationsEnabled); + } catch (_) { + return true; + } +} + +bool animationsAllowed(BuildContext context) { + final platformReducedMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + final bindingName = WidgetsBinding.instance.runtimeType.toString(); + final runningInWidgetTest = bindingName.contains('TestWidgetsFlutterBinding'); + return !runningInWidgetTest && !platformReducedMotion && animationsEnabledByUser(context); +} + +extension AnimateAccessibility on Widget { + Widget animateIfAllowed(BuildContext context, {required List> effects, Duration? delay}) { + if (animationsAllowed(context)) { + return animate(effects: effects, delay: delay); + } + + return this; + } +} diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index d404c57..deb4a22 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -2,8 +2,11 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/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/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/connectivity/connectivity_helpers.dart'; @@ -15,6 +18,7 @@ import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actio import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; /// Returns the number of grid columns for [width] per the responsive /// breakpoints defined in the UI spec. @@ -110,13 +114,20 @@ class _HomeFeedScreenState extends State { itemBuilder: (context, index) => _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), ), - floatingActionButton: 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), - ), + floatingActionButton: + 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), + ], + ), ); }, ); @@ -224,6 +235,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli bool _hasError = false; String? _errorMessage; final ScrollController _scrollController = ScrollController(); + final Set _seenPostUris = {}; @override bool get wantKeepAlive => true; @@ -390,16 +402,21 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli final accountDid = context.read().state.tokens?.did ?? ''; - PostCardWithActions buildCard(int index, PostCardVariant variant) { + Widget buildCard(int index, PostCardVariant variant) { final post = _posts[index]; - return PostCardWithActions( - feedViewPost: post, - accountDid: accountDid, - variant: variant, - onDeleted: () { - final uri = post.post.uri.toString(); - _setStateIfMounted(() => _posts.removeWhere((p) => p.post.uri.toString() == uri)); - }, + final postUri = post.post.uri.toString(); + return StaggeredEntrance( + itemKey: postUri, + index: index, + seenKeys: _seenPostUris, + child: PostCardWithActions( + feedViewPost: post, + accountDid: accountDid, + variant: variant, + onDeleted: () { + _setStateIfMounted(() => _posts.removeWhere((p) => p.post.uri.toString() == postUri)); + }, + ), ); } diff --git a/lib/features/feed/presentation/saved_posts_screen.dart b/lib/features/feed/presentation/saved_posts_screen.dart index 06f3104..a35ddea 100644 --- a/lib/features/feed/presentation/saved_posts_screen.dart +++ b/lib/features/feed/presentation/saved_posts_screen.dart @@ -11,9 +11,11 @@ import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; import 'package:share_plus/share_plus.dart'; class SavedPostsScreen extends StatelessWidget { @@ -110,9 +112,16 @@ class _SavedPostsContentState extends State<_SavedPostsContent> with SingleTicke } } -class _AllSavedTab extends StatelessWidget { +class _AllSavedTab extends StatefulWidget { const _AllSavedTab(); + @override + State<_AllSavedTab> createState() => _AllSavedTabState(); +} + +class _AllSavedTabState extends State<_AllSavedTab> { + final Set _seenPostUris = {}; + @override Widget build(BuildContext context) { return BlocBuilder( @@ -137,15 +146,20 @@ class _AllSavedTab extends StatelessWidget { ); } - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: () => context.read().loadSavedPosts(), child: ListView.builder( itemCount: state.savedPosts.length, itemBuilder: (context, index) { final savedPost = state.savedPosts[index]; - return _SavedPostCard( - savedPost: savedPost, - onUnsave: () => context.read().unsavePostById(savedPost.id), + return StaggeredEntrance( + itemKey: savedPost.postUri, + index: index, + seenKeys: _seenPostUris, + child: _SavedPostCard( + savedPost: savedPost, + onUnsave: () => context.read().unsavePostById(savedPost.id), + ), ); }, ), diff --git a/lib/features/feed/presentation/widgets/feed_layout_view.dart b/lib/features/feed/presentation/widgets/feed_layout_view.dart index 0a76152..97749af 100644 --- a/lib/features/feed/presentation/widgets/feed_layout_view.dart +++ b/lib/features/feed/presentation/widgets/feed_layout_view.dart @@ -4,6 +4,7 @@ import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; const double _gridSpacing = 1; const double _gridCardChromeHeight = 160; @@ -54,7 +55,7 @@ class FeedLayoutView extends StatelessWidget { } final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: onRefresh, child: CustomScrollView( controller: scrollController, @@ -80,7 +81,7 @@ class FeedLayoutView extends StatelessWidget { } Widget _buildSingleColumnGrid(BuildContext context) { - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: onRefresh, child: CustomScrollView( controller: scrollController, @@ -105,7 +106,7 @@ class FeedLayoutView extends StatelessWidget { } Widget _buildLinear(BuildContext context) { - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: onRefresh, child: ListView.builder( controller: scrollController, diff --git a/lib/features/feed/presentation/widgets/post_action_bar.dart b/lib/features/feed/presentation/widgets/post_action_bar.dart index 5a5e81a..293821f 100644 --- a/lib/features/feed/presentation/widgets/post_action_bar.dart +++ b/lib/features/feed/presentation/widgets/post_action_bar.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; @@ -78,6 +80,7 @@ class PostActionBar extends StatelessWidget { count: repostCount, isActive: isReposted, isLoading: isLoadingRepost, + animateOnTap: true, onTap: isOffline ? null : onRepost, activeColor: Colors.green, onLongPress: !isOffline && onRepost != null ? () => _showRepostOptions(context) : null, @@ -89,6 +92,7 @@ class PostActionBar extends StatelessWidget { count: likeCount, isActive: isLiked, isLoading: isLoadingLike, + animateOnTap: true, onTap: isOffline ? null : onLike, activeColor: Colors.pink, tooltip: isOffline ? offlineActionMessage('like this post') : null, @@ -98,6 +102,7 @@ class PostActionBar extends StatelessWidget { activeIcon: Icons.bookmark, count: saveCount, isActive: isSaved, + animateOnTap: true, onTap: onSave != null ? () => _showSaveOptions(context) : null, onLongPress: onLongPressSave, color: context.colorScheme.onSurfaceVariant, @@ -204,6 +209,7 @@ class _ActionButton extends StatelessWidget { this.color, this.activeColor, this.tooltip, + this.animateOnTap = false, }); final IconData icon; @@ -216,46 +222,123 @@ class _ActionButton extends StatelessWidget { final Color? color; final Color? activeColor; final String? tooltip; + final bool animateOnTap; @override Widget build(BuildContext context) { - final defaultColor = color ?? context.colorScheme.onSurfaceVariant; - final iconColor = isActive ? (activeColor ?? defaultColor) : defaultColor; + return _ActionButtonBody( + icon: icon, + activeIcon: activeIcon, + count: count, + isActive: isActive, + isLoading: isLoading, + onTap: onTap, + onLongPress: onLongPress, + color: color, + activeColor: activeColor, + tooltip: tooltip, + animateOnTap: animateOnTap, + ); + } +} + +class _ActionButtonBody extends StatefulWidget { + const _ActionButtonBody({ + required this.icon, + required this.activeIcon, + required this.count, + required this.isActive, + required this.isLoading, + required this.onTap, + required this.onLongPress, + required this.color, + required this.activeColor, + required this.tooltip, + required this.animateOnTap, + }); + + final IconData icon; + final IconData activeIcon; + final int count; + final bool isActive; + final bool isLoading; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final Color? color; + final Color? activeColor; + final String? tooltip; + final bool animateOnTap; + + @override + State<_ActionButtonBody> createState() => _ActionButtonBodyState(); +} + +class _ActionButtonBodyState extends State<_ActionButtonBody> { + int _tapSequence = 0; + + @override + Widget build(BuildContext context) { + final defaultColor = widget.color ?? context.colorScheme.onSurfaceVariant; + final iconColor = widget.isActive ? (widget.activeColor ?? defaultColor) : defaultColor; + final iconWidget = widget.isLoading + ? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor)) + : AnimatedSwitcher( + duration: Anim.fast, + switchInCurve: Anim.enter, + switchOutCurve: Anim.exit, + transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), + child: Icon( + widget.isActive ? widget.activeIcon : widget.icon, + key: ValueKey('${widget.isActive}-${widget.isLoading}'), + size: 18, + color: iconColor, + ), + ); Widget button = InkWell( - onTap: isLoading ? null : onTap, - onLongPress: onLongPress, + onTap: widget.isLoading ? null : _handleTap, + onLongPress: widget.onLongPress, borderRadius: BorderRadius.circular(999), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (isLoading) - SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor)) - else - Icon(isActive ? activeIcon : icon, size: 18, color: iconColor), - if (count > 0) ...[ + iconWidget, + if (widget.count > 0) ...[ const SizedBox(width: 4), - Text(formatCount(count), style: context.textTheme.bodySmall?.copyWith(color: iconColor)), + Text(formatCount(widget.count), style: context.textTheme.bodySmall?.copyWith(color: iconColor)), ], ], ), ), ); - if (onTap != null && !isLoading) { - button = GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: AnimatedScale(scale: isActive ? 1.0 : 1.0, duration: const Duration(milliseconds: 100), child: button), + if (widget.animateOnTap && _tapSequence > 0) { + button = button.animate( + key: ValueKey('action-${widget.icon.codePoint}-$_tapSequence'), + effects: const [ + ScaleEffect(begin: Offset(1, 1), end: Offset(1.3, 1.3), duration: Anim.actionBounceIn, curve: Anim.enter), + ScaleEffect(begin: Offset(1.3, 1.3), end: Offset(1, 1), duration: Anim.actionBounceOut, curve: Anim.emphasis), + ], ); } - if (tooltip != null) { - button = Tooltip(message: tooltip!, child: button); + if (widget.tooltip != null) { + button = Tooltip(message: widget.tooltip!, child: button); } return button; } + + void _handleTap() { + if (widget.onTap == null) { + return; + } + + if (widget.animateOnTap) { + setState(() => _tapSequence++); + } + widget.onTap!.call(); + } } diff --git a/lib/features/lists/presentation/list_members_screen.dart b/lib/features/lists/presentation/list_members_screen.dart index fddefbd..292b35d 100644 --- a/lib/features/lists/presentation/list_members_screen.dart +++ b/lib/features/lists/presentation/list_members_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/lists/bloc/list_bloc.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; /// Screen for adding and removing members from a list. @@ -32,6 +33,8 @@ class _ListMembersViewState extends State<_ListMembersView> { final _searchController = TextEditingController(); List _searchResults = []; bool _isSearching = false; + final Set _seenSearchResultDids = {}; + final Set _seenMemberUris = {}; @override void dispose() { @@ -114,30 +117,35 @@ class _ListMembersViewState extends State<_ListMembersView> { final profile = _searchResults[index]; final isAlreadyMember = currentDids.contains(profile.did); - return ListTile( - leading: CircleAvatar( - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, - backgroundColor: colorScheme.surfaceContainerHighest, - child: profile.avatar == null - ? Text( - (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) - .substring(0, 1) - .toUpperCase(), - ) - : null, + return StaggeredEntrance( + itemKey: profile.did, + index: index, + seenKeys: _seenSearchResultDids, + child: ListTile( + leading: CircleAvatar( + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, + backgroundColor: colorScheme.surfaceContainerHighest, + child: profile.avatar == null + ? Text( + (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) + .substring(0, 1) + .toUpperCase(), + ) + : null, + ), + title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), + trailing: isAlreadyMember + ? Icon(Icons.check_circle, color: colorScheme.primary) + : IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: () { + context.read().add(ListItemAdded(subjectDid: profile.did)); + _searchController.clear(); + setState(() => _searchResults = []); + }, + ), ), - title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), - trailing: isAlreadyMember - ? Icon(Icons.check_circle, color: colorScheme.primary) - : IconButton( - icon: const Icon(Icons.add_circle_outline), - onPressed: () { - context.read().add(ListItemAdded(subjectDid: profile.did)); - _searchController.clear(); - setState(() => _searchResults = []); - }, - ), ); }, ), @@ -177,28 +185,33 @@ class _ListMembersViewState extends State<_ListMembersView> { final item = state.items[index]; final subject = item.subject; - return ListTile( - key: ValueKey(item.uri), - leading: CircleAvatar( - backgroundImage: subject.avatar != null ? NetworkImage(subject.avatar!) : null, - backgroundColor: colorScheme.surfaceContainerHighest, - child: subject.avatar == null - ? Text( - (subject.displayName?.isNotEmpty == true ? subject.displayName! : subject.handle) - .substring(0, 1) - .toUpperCase(), - ) - : null, + return StaggeredEntrance( + itemKey: item.uri.toString(), + index: index, + seenKeys: _seenMemberUris, + child: ListTile( + key: ValueKey(item.uri), + leading: CircleAvatar( + backgroundImage: subject.avatar != null ? NetworkImage(subject.avatar!) : null, + backgroundColor: colorScheme.surfaceContainerHighest, + child: subject.avatar == null + ? Text( + (subject.displayName?.isNotEmpty == true ? subject.displayName! : subject.handle) + .substring(0, 1) + .toUpperCase(), + ) + : null, + ), + title: Text(subject.displayName ?? subject.handle, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('@${subject.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), + onTap: () => navigateToProfile(context, subject.did), + trailing: state.isMutating + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : IconButton( + icon: Icon(Icons.remove_circle_outline, color: colorScheme.error), + onPressed: () => context.read().add(ListItemRemoved(listItemUri: item.uri)), + ), ), - title: Text(subject.displayName ?? subject.handle, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text('@${subject.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), - onTap: () => navigateToProfile(context, subject.did), - trailing: state.isMutating - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : IconButton( - icon: Icon(Icons.remove_circle_outline, color: colorScheme.error), - onPressed: () => context.read().add(ListItemRemoved(listItemUri: item.uri)), - ), ); }, ), diff --git a/lib/features/lists/presentation/my_lists_screen.dart b/lib/features/lists/presentation/my_lists_screen.dart index f7cfe75..f1bf955 100644 --- a/lib/features/lists/presentation/my_lists_screen.dart +++ b/lib/features/lists/presentation/my_lists_screen.dart @@ -1,15 +1,20 @@ import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 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/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; import 'package:lazurite/features/lists/data/list_repository.dart'; import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; class MyListsScreen extends StatelessWidget { const MyListsScreen({super.key}); @@ -33,6 +38,7 @@ class _MyListsView extends StatefulWidget { class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderStateMixin { late final TabController _tabController; + final Set _seenListUris = {}; @override void initState() { @@ -108,11 +114,18 @@ class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderSta ); }, ), - floatingActionButton: FloatingActionButton( - heroTag: 'my-lists-fab', - onPressed: () => _showCreateDialog(context), - child: const Icon(Icons.add), - ), + floatingActionButton: + FloatingActionButton( + heroTag: 'my-lists-fab', + onPressed: () => _showCreateDialog(context), + 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), + ], + ), ); } @@ -121,15 +134,23 @@ class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderSta return const EmptyState(message: 'No lists yet', icon: Icons.list_alt_outlined); } - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: () => context.read().refresh(), child: ListView.builder( itemCount: lists.length, - itemBuilder: (context, index) => ListRowTile( - key: ValueKey(lists[index].uri), - list: lists[index], - onTap: () => context.push('/list?uri=${Uri.encodeComponent(lists[index].uri.toString())}'), - ), + itemBuilder: (context, index) { + final list = lists[index]; + return StaggeredEntrance( + itemKey: list.uri.toString(), + index: index, + seenKeys: _seenListUris, + child: ListRowTile( + key: ValueKey(list.uri), + list: list, + onTap: () => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'), + ), + ); + }, ), ); } diff --git a/lib/features/messages/presentation/widgets/convo_list_pane.dart b/lib/features/messages/presentation/widgets/convo_list_pane.dart index 7f0a13d..f1c8130 100644 --- a/lib/features/messages/presentation/widgets/convo_list_pane.dart +++ b/lib/features/messages/presentation/widgets/convo_list_pane.dart @@ -7,9 +7,11 @@ import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; class ConvoListPane extends StatefulWidget { @@ -23,6 +25,7 @@ class ConvoListPane extends StatefulWidget { class _ConvoListPaneState extends State { final ScrollController _scrollController = ScrollController(); + final Set _seenConvoIds = {}; @override void initState() { @@ -102,7 +105,7 @@ class _ConvoListPaneState extends State { if (isOffline) { return const _OfflineConvoState(); } - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: _onRefresh, child: ListView( controller: _scrollController, @@ -121,24 +124,29 @@ class _ConvoListPaneState extends State { final currentUserDid = _currentUserDid(context); - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: _onRefresh, child: ListView.builder( controller: _scrollController, itemCount: filtered.length, itemBuilder: (context, index) { final convo = filtered[index]; - return ConvoListItem( - convo: convo, - currentUserDid: currentUserDid, - onTap: () => _openThread(context, convo, currentUserDid), - onMuteTap: () { - if (convo.muted) { - context.read().add(ConvoUnmuted(convoId: convo.id)); - } else { - context.read().add(ConvoMuted(convoId: convo.id)); - } - }, + return StaggeredEntrance( + itemKey: convo.id, + index: index, + seenKeys: _seenConvoIds, + child: ConvoListItem( + convo: convo, + currentUserDid: currentUserDid, + onTap: () => _openThread(context, convo, currentUserDid), + onMuteTap: () { + if (convo.muted) { + context.read().add(ConvoUnmuted(convoId: convo.id)); + } else { + context.read().add(ConvoMuted(convoId: convo.id)); + } + }, + ), ); }, ), diff --git a/lib/features/notifications/presentation/widgets/notifications_pane.dart b/lib/features/notifications/presentation/widgets/notifications_pane.dart index b68fa1a..4f2de56 100644 --- a/lib/features/notifications/presentation/widgets/notifications_pane.dart +++ b/lib/features/notifications/presentation/widgets/notifications_pane.dart @@ -6,9 +6,11 @@ import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; class NotificationsPane extends StatefulWidget { @@ -20,6 +22,7 @@ class NotificationsPane extends StatefulWidget { class _NotificationsPaneState extends State { final ScrollController _scrollController = ScrollController(); + final Set _seenNotificationKeys = {}; @override void initState() { @@ -85,7 +88,7 @@ class _NotificationsPaneState extends State { final groupedNotifications = _groupNotificationsByDay(state.notifications); - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: _onRefresh, child: ListView.builder( controller: _scrollController, @@ -96,10 +99,11 @@ class _NotificationsPaneState extends State { if (item is String) { return _DayHeader(title: item); } else if (item is NotificationGroup) { - if (item.count == 1) { - return NotificationListItem(notification: item.latest); - } - return GroupedNotificationListItem(group: item); + final key = '${item.latest.uri}:${item.latest.indexedAt.toIso8601String()}'; + final child = item.count == 1 + ? NotificationListItem(notification: item.latest) + : GroupedNotificationListItem(group: item); + return StaggeredEntrance(itemKey: key, index: index, seenKeys: _seenNotificationKeys, child: child); } else if (item == null) { return const Center( child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), diff --git a/lib/features/profile/presentation/follow_audit_screen.dart b/lib/features/profile/presentation/follow_audit_screen.dart index c795410..c096d3d 100644 --- a/lib/features/profile/presentation/follow_audit_screen.dart +++ b/lib/features/profile/presentation/follow_audit_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; class FollowAuditScreen extends StatelessWidget { const FollowAuditScreen({super.key}); @@ -439,34 +440,47 @@ class _FilterTile extends StatelessWidget { } } -class _ResultsPanel extends StatelessWidget { +class _ResultsPanel extends StatefulWidget { const _ResultsPanel({required this.state, required this.visibleEntries}); final FollowAuditState state; final List<({int index, ClassifiedFollow item})> visibleEntries; + @override + State<_ResultsPanel> createState() => _ResultsPanelState(); +} + +class _ResultsPanelState extends State<_ResultsPanel> { + final Set _seenRows = {}; + @override Widget build(BuildContext context) { - if (state.status == FollowAuditStatus.initial) { + if (widget.state.status == FollowAuditStatus.initial) { return const Center(child: Text('Tap Scan to audit your follow list.')); } - if (state.results.isEmpty && - (state.status == FollowAuditStatus.ready || state.status == FollowAuditStatus.complete)) { + if (widget.state.results.isEmpty && + (widget.state.status == FollowAuditStatus.ready || widget.state.status == FollowAuditStatus.complete)) { return const Center(child: Text('No problematic follows found', key: Key('follow_audit_empty_message'))); } - if (visibleEntries.isEmpty && state.results.isNotEmpty) { + if (widget.visibleEntries.isEmpty && widget.state.results.isNotEmpty) { return const Center(child: Text('No results visible for the current filters.')); } return ListView.separated( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - itemCount: visibleEntries.length, + itemCount: widget.visibleEntries.length, separatorBuilder: (context, index) => const SizedBox(height: 8), itemBuilder: (context, index) { - final entry = visibleEntries[index]; - return _ResultRow(index: entry.index, item: entry.item); + final entry = widget.visibleEntries[index]; + final rowKey = '${entry.item.record.subjectDid}:${entry.item.record.rkey}'; + return StaggeredEntrance( + itemKey: rowKey, + index: index, + seenKeys: _seenRows, + child: _ResultRow(index: entry.index, item: entry.item), + ); }, ); } diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 565447a..c65ca00 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -3,10 +3,13 @@ import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; import 'package:bluesky/moderation.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'; 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'; @@ -67,6 +70,7 @@ class _ProfileScreenState extends State with TickerProviderStateM late TabController _tabController; late bool _showSuggestedTab; + double _coverScrollOffset = 0; @override void initState() { @@ -167,76 +171,101 @@ class _ProfileScreenState extends State with TickerProviderStateM if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), ]; - return NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverAppBar( - floating: true, - pinned: true, - snap: true, - title: Text(_appBarTitle(profile)), - leading: widget.showBackButton - ? IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), - ) - : const AppShellMenuButton(), - actions: [ - if (profile != null && isOwnProfile) + return NotificationListener( + onNotification: (notification) { + if (notification.metrics.axis != Axis.vertical) { + return false; + } + final offset = notification.metrics.pixels; + if ((offset - _coverScrollOffset).abs() >= 1) { + setState(() => _coverScrollOffset = offset); + } + return false; + }, + child: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverAppBar( + floating: true, + pinned: true, + snap: true, + title: Text(_appBarTitle(profile)), + leading: widget.showBackButton + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), + ) + : const AppShellMenuButton(), + actions: [ + if (profile != null && isOwnProfile) + IconButton( + key: const Key('profile_more_button'), + icon: const Icon(Icons.more_vert), + onPressed: () => _showOwnProfileMoreOptions(context, profile), + ), IconButton( - key: const Key('profile_more_button'), - icon: const Icon(Icons.more_vert), - onPressed: () => _showOwnProfileMoreOptions(context, profile), + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.go('/settings'), ), - IconButton( - icon: const Icon(Icons.settings_outlined), - onPressed: () => context.go('/settings'), - ), - ], - ), - SliverToBoxAdapter(child: _buildCoverSection(context, profile)), - SliverToBoxAdapter( - child: switch (profileState.status) { - ProfileStatus.loading => const Padding( - padding: AppInsets.allLg, - child: Center(child: CircularProgressIndicator()), - ), - ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), - _ => _buildProfileSummary(context, profile, isOwnProfile), - }, - ), - SliverPersistentHeader( - pinned: true, - delegate: SliverTabBarDelegate( - TabBar( - controller: _tabController, - tabs: [for (final label in _tabLabels) Tab(text: label)], - onTap: (index) { - if (index < _feedTabs.length) { - _loadProfileAndFeed(filter: _feedTabs[index].filter); - } - }, - isScrollable: true, - tabAlignment: TabAlignment.start, - labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), - unselectedLabelStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 2.2, + ], + ), + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), + SliverToBoxAdapter( + child: switch (profileState.status) { + ProfileStatus.loading => const Padding( + padding: AppInsets.allLg, + child: Center(child: CircularProgressIndicator()), + ), + ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), + _ => _buildProfileSummary(context, profile, isOwnProfile), + }, + ), + SliverPersistentHeader( + pinned: true, + delegate: SliverTabBarDelegate( + TabBar( + controller: _tabController, + tabs: [for (final label in _tabLabels) Tab(text: label)], + onTap: (index) { + if (index < _feedTabs.length) { + _loadProfileAndFeed(filter: _feedTabs[index].filter); + } + }, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.2, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 2.2, + ), + indicatorWeight: 2, ), - indicatorWeight: 2, ), ), - ), - ]; - }, - body: TabBarView(controller: _tabController, children: tabChildren), + ]; + }, + body: TabBarView(controller: _tabController, children: tabChildren), + ), ); }, ); }, ), - floatingActionButton: _buildComposeFab(context), + floatingActionButton: AnimatedSwitcher( + duration: Anim.feedItem, + switchInCurve: Anim.enter, + switchOutCurve: Anim.exit, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition(scale: animation, child: child), + ), + child: _buildComposeFab(context), + ), ), ); } @@ -274,11 +303,14 @@ class _ProfileScreenState extends State with TickerProviderStateM left: 0, right: 0, height: coverHeight, - child: Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), + child: Transform.translate( + offset: Offset(0, -1.0 * (_coverScrollOffset * 0.5).clamp(0, coverHeight * 0.3).toDouble()), + child: Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), + ), + child: Opacity(opacity: 0.5, child: coverContent), ), - child: Opacity(opacity: 0.5, child: coverContent), ), ), Positioned( @@ -655,11 +687,11 @@ class _ProfileScreenState extends State with TickerProviderStateM return _SuggestedFollowsTab(actor: actor, onProfileTap: (target) => navigateToProfile(context, target.did)); } - Widget? _buildComposeFab(BuildContext context) { + Widget _buildComposeFab(BuildContext context) { return BlocBuilder( builder: (context, state) { final profile = state.profile; - if (profile == null) return const SizedBox.shrink(); + if (profile == null) return const SizedBox.shrink(key: ValueKey('profile-compose-fab-empty')); final currentUserDid = context.read().state.tokens?.did; final isOwnProfile = profile.did == currentUserDid; @@ -667,12 +699,19 @@ class _ProfileScreenState extends State with TickerProviderStateM final isOffline = context.select((cubit) => cubit.state.isOffline); return FloatingActionButton( + key: const ValueKey('profile-compose-fab'), heroTag: 'profile-compose-fab', tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 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/lib/features/search/presentation/hashtag_screen.dart b/lib/features/search/presentation/hashtag_screen.dart index ee517a9..df4d5a7 100644 --- a/lib/features/search/presentation/hashtag_screen.dart +++ b/lib/features/search/presentation/hashtag_screen.dart @@ -3,8 +3,11 @@ import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:bluesky/moderation.dart' as bsky_moderation; 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/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; @@ -14,6 +17,8 @@ import 'package:lazurite/features/search/data/hashtag_utils.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -28,6 +33,7 @@ class HashtagScreen extends StatefulWidget { class _HashtagScreenState extends State { final ScrollController _scrollController = ScrollController(); + final Set _seenPostUris = {}; @override void initState() { @@ -142,6 +148,12 @@ class _HashtagScreenState extends State { onPressed: state.isMissingTag ? null : () => _openHashtagJumpSheet(state), icon: const Icon(Icons.tag), label: const Text('Jump to hashtag'), + ).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), + ], ); }, ), @@ -241,7 +253,7 @@ class _HashtagScreenState extends State { return Center(child: Text('No posts found for #${state.tag}.')); } - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: () => context.read().refreshCurrent(), child: ListView.builder( controller: _scrollController, @@ -252,7 +264,13 @@ class _HashtagScreenState extends State { child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), ); } - return _HashtagPostCard(post: timeline.posts[index]); + final post = timeline.posts[index]; + return StaggeredEntrance( + itemKey: post.uri.toString(), + index: index, + seenKeys: _seenPostUris, + child: _HashtagPostCard(post: post), + ); }, ), ); diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index d4b9584..5199b05 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -3,9 +3,12 @@ import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:bluesky/moderation.dart' as bsky_moderation; 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/router/app_shell.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.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'; @@ -19,6 +22,7 @@ import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -33,6 +37,7 @@ class _SearchScreenState extends State { final TextEditingController _searchController = TextEditingController(); final FocusNode _focusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); + final Set _seenResultKeys = {}; @override void initState() { @@ -218,11 +223,18 @@ class _SearchScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - floatingActionButton: FloatingActionButton.extended( - onPressed: _openJumpToProfileDialog, - icon: const Icon(Icons.person_search), - label: const Text('Jump to profile'), - ), + floatingActionButton: + FloatingActionButton.extended( + onPressed: _openJumpToProfileDialog, + icon: const Icon(Icons.person_search), + label: const Text('Jump to profile'), + ).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), + ], + ), body: SafeArea( child: BlocBuilder( builder: (context, state) { @@ -511,7 +523,13 @@ class _SearchScreenState extends State { child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), ); } - return _PostViewCard(post: posts[index]); + final post = posts[index]; + return StaggeredEntrance( + itemKey: post.uri.toString(), + index: index, + seenKeys: _seenResultKeys, + child: _PostViewCard(post: post), + ); }, ); } @@ -531,7 +549,13 @@ class _SearchScreenState extends State { child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), ); } - return _ActorResultTile(actor: actors[index]); + final actor = actors[index]; + return StaggeredEntrance( + itemKey: actor.did, + index: index, + seenKeys: _seenResultKeys, + child: _ActorResultTile(actor: actor), + ); }, ); } @@ -553,16 +577,21 @@ class _SearchScreenState extends State { } final feed = feeds[index]; - return _FeedResultTile( - feed: feed, - onAdded: (displayName) { - showAppSnackBar( - context, - 'Added $displayName to your saved feeds', - actionLabel: 'Manage', - onAction: () => GoRouter.maybeOf(context)?.push('/feeds'), - ); - }, + return StaggeredEntrance( + itemKey: feed.uri.toString(), + index: index, + seenKeys: _seenResultKeys, + child: _FeedResultTile( + feed: feed, + onAdded: (displayName) { + showAppSnackBar( + context, + 'Added $displayName to your saved feeds', + actionLabel: 'Manage', + onAction: () => GoRouter.maybeOf(context)?.push('/feeds'), + ); + }, + ), ); }, ); @@ -584,14 +613,19 @@ class _SearchScreenState extends State { ); } final pack = packs[index]; - return StarterPackCard( - pack: pack, - onTap: () { - final router = GoRouter.maybeOf(context); - if (router != null) { - router.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'); - } - }, + return StaggeredEntrance( + itemKey: pack.uri.toString(), + index: index, + seenKeys: _seenResultKeys, + child: StarterPackCard( + pack: pack, + onTap: () { + final router = GoRouter.maybeOf(context); + if (router != null) { + router.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'); + } + }, + ), ); }, ); diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart index d2b09df..4f8bde1 100644 --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -12,6 +12,7 @@ class SettingsCubit extends Cubit { AppThemeVariant? initialVariant, bool? initialUseSystemTheme, FeedLayout? initialFeedLayout, + bool? initialAnimationsEnabled, bool? initialSimulateOffline, int? initialThreadAutoCollapseDepth, String? initialConstellationUrl, @@ -21,6 +22,7 @@ class SettingsCubit extends Cubit { themeVariant: initialVariant ?? AppThemeVariant.dark, useSystemTheme: initialUseSystemTheme ?? false, feedLayout: initialFeedLayout ?? FeedLayout.card, + animationsEnabled: initialAnimationsEnabled ?? true, simulateOffline: initialSimulateOffline ?? false, threadAutoCollapseDepth: initialThreadAutoCollapseDepth, constellationUrl: initialConstellationUrl ?? 'https://constellation.microcosm.blue', @@ -34,6 +36,7 @@ class SettingsCubit extends Cubit { static const String _keyUseSystemTheme = 'use_system_theme'; static const String _keyFeedLayout = 'feed_layout'; static const String _legacyKeyFeedArchitecture = 'feed_architecture'; + static const String _keyAnimationsEnabled = 'animations_enabled'; static const String _keySimulateOffline = 'simulate_offline'; static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; static const String _keyConstellationUrl = 'constellation_url'; @@ -48,6 +51,7 @@ class SettingsCubit extends Cubit { final useSystemStr = await database.getSetting(_keyUseSystemTheme); final feedLayoutStr = await database.getSetting(_keyFeedLayout) ?? await database.getSetting(_legacyKeyFeedArchitecture); + final animationsEnabledStr = await database.getSetting(_keyAnimationsEnabled); final simulateOfflineStr = await database.getSetting(_keySimulateOffline); final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); final constellationUrlStr = await database.getSetting(_keyConstellationUrl); @@ -61,6 +65,7 @@ class SettingsCubit extends Cubit { themeVariant: AppTheme.parseVariant(variantStr), useSystemTheme: useSystemStr == 'true', feedLayout: FeedLayout.fromString(feedLayoutStr), + animationsEnabled: animationsEnabledStr != 'false', simulateOffline: simulateOfflineStr == 'true', threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, @@ -98,6 +103,11 @@ class SettingsCubit extends Cubit { emit(state.copyWith(feedLayout: layout)); } + Future setAnimationsEnabled(bool value) async { + await database.setSetting(_keyAnimationsEnabled, value.toString()); + emit(state.copyWith(animationsEnabled: value)); + } + Future setSimulateOffline(bool value) async { await database.setSetting(_keySimulateOffline, value.toString()); emit(state.copyWith(simulateOffline: value)); diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart index 814ab56..ae4d3f0 100644 --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -11,6 +11,7 @@ class SettingsState extends Equatable { required this.themeVariant, required this.useSystemTheme, this.feedLayout = FeedLayout.card, + this.animationsEnabled = true, this.simulateOffline = false, this.threadAutoCollapseDepth, this.constellationUrl = 'https://constellation.microcosm.blue', @@ -23,6 +24,7 @@ class SettingsState extends Equatable { final AppThemeVariant themeVariant; final bool useSystemTheme; final FeedLayout feedLayout; + final bool animationsEnabled; final bool simulateOffline; final int? threadAutoCollapseDepth; final String constellationUrl; @@ -41,6 +43,7 @@ class SettingsState extends Equatable { AppThemeVariant? themeVariant, bool? useSystemTheme, FeedLayout? feedLayout, + bool? animationsEnabled, bool? simulateOffline, Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, String? constellationUrl, @@ -53,6 +56,7 @@ class SettingsState extends Equatable { themeVariant: themeVariant ?? this.themeVariant, useSystemTheme: useSystemTheme ?? this.useSystemTheme, feedLayout: feedLayout ?? this.feedLayout, + animationsEnabled: animationsEnabled ?? this.animationsEnabled, simulateOffline: simulateOffline ?? this.simulateOffline, threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) ? this.threadAutoCollapseDepth @@ -70,6 +74,7 @@ class SettingsState extends Equatable { themeVariant, useSystemTheme, feedLayout, + animationsEnabled, simulateOffline, threadAutoCollapseDepth, constellationUrl, diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 48a7527..5f86807 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -286,6 +286,16 @@ class SettingsScreen extends StatelessWidget { labelBuilder: (depth) => depth == null ? 'Off' : 'Depth $depth', onChanged: settingsCubit.setThreadAutoCollapseDepth, ), + const Divider(height: 1), + _SettingsTile( + icon: Icons.motion_photos_off_outlined, + title: 'Animations', + subtitle: 'Turn off non-essential motion effects', + trailing: Switch.adaptive( + value: state.animationsEnabled, + onChanged: settingsCubit.setAnimationsEnabled, + ), + ), ], ), ); diff --git a/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart b/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart index 295fa37..c2fc4c2 100644 --- a/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart +++ b/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart @@ -1,9 +1,14 @@ 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/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; +import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; class ActorStarterPacksScreen extends StatelessWidget { const ActorStarterPacksScreen({super.key, required this.actor}); @@ -24,6 +29,7 @@ class _ActorStarterPacksView extends StatelessWidget { const _ActorStarterPacksView({required this.actor}); final String actor; + static final Set _seenPackUris = {}; @override Widget build(BuildContext context) { @@ -40,6 +46,12 @@ class _ActorStarterPacksView extends StatelessWidget { onPressed: () => context.push('/create-starter-pack'), tooltip: 'Create starter pack', 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), + ], ) : null, body: BlocBuilder( @@ -68,7 +80,7 @@ class _ActorStarterPacksView extends StatelessWidget { return const Center(child: Text('No starter packs yet')); } - return RefreshIndicator( + return AnimatedRefreshIndicator( onRefresh: () => context.read().refresh(), child: NotificationListener( onNotification: (notification) { @@ -91,10 +103,15 @@ class _ActorStarterPacksView extends StatelessWidget { } final pack = state.starterPacks[index]; - return StarterPackCard( - key: ValueKey(pack.uri), - pack: pack, - onTap: () => context.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'), + return StaggeredEntrance( + itemKey: pack.uri.toString(), + index: index, + seenKeys: _seenPackUris, + child: StarterPackCard( + key: ValueKey(pack.uri), + pack: pack, + onTap: () => context.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'), + ), ); }, ), diff --git a/lib/shared/presentation/helpers/snackbar_helper.dart b/lib/shared/presentation/helpers/snackbar_helper.dart index 1350037..055cd39 100644 --- a/lib/shared/presentation/helpers/snackbar_helper.dart +++ b/lib/shared/presentation/helpers/snackbar_helper.dart @@ -1,4 +1,7 @@ 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'; import 'package:lazurite/core/theme/theme_extensions.dart'; ScaffoldFeatureController showAppSnackBar( @@ -18,10 +21,19 @@ ScaffoldFeatureController showAppSnackBar( messenger.hideCurrentSnackBar(); } + final snackBarBehavior = behavior ?? SnackBarBehavior.floating; + final animatedContent = Text(message).animateIfAllowed( + context, + effects: const [ + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), + SlideEffect(begin: Offset(0, 0.25), end: Offset.zero, duration: Anim.feedItem, curve: Anim.enter), + ], + ); + return messenger.showSnackBar( SnackBar( - content: Text(message), - behavior: behavior, + content: animatedContent, + behavior: snackBarBehavior, duration: duration ?? const Duration(seconds: 4), backgroundColor: isError ? colorScheme.error : null, action: actionLabel == null ? null : SnackBarAction(label: actionLabel, onPressed: onAction ?? () {}), diff --git a/lib/shared/presentation/widgets/animated_refresh_indicator.dart b/lib/shared/presentation/widgets/animated_refresh_indicator.dart new file mode 100644 index 0000000..7f01921 --- /dev/null +++ b/lib/shared/presentation/widgets/animated_refresh_indicator.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; + +class AnimatedRefreshIndicator extends StatefulWidget { + const AnimatedRefreshIndicator({super.key, required this.onRefresh, required this.child, this.displacement = 40}); + + final RefreshCallback onRefresh; + final Widget child; + final double displacement; + + @override + State createState() => _AnimatedRefreshIndicatorState(); +} + +class _AnimatedRefreshIndicatorState extends State with SingleTickerProviderStateMixin { + late final AnimationController _rotationController; + bool _refreshing = false; + + @override + void initState() { + super.initState(); + _rotationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 700)); + } + + @override + void dispose() { + _rotationController.dispose(); + super.dispose(); + } + + Future _handleRefresh() async { + if (_refreshing) { + return; + } + + setState(() => _refreshing = true); + unawaited(_rotationController.repeat()); + + try { + await widget.onRefresh(); + } finally { + _rotationController.stop(); + if (mounted) { + setState(() => _refreshing = false); + } + } + } + + @override + Widget build(BuildContext context) => Stack( + children: [ + RefreshIndicator(onRefresh: _handleRefresh, displacement: widget.displacement, child: widget.child), + if (animationsAllowed(context)) + Positioned( + top: 12, + right: 16, + child: IgnorePointer( + child: AnimatedOpacity( + duration: Anim.fast, + opacity: _refreshing ? 1 : 0, + child: AnimatedScale( + duration: Anim.refreshComplete, + scale: _refreshing ? 1 : 0.8, + curve: Anim.enter, + child: RotationTransition( + turns: _rotationController, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.88), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: const Padding(padding: EdgeInsets.all(8), child: Icon(Icons.sync, size: 16)), + ), + ), + ), + ), + ), + ), + ], + ); +} diff --git a/lib/shared/presentation/widgets/empty_state.dart b/lib/shared/presentation/widgets/empty_state.dart index 42ec6ed..d909657 100644 --- a/lib/shared/presentation/widgets/empty_state.dart +++ b/lib/shared/presentation/widgets/empty_state.dart @@ -1,4 +1,7 @@ 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'; import 'package:lazurite/core/theme/theme_extensions.dart'; class EmptyState extends StatelessWidget { @@ -21,22 +24,34 @@ class EmptyState extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = context.colorScheme; final textTheme = context.textTheme; + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: colorScheme.outline), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ..._subtitle(subtitle, textTheme, colorScheme), + ..._action(action), + ], + ); return Center( child: Padding( padding: padding, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 48, color: colorScheme.outline), - const SizedBox(height: 12), - Text( - message, - textAlign: TextAlign.center, - style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), + child: content.animateIfAllowed( + context, + effects: const [ + FadeEffect(duration: Anim.screenTransition, curve: Anim.enter), + ScaleEffect( + begin: Offset(0.95, 0.95), + end: Offset(1, 1), + duration: Anim.screenTransition, + curve: Anim.enter, ), - ..._subtitle(subtitle, textTheme, colorScheme), - ..._action(action), ], ), ), diff --git a/lib/shared/presentation/widgets/loading_state.dart b/lib/shared/presentation/widgets/loading_state.dart index bd4e29a..180d028 100644 --- a/lib/shared/presentation/widgets/loading_state.dart +++ b/lib/shared/presentation/widgets/loading_state.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lazurite/shared/presentation/widgets/shimmer_skeleton.dart'; class LoadingState extends StatelessWidget { const LoadingState({super.key, this.message, this.padding = const EdgeInsets.all(24)}); @@ -14,7 +15,12 @@ class LoadingState extends StatelessWidget { padding: padding, child: Column( mainAxisSize: MainAxisSize.min, - children: [const CircularProgressIndicator(), ..._message(message, theme.textTheme, theme.colorScheme)], + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const _LoadingSkeleton(), + ..._message(message, theme.textTheme, theme.colorScheme), + ], ), ), ); @@ -31,3 +37,21 @@ class LoadingState extends StatelessWidget { ), ]; } + +class _LoadingSkeleton extends StatelessWidget { + const _LoadingSkeleton(); + + @override + Widget build(BuildContext context) { + return const SizedBox( + width: 220, + child: Column( + children: [ + ShimmerSkeletonLine(width: 220, height: 12), + SizedBox(height: 8), + ShimmerSkeletonLine(width: 168, height: 12), + ], + ), + ); + } +} diff --git a/lib/shared/presentation/widgets/shimmer_skeleton.dart b/lib/shared/presentation/widgets/shimmer_skeleton.dart new file mode 100644 index 0000000..9c92a3f --- /dev/null +++ b/lib/shared/presentation/widgets/shimmer_skeleton.dart @@ -0,0 +1,24 @@ +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 ShimmerSkeletonLine extends StatelessWidget { + const ShimmerSkeletonLine({super.key, this.width, this.height = 14}); + + final double? width; + final double height; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: width, + height: height, + decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.zero), + ).animateIfAllowed( + context, + effects: [ShimmerEffect(duration: Anim.shimmerCycle, color: theme.colorScheme.surfaceContainerHigh)], + ); + } +} diff --git a/lib/shared/presentation/widgets/staggered_entrance.dart b/lib/shared/presentation/widgets/staggered_entrance.dart new file mode 100644 index 0000000..990d42f --- /dev/null +++ b/lib/shared/presentation/widgets/staggered_entrance.dart @@ -0,0 +1,42 @@ +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 StaggeredEntrance extends StatelessWidget { + const StaggeredEntrance({ + super.key, + required this.child, + required this.itemKey, + required this.index, + required this.seenKeys, + }); + + final Widget child; + final String itemKey; + final int index; + + /// Widget keys that have been seen before, used to determine whether to animate the child. + /// + /// Mark immediately so rebuilds during the same frame do not restart the entrance sequence. + final Set seenKeys; + + @override + Widget build(BuildContext context) { + final hasSeen = seenKeys.contains(itemKey); + if (hasSeen) { + return child; + } + + seenKeys.add(itemKey); + + return child.animateIfAllowed( + context, + delay: Anim.staggerFor(index), + effects: const [ + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), + SlideEffect(begin: Offset(0, 0.05), end: Offset.zero, duration: Anim.feedItem, curve: Anim.enter), + ], + ); + } +} diff --git a/test/core/theme/animation_tokens_test.dart b/test/core/theme/animation_tokens_test.dart new file mode 100644 index 0000000..ac1d7e9 --- /dev/null +++ b/test/core/theme/animation_tokens_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; + +void main() { + test('defines expected animation tokens', () { + expect(Anim.fast, const Duration(milliseconds: 150)); + expect(Anim.normal, const Duration(milliseconds: 250)); + expect(Anim.slow, const Duration(milliseconds: 400)); + + expect(Anim.feedItem, const Duration(milliseconds: 200)); + expect(Anim.screenTransition, const Duration(milliseconds: 300)); + expect(Anim.shimmerCycle, const Duration(milliseconds: 1200)); + + expect(Anim.enter, Curves.easeOut); + expect(Anim.exit, Curves.easeIn); + expect(Anim.emphasis, Curves.easeOutBack); + + expect(Anim.staggerOffset, const Duration(milliseconds: 50)); + expect(Anim.maxStaggerItems, 10); + }); + + test('staggerFor clamps index to max range', () { + expect(Anim.staggerFor(0), const Duration(milliseconds: 0)); + expect(Anim.staggerFor(1), const Duration(milliseconds: 50)); + expect(Anim.staggerFor(9), const Duration(milliseconds: 450)); + expect(Anim.staggerFor(50), const Duration(milliseconds: 450)); + }); +} diff --git a/test/core/theme/animation_utils_test.dart b/test/core/theme/animation_utils_test.dart new file mode 100644 index 0000000..5ad4242 --- /dev/null +++ b/test/core/theme/animation_utils_test.dart @@ -0,0 +1,74 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/core/theme/animation_tokens.dart'; +import 'package:lazurite/core/theme/animation_utils.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +class _AnimatedProbe extends StatelessWidget { + const _AnimatedProbe(); + + @override + Widget build(BuildContext context) { + return const Text('probe').animateIfAllowed(context, effects: const [FadeEffect(duration: Anim.fast)]); + } +} + +void main() { + Widget wrap(Widget child, {bool disableAnimations = false, SettingsCubit? settingsCubit}) { + final base = MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: Scaffold(body: Center(child: child)), + ), + ); + + if (settingsCubit == null) { + return base; + } + + return BlocProvider.value(value: settingsCubit, child: base); + } + + testWidgets('animateIfAllowed is disabled under widget tests', (tester) async { + await tester.pumpWidget(wrap(const _AnimatedProbe())); + await tester.pumpAndSettle(); + + expect(find.byType(Animate), findsNothing); + expect(find.text('probe'), findsOneWidget); + }); + + testWidgets('animateIfAllowed skips when platform reduced motion is enabled', (tester) async { + await tester.pumpWidget(wrap(const _AnimatedProbe(), disableAnimations: true)); + await tester.pumpAndSettle(); + + expect(find.byType(Animate), findsNothing); + expect(find.text('probe'), findsOneWidget); + }); + + testWidgets('animateIfAllowed skips when user disables animations', (tester) async { + final settingsCubit = MockSettingsCubit(); + const state = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + animationsEnabled: false, + ); + + when(() => settingsCubit.state).thenReturn(state); + whenListen(settingsCubit, const Stream.empty(), initialState: state); + + await tester.pumpWidget(wrap(const _AnimatedProbe(), settingsCubit: settingsCubit)); + await tester.pumpAndSettle(); + + expect(find.byType(Animate), findsNothing); + expect(find.text('probe'), findsOneWidget); + }); +} diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart index dca23fb..ad22955 100644 --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -25,6 +25,7 @@ void main() { expect(cubit.state.themeVariant, AppThemeVariant.dark); expect(cubit.state.useSystemTheme, false); expect(cubit.state.feedLayout, FeedLayout.card); + expect(cubit.state.animationsEnabled, true); expect(cubit.state.simulateOffline, false); expect(cubit.state.threadAutoCollapseDepth, isNull); }); @@ -36,6 +37,7 @@ void main() { initialVariant: AppThemeVariant.light, initialUseSystemTheme: true, initialFeedLayout: FeedLayout.compact, + initialAnimationsEnabled: false, initialSimulateOffline: true, initialThreadAutoCollapseDepth: 3, ); @@ -43,6 +45,7 @@ void main() { expect(cubit.state.themeVariant, AppThemeVariant.light); expect(cubit.state.useSystemTheme, true); expect(cubit.state.feedLayout, FeedLayout.compact); + expect(cubit.state.animationsEnabled, false); expect(cubit.state.simulateOffline, true); expect(cubit.state.threadAutoCollapseDepth, 3); }); @@ -55,6 +58,7 @@ void main() { await database.setSetting('theme_variant', 'light'); await database.setSetting('use_system_theme', 'true'); await database.setSetting('feed_architecture', 'linear'); + await database.setSetting('animations_enabled', 'false'); await database.setSetting('simulate_offline', 'true'); await database.setSetting('thread_auto_collapse_depth', '4'); }, @@ -65,6 +69,7 @@ void main() { .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.light) .having((s) => s.useSystemTheme, 'useSystemTheme', true) .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) + .having((s) => s.animationsEnabled, 'animationsEnabled', false) .having((s) => s.simulateOffline, 'simulateOffline', true) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), ], @@ -80,6 +85,7 @@ void main() { .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.dark) .having((s) => s.useSystemTheme, 'useSystemTheme', false) .having((s) => s.feedLayout, 'feedLayout', FeedLayout.card) + .having((s) => s.animationsEnabled, 'animationsEnabled', true) .having((s) => s.simulateOffline, 'simulateOffline', false) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), ], @@ -169,6 +175,17 @@ void main() { }, ); + blocTest( + 'setAnimationsEnabled updates state and persists to database', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setAnimationsEnabled(false), + expect: () => [isA().having((s) => s.animationsEnabled, 'animationsEnabled', false)], + verify: (cubit) async { + final value = await database.getSetting('animations_enabled'); + expect(value, 'false'); + }, + ); + blocTest( 'setSimulateOffline updates state and persists to database', build: () => SettingsCubit(database: database), diff --git a/test/features/settings/bloc/settings_state_test.dart b/test/features/settings/bloc/settings_state_test.dart index 1ffa5d1..e0ffc03 100644 --- a/test/features/settings/bloc/settings_state_test.dart +++ b/test/features/settings/bloc/settings_state_test.dart @@ -82,6 +82,23 @@ void main() { expect(state1, isNot(equals(state2))); }); + test('inequality when animationsEnabled differs', () { + const state1 = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + animationsEnabled: true, + ); + const state2 = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + animationsEnabled: false, + ); + + expect(state1, isNot(equals(state2))); + }); + test('inequality when simulateOffline differs', () { const state1 = SettingsState( themePalette: AppThemePalette.oxocarbon, @@ -128,6 +145,7 @@ void main() { themeVariant: AppThemeVariant.light, useSystemTheme: true, feedLayout: FeedLayout.compact, + animationsEnabled: false, simulateOffline: true, threadAutoCollapseDepth: 3, ); @@ -136,6 +154,7 @@ void main() { expect(updated.themeVariant, AppThemeVariant.light); expect(updated.useSystemTheme, true); expect(updated.feedLayout, FeedLayout.compact); + expect(updated.animationsEnabled, false); expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 3); expect(original.themePalette, AppThemePalette.oxocarbon); @@ -147,6 +166,7 @@ void main() { themeVariant: AppThemeVariant.light, useSystemTheme: true, feedLayout: FeedLayout.compact, + animationsEnabled: false, simulateOffline: true, threadAutoCollapseDepth: 4, ); @@ -157,6 +177,7 @@ void main() { expect(updated.themeVariant, AppThemeVariant.light); expect(updated.useSystemTheme, true); expect(updated.feedLayout, FeedLayout.compact); + expect(updated.animationsEnabled, false); expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 4); }); @@ -180,6 +201,7 @@ void main() { themeVariant: AppThemeVariant.light, useSystemTheme: true, feedLayout: FeedLayout.compact, + animationsEnabled: false, simulateOffline: true, threadAutoCollapseDepth: 6, ); @@ -188,6 +210,7 @@ void main() { expect(state.props, contains(AppThemeVariant.light)); expect(state.props, contains(true)); expect(state.props, contains(FeedLayout.compact)); + expect(state.props, contains(false)); expect(state.props, contains(true)); expect(state.props, contains(6)); }); @@ -210,6 +233,15 @@ void main() { expect(state.simulateOffline, isFalse); }); + test('defaults animationsEnabled to true', () { + const state = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ); + expect(state.animationsEnabled, isTrue); + }); + test('defaults threadAutoCollapseDepth to null', () { const state = SettingsState( themePalette: AppThemePalette.oxocarbon, diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 3076326..26a86d9 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -117,6 +117,7 @@ void main() { expect(find.text('LAYOUT'), findsOneWidget); expect(find.text('Feed Layout'), findsOneWidget); expect(find.text('Thread Auto-Collapse'), findsOneWidget); + expect(find.text('Animations'), findsOneWidget); }); testWidgets('shows the AT Protocol connection card for the authenticated account', (tester) async { diff --git a/test/shared/presentation/widgets/empty_state_test.dart b/test/shared/presentation/widgets/empty_state_test.dart index 66abdf9..3468386 100644 --- a/test/shared/presentation/widgets/empty_state_test.dart +++ b/test/shared/presentation/widgets/empty_state_test.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; void main() { - Widget buildSubject(Widget child) { - return MaterialApp(home: Scaffold(body: child)); + Widget buildSubject(Widget child, {bool disableAnimations = false}) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: Scaffold(body: child), + ), + ); } testWidgets('renders message and icon', (tester) async { @@ -29,4 +35,11 @@ void main() { expect(find.text('Add feeds to continue.'), findsOneWidget); expect(find.text('Manage Feeds'), findsOneWidget); }); + + testWidgets('skips entrance animation when reduced motion is enabled', (tester) async { + await tester.pumpWidget(buildSubject(const EmptyState(message: 'Nothing here'), disableAnimations: true)); + + expect(find.byType(Animate), findsNothing); + expect(find.text('Nothing here'), findsOneWidget); + }); }