mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: animated transitions

+1262 -380
+5
docs/TODO.md
··· 70 70 3. Lists 71 71 4. Starter Packs 72 72 5. Download images & videos 73 + 74 + --- 75 + 76 + - Last read position 77 + - Autothreading of posts over char limit; splitting posts
+14 -14
docs/tasks/phase-8.md
··· 12 12 Spec: [animate.md](../specs/animate.md) 13 13 14 14 - [x] Add `flutter_animate` dependency 15 - - [ ] Create `lib/core/theme/animation_tokens.dart` with centralised durations, curves, stagger constants 16 - - [ ] Add reduced-motion utility (`animateIfAllowed` extension) 17 - - [ ] Add setting for users to turn off animations 18 - - [ ] Feed & list items: staggered fade-in + slide-up on first appearance (track seen items to avoid re-animation) 19 - - [ ] Action feedback: scale-bounce on like / repost / bookmark tap 20 - - [ ] Screen transitions: fade-through `TransitionPage` wrapper for GoRouter 21 - - [ ] Shimmer loading: replace static skeleton placeholders with `.shimmer()` sweep 22 - - [ ] Bottom nav bar: scale + crossfade on active icon change 23 - - [ ] Snackbar / toast: slide-up entrance, fade-out dismiss 24 - - [ ] FAB / action buttons: scale-in on appear, scale-out on disappear 25 - - [ ] Pull-to-refresh: rotate + fade-out on completion 26 - - [ ] Profile header: parallax banner on scroll 27 - - [ ] Empty state: gentle fade + scale entrance 28 - - [ ] Widget tests for all animated widgets (pumpAndSettle, reduced-motion branch) 15 + - [x] Create `lib/core/theme/animation_tokens.dart` with centralised durations, curves, stagger constants 16 + - [x] Add reduced-motion utility (`animateIfAllowed` extension) 17 + - [x] Add setting for users to turn off animations 18 + - [x] Feed & list items: staggered fade-in + slide-up on first appearance (track seen items to avoid re-animation) 19 + - [x] Action feedback: scale-bounce on like / repost / bookmark tap 20 + - [x] Screen transitions: fade-through `TransitionPage` wrapper for GoRouter 21 + - [x] Shimmer loading: replace static skeleton placeholders with `.shimmer()` sweep 22 + - [x] Bottom nav bar: scale + crossfade on active icon change 23 + - [x] Snackbar / toast: slide-up entrance, fade-out dismiss 24 + - [x] FAB / action buttons: scale-in on appear, scale-out on disappear 25 + - [x] Pull-to-refresh: rotate + fade-out on completion 26 + - [x] Profile header: parallax banner on scroll 27 + - [x] Empty state: gentle fade + scale entrance 28 + - [x] Widget tests for all animated widgets (pumpAndSettle, reduced-motion branch)
+172 -117
lib/core/router/app_router.dart
··· 1 1 import 'dart:async'; 2 2 3 3 import 'package:atproto_core/atproto_core.dart' show AtUri; 4 + import 'package:bluesky/bluesky.dart'; 4 5 import 'package:flutter/material.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 7 import 'package:go_router/go_router.dart'; 6 - import 'package:bluesky/bluesky.dart'; 7 - import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 9 import 'package:lazurite/core/logging/app_logger.dart'; 10 - import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 10 + import 'package:lazurite/core/network/constellation_client.dart'; 11 11 import 'package:lazurite/core/router/app_shell.dart'; 12 + import 'package:lazurite/core/router/fade_through_page.dart'; 13 + import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 14 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 12 15 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 13 - import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 14 16 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 15 17 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 16 18 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; 17 19 import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 18 20 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; 19 21 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 20 - import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; 21 22 import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 22 - import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 23 + import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; 23 24 import 'package:lazurite/features/feed/presentation/media/video_player_route_args.dart'; 24 25 import 'package:lazurite/features/feed/presentation/media/video_player_screen.dart'; 25 - import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 26 - import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 27 - import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 28 - import 'package:lazurite/features/notifications/data/notification_repository.dart'; 29 - import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 30 - import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 26 + import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 31 27 import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; 32 - import 'package:lazurite/features/search/presentation/search_screen.dart'; 33 - import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 34 - import 'package:lazurite/features/search/data/hashtag_utils.dart'; 35 - import 'package:lazurite/features/search/data/search_repository.dart'; 36 - import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; 37 - import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 38 - import 'package:lazurite/features/messages/data/convo_repository.dart'; 39 - import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 40 - import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; 41 28 import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 42 29 import 'package:lazurite/features/lists/data/list_repository.dart'; 43 30 import 'package:lazurite/features/lists/presentation/list_detail_screen.dart'; 44 31 import 'package:lazurite/features/lists/presentation/list_members_screen.dart'; 45 32 import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; 33 + import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 34 + import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 35 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 36 + import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 37 + import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; 46 38 import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; 47 39 import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; 48 - import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 49 - import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 50 - import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; 51 - import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 52 - import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 40 + import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 41 + import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 42 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 43 + import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 53 44 import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; 54 - import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 45 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 55 46 import 'package:lazurite/features/profile/data/profile_context_repository.dart'; 56 - import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 47 + import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 57 48 import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 58 - import 'package:lazurite/core/network/constellation_client.dart'; 49 + import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 50 + import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 51 + import 'package:lazurite/features/search/data/hashtag_utils.dart'; 52 + import 'package:lazurite/features/search/data/search_repository.dart'; 53 + import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; 54 + import 'package:lazurite/features/search/presentation/search_screen.dart'; 59 55 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 60 56 import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 61 57 import 'package:lazurite/features/settings/data/video_repository.dart'; ··· 64 60 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 65 61 import 'package:lazurite/features/settings/presentation/terms_of_service_screen.dart'; 66 62 import 'package:lazurite/features/settings/presentation/video_upload_limits_screen.dart'; 63 + import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 64 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 65 + import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; 66 + import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 67 + import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 67 68 68 69 ComposeRouteArgs parseComposeRouteExtra(Object? extra) { 69 70 if (extra is ComposeRouteArgs) { ··· 128 129 final GlobalKey<NavigatorState> _notificationsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'notifications'); 129 130 final GlobalKey<NavigatorState> _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile'); 130 131 132 + Page<dynamic> _page(BuildContext context, GoRouterState state, Widget child) => 133 + buildFadeThroughPage(context: context, state: state, child: child); 134 + 131 135 GoRouter get router => GoRouter( 132 136 navigatorKey: _rootNavigatorKey, 133 137 refreshListenable: GoRouterRefreshStream(authBloc.stream), ··· 150 154 return null; 151 155 }, 152 156 routes: [ 153 - GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), 154 - GoRoute(path: '/terms', builder: (context, state) => const TermsOfServiceScreen()), 155 - GoRoute(path: '/privacy', builder: (context, state) => const PrivacyPolicyScreen()), 157 + GoRoute(path: '/login', pageBuilder: (context, state) => _page(context, state, const LoginScreen())), 158 + GoRoute(path: '/terms', pageBuilder: (context, state) => _page(context, state, const TermsOfServiceScreen())), 159 + GoRoute(path: '/privacy', pageBuilder: (context, state) => _page(context, state, const PrivacyPolicyScreen())), 156 160 GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'), 157 161 GoRoute(path: '/messages', redirect: (_, _) => '/alerts/messages'), 158 162 GoRoute( 159 163 path: '/compose', 160 164 parentNavigatorKey: _rootNavigatorKey, 161 - builder: (context, state) { 165 + pageBuilder: (context, state) { 162 166 final args = parseComposeRouteExtra(state.extra); 163 - return BlocProvider( 164 - create: (_) => ComposeBloc( 165 - composeRepository: ComposeRepository(bluesky: context.read<Bluesky>()), 166 - database: context.read<AppDatabase>(), 167 - accountDid: context.read<String>(), 168 - ), 169 - child: ComposeScreen( 170 - replyParentUri: args.replyParentUri, 171 - replyParentCid: args.replyParentCid, 172 - replyRootUri: args.replyRootUri, 173 - replyRootCid: args.replyRootCid, 174 - replyAuthorHandle: args.replyAuthorHandle, 175 - quoteUri: args.quoteUri, 176 - quoteCid: args.quoteCid, 177 - quoteAuthorHandle: args.quoteAuthorHandle, 178 - draftId: args.draftId, 179 - initialText: args.initialText, 180 - editPostUri: args.editPostUri, 181 - editPostCid: args.editPostCid, 182 - editRecord: args.editRecord, 167 + return _page( 168 + context, 169 + state, 170 + BlocProvider( 171 + create: (_) => ComposeBloc( 172 + composeRepository: ComposeRepository(bluesky: context.read<Bluesky>()), 173 + database: context.read<AppDatabase>(), 174 + accountDid: context.read<String>(), 175 + ), 176 + child: ComposeScreen( 177 + replyParentUri: args.replyParentUri, 178 + replyParentCid: args.replyParentCid, 179 + replyRootUri: args.replyRootUri, 180 + replyRootCid: args.replyRootCid, 181 + replyAuthorHandle: args.replyAuthorHandle, 182 + quoteUri: args.quoteUri, 183 + quoteCid: args.quoteCid, 184 + quoteAuthorHandle: args.quoteAuthorHandle, 185 + draftId: args.draftId, 186 + initialText: args.initialText, 187 + editPostUri: args.editPostUri, 188 + editPostCid: args.editPostCid, 189 + editRecord: args.editRecord, 190 + ), 183 191 ), 184 192 ); 185 193 }, ··· 187 195 GoRoute( 188 196 path: '/post', 189 197 parentNavigatorKey: _rootNavigatorKey, 190 - builder: (context, state) { 198 + pageBuilder: (context, state) { 191 199 final uri = state.uri.queryParameters['uri'] ?? ''; 192 - return PostThreadScreen(postUri: Uri.decodeComponent(uri)); 200 + return _page(context, state, PostThreadScreen(postUri: Uri.decodeComponent(uri))); 193 201 }, 194 202 ), 195 203 GoRoute( 196 204 path: '/hashtag', 197 205 parentNavigatorKey: _rootNavigatorKey, 198 - builder: (context, state) { 206 + pageBuilder: (context, state) { 199 207 final normalizedTag = normalizeHashtag(state.uri.queryParameters['tag'] ?? ''); 200 - return BlocProvider( 201 - key: ValueKey('hashtag-$normalizedTag'), 202 - create: (_) => HashtagCubit(searchRepository: context.read<SearchRepository>(), tag: normalizedTag), 203 - child: HashtagScreen(tag: normalizedTag), 208 + return _page( 209 + context, 210 + state, 211 + BlocProvider( 212 + key: ValueKey('hashtag-$normalizedTag'), 213 + create: (_) => HashtagCubit(searchRepository: context.read<SearchRepository>(), tag: normalizedTag), 214 + child: HashtagScreen(tag: normalizedTag), 215 + ), 204 216 ); 205 217 }, 206 218 ), 207 219 GoRoute( 208 220 path: '/images', 209 221 parentNavigatorKey: _rootNavigatorKey, 210 - builder: (context, state) { 222 + pageBuilder: (context, state) { 211 223 final args = state.extra as ImageViewerRouteArgs; 212 - return ImageViewerScreen(args: args); 224 + return _page(context, state, ImageViewerScreen(args: args)); 213 225 }, 214 226 ), 215 227 GoRoute( 216 228 path: '/video', 217 229 parentNavigatorKey: _rootNavigatorKey, 218 - builder: (context, state) { 230 + pageBuilder: (context, state) { 219 231 final args = state.extra as VideoPlayerRouteArgs; 220 - return VideoPlayerScreen(args: args); 232 + return _page(context, state, VideoPlayerScreen(args: args)); 221 233 }, 222 234 ), 223 235 GoRoute( 224 236 path: '/saved', 225 - builder: (context, state) => SavedPostsScreen(accountDid: context.read<String>()), 237 + pageBuilder: (context, state) => _page(context, state, SavedPostsScreen(accountDid: context.read<String>())), 226 238 ), 227 - GoRoute(path: '/lists', builder: (context, state) => const MyListsScreen()), 239 + GoRoute(path: '/lists', pageBuilder: (context, state) => _page(context, state, const MyListsScreen())), 228 240 GoRoute( 229 241 path: '/create-starter-pack', 230 - builder: (context, state) { 231 - return BlocProvider( 232 - create: (_) => StarterPackBloc(starterPackRepository: context.read<StarterPackRepository>()), 233 - child: CreateStarterPackScreen(userDid: context.read<String>()), 242 + pageBuilder: (context, state) { 243 + return _page( 244 + context, 245 + state, 246 + BlocProvider( 247 + create: (_) => StarterPackBloc(starterPackRepository: context.read<StarterPackRepository>()), 248 + child: CreateStarterPackScreen(userDid: context.read<String>()), 249 + ), 234 250 ); 235 251 }, 236 252 ), 237 253 GoRoute( 238 254 path: '/starter-pack', 239 - builder: (context, state) { 255 + pageBuilder: (context, state) { 240 256 final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); 241 257 final packUri = AtUri.parse(uriStr); 242 - return StarterPackDetailScreen(packUri: packUri); 258 + return _page(context, state, StarterPackDetailScreen(packUri: packUri)); 243 259 }, 244 260 ), 245 261 GoRoute( 246 262 path: '/starter-packs', 247 - builder: (context, state) { 263 + pageBuilder: (context, state) { 248 264 final actor = state.uri.queryParameters['actor'] ?? ''; 249 - return ActorStarterPacksScreen(actor: actor); 265 + return _page(context, state, ActorStarterPacksScreen(actor: actor)); 250 266 }, 251 267 ), 252 268 GoRoute( 253 269 path: '/list', 254 - builder: (context, state) { 270 + pageBuilder: (context, state) { 255 271 final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); 256 272 final listUri = AtUri.parse(uriStr); 257 - return ListDetailScreen(listUri: listUri); 273 + return _page(context, state, ListDetailScreen(listUri: listUri)); 258 274 }, 259 275 routes: [ 260 276 GoRoute( 261 277 path: 'members', 262 - builder: (context, state) { 278 + pageBuilder: (context, state) { 263 279 final uriStr = Uri.decodeComponent(state.uri.queryParameters['uri'] ?? ''); 264 280 final listUri = AtUri.parse(uriStr); 265 - return BlocProvider( 266 - create: (_) => 267 - ListBloc(listRepository: context.read<ListRepository>())..add(ListRequested(listUri: listUri)), 268 - child: ListMembersScreen(listUri: listUri), 281 + return _page( 282 + context, 283 + state, 284 + BlocProvider( 285 + create: (_) => 286 + ListBloc(listRepository: context.read<ListRepository>())..add(ListRequested(listUri: listUri)), 287 + child: ListMembersScreen(listUri: listUri), 288 + ), 269 289 ); 270 290 }, 271 291 ), ··· 273 293 ), 274 294 GoRoute( 275 295 path: '/profile-context', 276 - builder: (context, state) { 296 + pageBuilder: (context, state) { 277 297 final did = state.uri.queryParameters['did'] ?? ''; 278 298 final handle = state.uri.queryParameters['handle'] ?? ''; 279 299 final isOwnProfile = did == context.read<String>(); ··· 283 303 publicBluesky: Bluesky.anonymous(service: profileContextPublicAppViewService), 284 304 constellationClient: ConstellationClient(baseUrl: constellationUrl), 285 305 ); 286 - return BlocProvider( 287 - create: (_) => ProfileContextCubit(repository: repository, did: did, isOwnProfile: isOwnProfile), 288 - child: ProfileContextScreen(handle: handle), 306 + return _page( 307 + context, 308 + state, 309 + BlocProvider( 310 + create: (_) => ProfileContextCubit(repository: repository, did: did, isOwnProfile: isOwnProfile), 311 + child: ProfileContextScreen(handle: handle), 312 + ), 289 313 ); 290 314 }, 291 315 ), ··· 322 346 routes: [ 323 347 GoRoute( 324 348 path: '/', 325 - builder: (context, state) => const HomeFeedScreen(), 349 + pageBuilder: (context, state) => _page(context, state, const HomeFeedScreen()), 326 350 routes: [ 327 - GoRoute(path: 'feeds', builder: (context, state) => const FeedManagementScreen()), 351 + GoRoute( 352 + path: 'feeds', 353 + pageBuilder: (context, state) => _page(context, state, const FeedManagementScreen()), 354 + ), 328 355 GoRoute( 329 356 path: 'settings', 330 - builder: (context, state) => const SettingsScreen(), 357 + pageBuilder: (context, state) => _page(context, state, const SettingsScreen()), 331 358 routes: [ 332 359 GoRoute( 333 360 path: 'moderation', 334 - builder: (context, state) => const ModerationSettingsScreen(), 361 + pageBuilder: (context, state) => _page(context, state, const ModerationSettingsScreen()), 335 362 routes: [ 336 363 GoRoute( 337 364 path: 'detail', 338 - builder: (context, state) => 339 - LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? ''), 365 + pageBuilder: (context, state) => 366 + _page(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), 340 367 ), 341 368 ], 342 369 ), 343 - GoRoute(path: 'about', builder: (context, state) => const AboutScreen()), 344 - GoRoute(path: 'logs', builder: (context, state) => const LogsScreen()), 370 + GoRoute( 371 + path: 'about', 372 + pageBuilder: (context, state) => _page(context, state, const AboutScreen()), 373 + ), 374 + GoRoute(path: 'logs', pageBuilder: (context, state) => _page(context, state, const LogsScreen())), 345 375 GoRoute( 346 376 path: 'clean-follows', 347 - builder: (context, state) => BlocProvider( 348 - create: (_) => FollowAuditCubit( 349 - repository: FollowAuditRepository(bluesky: context.read<Bluesky>()), 350 - ownDid: context.read<String>(), 377 + pageBuilder: (context, state) => _page( 378 + context, 379 + state, 380 + BlocProvider( 381 + create: (_) => FollowAuditCubit( 382 + repository: FollowAuditRepository(bluesky: context.read<Bluesky>()), 383 + ownDid: context.read<String>(), 384 + ), 385 + child: const FollowAuditScreen(), 351 386 ), 352 - child: const FollowAuditScreen(), 353 387 ), 354 388 ), 355 389 GoRoute( 356 390 path: 'devtools', 357 - builder: (context, state) => DevToolsScreen(initialQuery: state.uri.queryParameters['query']), 391 + pageBuilder: (context, state) => 392 + _page(context, state, DevToolsScreen(initialQuery: state.uri.queryParameters['query'])), 358 393 ), 359 394 GoRoute( 360 395 path: 'video-limits', 361 - builder: (context, state) => BlocProvider( 362 - create: (_) => VideoUploadLimitsCubit(repository: context.read<VideoRepository>()), 363 - child: const VideoUploadLimitsScreen(), 396 + pageBuilder: (context, state) => _page( 397 + context, 398 + state, 399 + BlocProvider( 400 + create: (_) => VideoUploadLimitsCubit(repository: context.read<VideoRepository>()), 401 + child: const VideoUploadLimitsScreen(), 402 + ), 364 403 ), 365 404 ), 366 405 ], ··· 371 410 ), 372 411 StatefulShellBranch( 373 412 navigatorKey: _searchNavigatorKey, 374 - routes: [GoRoute(path: '/search', builder: (context, state) => const SearchScreen())], 413 + routes: [ 414 + GoRoute(path: '/search', pageBuilder: (context, state) => _page(context, state, const SearchScreen())), 415 + ], 375 416 ), 376 417 StatefulShellBranch( 377 418 navigatorKey: _notificationsNavigatorKey, 378 419 routes: [ 379 420 GoRoute( 380 421 path: '/alerts', 381 - builder: (context, state) => _buildAlertsRoute(context, const AlertsScreen()), 422 + pageBuilder: (context, state) => 423 + _page(context, state, _buildAlertsRoute(context, const AlertsScreen())), 382 424 routes: [ 383 425 GoRoute( 384 426 path: 'messages', 385 - builder: (context, state) => 386 - _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), 427 + pageBuilder: (context, state) => _page( 428 + context, 429 + state, 430 + _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), 431 + ), 387 432 routes: [ 388 433 GoRoute( 389 434 path: ':id', 390 - builder: (context, state) { 435 + pageBuilder: (context, state) { 391 436 final convoId = state.pathParameters['id']!; 392 437 final args = state.extra as MessageThreadRouteArgs?; 393 - return BlocProvider( 394 - create: (_) => MessageBloc( 395 - convoRepository: context.read<ConvoRepository>(), 396 - currentUserDid: context.read<String>(), 438 + return _page( 439 + context, 440 + state, 441 + BlocProvider( 442 + create: (_) => MessageBloc( 443 + convoRepository: context.read<ConvoRepository>(), 444 + currentUserDid: context.read<String>(), 445 + ), 446 + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 397 447 ), 398 - child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 399 448 ); 400 449 }, 401 450 ), ··· 403 452 ), 404 453 GoRoute( 405 454 path: 'requests', 406 - builder: (context, state) => 407 - _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), 455 + pageBuilder: (context, state) => _page( 456 + context, 457 + state, 458 + _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), 459 + ), 408 460 ), 409 461 ], 410 462 ), ··· 415 467 routes: [ 416 468 GoRoute( 417 469 path: '/profile', 418 - builder: (context, state) => const ProfileScreen(), 470 + pageBuilder: (context, state) => _page(context, state, const ProfileScreen()), 419 471 routes: [ 420 472 GoRoute( 421 473 path: 'view', 422 - builder: (context, state) => 423 - ProfileScreen(actor: state.uri.queryParameters['actor'], showBackButton: true), 474 + pageBuilder: (context, state) => _page( 475 + context, 476 + state, 477 + ProfileScreen(actor: state.uri.queryParameters['actor'], showBackButton: true), 478 + ), 424 479 ), 425 480 ], 426 481 ),
+72 -13
lib/core/router/app_shell.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 2 3 import 'package:flutter_bloc/flutter_bloc.dart'; 3 4 import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/core/theme/animation_tokens.dart'; 6 + import 'package:lazurite/core/theme/animation_utils.dart'; 4 7 import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; 5 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 9 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; ··· 85 88 } 86 89 87 90 List<Widget> get _destinations => [ 88 - NavigationDestination( 89 - icon: const Icon(Icons.home_outlined), 90 - selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.home)), 91 + const NavigationDestination( 92 + icon: _AnimatedNavIcon(selected: false, outlined: Icons.home_outlined, filled: Icons.home), 93 + selectedIcon: _AnimatedNavIcon(selected: true, outlined: Icons.home_outlined, filled: Icons.home), 91 94 label: 'HOME', 92 95 ), 93 - NavigationDestination( 94 - icon: const Icon(Icons.search_outlined), 95 - selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.search)), 96 + const NavigationDestination( 97 + icon: _AnimatedNavIcon(selected: false, outlined: Icons.search_outlined, filled: Icons.search), 98 + selectedIcon: _AnimatedNavIcon(selected: true, outlined: Icons.search_outlined, filled: Icons.search), 96 99 label: 'SEARCH', 97 100 ), 98 - NavigationDestination( 99 - icon: const _NotificationDestinationIcon(selected: false), 100 - selectedIcon: Transform.scale(scale: 1.15, child: const _NotificationDestinationIcon(selected: true)), 101 + const NavigationDestination( 102 + icon: _AnimatedNotificationNavIcon(selected: false), 103 + selectedIcon: _AnimatedNotificationNavIcon(selected: true), 101 104 label: 'ALERTS', 102 105 ), 103 - NavigationDestination( 104 - icon: const Icon(Icons.person_outline), 105 - selectedIcon: Transform.scale(scale: 1.15, child: const Icon(Icons.person)), 106 + const NavigationDestination( 107 + icon: _AnimatedNavIcon(selected: false, outlined: Icons.person_outline, filled: Icons.person), 108 + selectedIcon: _AnimatedNavIcon(selected: true, outlined: Icons.person_outline, filled: Icons.person), 106 109 label: 'PROFILE', 107 110 ), 108 111 ]; 109 112 } 110 113 114 + class _AnimatedNavIcon extends StatelessWidget { 115 + const _AnimatedNavIcon({required this.selected, required this.outlined, required this.filled}); 116 + 117 + final bool selected; 118 + final IconData outlined; 119 + final IconData filled; 120 + 121 + @override 122 + Widget build(BuildContext context) { 123 + final icon = Icon(selected ? filled : outlined, key: ValueKey(selected), size: selected ? 26 : 24); 124 + final transitioned = AnimatedSwitcher( 125 + duration: Anim.fast, 126 + switchInCurve: Anim.enter, 127 + switchOutCurve: Anim.exit, 128 + transitionBuilder: (child, animation) => FadeTransition( 129 + opacity: animation, 130 + child: ScaleTransition(scale: animation, child: child), 131 + ), 132 + child: icon, 133 + ); 134 + 135 + return transitioned.animateIfAllowed( 136 + context, 137 + effects: selected 138 + ? const [ScaleEffect(begin: Offset(1, 1), end: Offset(1.15, 1.15), duration: Anim.fast, curve: Anim.enter)] 139 + : const [], 140 + ); 141 + } 142 + } 143 + 144 + class _AnimatedNotificationNavIcon extends StatelessWidget { 145 + const _AnimatedNotificationNavIcon({required this.selected}); 146 + 147 + final bool selected; 148 + 149 + @override 150 + Widget build(BuildContext context) { 151 + final icon = _NotificationDestinationIcon(selected: selected, key: ValueKey(selected)); 152 + return AnimatedSwitcher( 153 + duration: Anim.fast, 154 + switchInCurve: Anim.enter, 155 + switchOutCurve: Anim.exit, 156 + transitionBuilder: (child, animation) => FadeTransition( 157 + opacity: animation, 158 + child: ScaleTransition(scale: animation, child: child), 159 + ), 160 + child: icon, 161 + ).animateIfAllowed( 162 + context, 163 + effects: selected 164 + ? const [ScaleEffect(begin: Offset(1, 1), end: Offset(1.15, 1.15), duration: Anim.fast, curve: Anim.enter)] 165 + : const [], 166 + ); 167 + } 168 + } 169 + 111 170 class _NotificationDestinationIcon extends StatelessWidget { 112 - const _NotificationDestinationIcon({required this.selected}); 171 + const _NotificationDestinationIcon({super.key, required this.selected}); 113 172 114 173 final bool selected; 115 174
+32
lib/core/router/fade_through_page.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:go_router/go_router.dart'; 3 + import 'package:lazurite/core/theme/animation_tokens.dart'; 4 + import 'package:lazurite/core/theme/animation_utils.dart'; 5 + 6 + Page<T> buildFadeThroughPage<T>({required BuildContext context, required GoRouterState state, required Widget child}) { 7 + final reducedMotion = !animationsAllowed(context); 8 + final duration = reducedMotion ? Anim.fast : Anim.screenTransition; 9 + 10 + return CustomTransitionPage<T>( 11 + key: state.pageKey, 12 + transitionDuration: duration, 13 + reverseTransitionDuration: Anim.fast, 14 + child: child, 15 + transitionsBuilder: (context, animation, secondaryAnimation, child) { 16 + if (reducedMotion) { 17 + return FadeTransition(opacity: animation, child: child); 18 + } 19 + 20 + final fade = CurvedAnimation( 21 + parent: animation, 22 + curve: const Interval(0.2, 1, curve: Anim.enter), 23 + ); 24 + 25 + final scale = Tween<double>(begin: 0.96, end: 1).animate(CurvedAnimation(parent: animation, curve: Anim.enter)); 26 + return FadeTransition( 27 + opacity: fade, 28 + child: ScaleTransition(scale: scale, child: child), 29 + ); 30 + }, 31 + ); 32 + }
+26
lib/core/theme/animation_tokens.dart
··· 1 + import 'package:flutter/animation.dart'; 2 + 3 + abstract final class Anim { 4 + static const Duration fast = Duration(milliseconds: 150); 5 + static const Duration normal = Duration(milliseconds: 250); 6 + static const Duration slow = Duration(milliseconds: 400); 7 + 8 + static const Duration feedItem = Duration(milliseconds: 200); 9 + static const Duration screenTransition = Duration(milliseconds: 300); 10 + static const Duration shimmerCycle = Duration(milliseconds: 1200); 11 + static const Duration actionBounceIn = Duration(milliseconds: 120); 12 + static const Duration actionBounceOut = Duration(milliseconds: 100); 13 + static const Duration refreshComplete = Duration(milliseconds: 200); 14 + 15 + static const Curve enter = Curves.easeOut; 16 + static const Curve exit = Curves.easeIn; 17 + static const Curve emphasis = Curves.easeOutBack; 18 + 19 + static const Duration staggerOffset = Duration(milliseconds: 50); 20 + static const int maxStaggerItems = 10; 21 + 22 + static Duration staggerFor(int index) { 23 + final clampedIndex = index.clamp(0, maxStaggerItems - 1); 24 + return Duration(milliseconds: staggerOffset.inMilliseconds * clampedIndex); 25 + } 26 + }
+29
lib/core/theme/animation_utils.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 5 + 6 + bool animationsEnabledByUser(BuildContext context) { 7 + try { 8 + return context.select<SettingsCubit, bool>((cubit) => cubit.state.animationsEnabled); 9 + } catch (_) { 10 + return true; 11 + } 12 + } 13 + 14 + bool animationsAllowed(BuildContext context) { 15 + final platformReducedMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; 16 + final bindingName = WidgetsBinding.instance.runtimeType.toString(); 17 + final runningInWidgetTest = bindingName.contains('TestWidgetsFlutterBinding'); 18 + return !runningInWidgetTest && !platformReducedMotion && animationsEnabledByUser(context); 19 + } 20 + 21 + extension AnimateAccessibility on Widget { 22 + Widget animateIfAllowed(BuildContext context, {required List<Effect<dynamic>> effects, Duration? delay}) { 23 + if (animationsAllowed(context)) { 24 + return animate(effects: effects, delay: delay); 25 + } 26 + 27 + return this; 28 + } 29 + }
+33 -16
lib/features/feed/presentation/home_feed_screen.dart
··· 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 4 import 'package:flutter/material.dart'; 5 + import 'package:flutter_animate/flutter_animate.dart'; 5 6 import 'package:flutter_bloc/flutter_bloc.dart'; 6 7 import 'package:go_router/go_router.dart'; 8 + import 'package:lazurite/core/theme/animation_tokens.dart'; 9 + import 'package:lazurite/core/theme/animation_utils.dart'; 7 10 import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 8 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 12 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; ··· 15 18 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 16 19 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 17 20 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 21 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 18 22 19 23 /// Returns the number of grid columns for [width] per the responsive 20 24 /// breakpoints defined in the UI spec. ··· 110 114 itemBuilder: (context, index) => 111 115 _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), 112 116 ), 113 - floatingActionButton: FloatingActionButton( 114 - heroTag: 'home-compose-fab', 115 - tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 116 - onPressed: isOffline ? null : () => context.push('/compose'), 117 - shape: const CircleBorder(), 118 - child: const Icon(Icons.add), 119 - ), 117 + floatingActionButton: 118 + FloatingActionButton( 119 + heroTag: 'home-compose-fab', 120 + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 121 + onPressed: isOffline ? null : () => context.push('/compose'), 122 + shape: const CircleBorder(), 123 + child: const Icon(Icons.add), 124 + ).animateIfAllowed( 125 + context, 126 + effects: const [ 127 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 128 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 129 + ], 130 + ), 120 131 ); 121 132 }, 122 133 ); ··· 224 235 bool _hasError = false; 225 236 String? _errorMessage; 226 237 final ScrollController _scrollController = ScrollController(); 238 + final Set<String> _seenPostUris = <String>{}; 227 239 228 240 @override 229 241 bool get wantKeepAlive => true; ··· 390 402 391 403 final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 392 404 393 - PostCardWithActions buildCard(int index, PostCardVariant variant) { 405 + Widget buildCard(int index, PostCardVariant variant) { 394 406 final post = _posts[index]; 395 - return PostCardWithActions( 396 - feedViewPost: post, 397 - accountDid: accountDid, 398 - variant: variant, 399 - onDeleted: () { 400 - final uri = post.post.uri.toString(); 401 - _setStateIfMounted(() => _posts.removeWhere((p) => p.post.uri.toString() == uri)); 402 - }, 407 + final postUri = post.post.uri.toString(); 408 + return StaggeredEntrance( 409 + itemKey: postUri, 410 + index: index, 411 + seenKeys: _seenPostUris, 412 + child: PostCardWithActions( 413 + feedViewPost: post, 414 + accountDid: accountDid, 415 + variant: variant, 416 + onDeleted: () { 417 + _setStateIfMounted(() => _posts.removeWhere((p) => p.post.uri.toString() == postUri)); 418 + }, 419 + ), 403 420 ); 404 421 } 405 422
+19 -5
lib/features/feed/presentation/saved_posts_screen.dart
··· 11 11 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 12 12 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 13 13 import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 14 15 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 15 16 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 16 17 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 18 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 17 19 import 'package:share_plus/share_plus.dart'; 18 20 19 21 class SavedPostsScreen extends StatelessWidget { ··· 110 112 } 111 113 } 112 114 113 - class _AllSavedTab extends StatelessWidget { 115 + class _AllSavedTab extends StatefulWidget { 114 116 const _AllSavedTab(); 117 + 118 + @override 119 + State<_AllSavedTab> createState() => _AllSavedTabState(); 120 + } 121 + 122 + class _AllSavedTabState extends State<_AllSavedTab> { 123 + final Set<String> _seenPostUris = <String>{}; 115 124 116 125 @override 117 126 Widget build(BuildContext context) { ··· 137 146 ); 138 147 } 139 148 140 - return RefreshIndicator( 149 + return AnimatedRefreshIndicator( 141 150 onRefresh: () => context.read<SavedPostsCubit>().loadSavedPosts(), 142 151 child: ListView.builder( 143 152 itemCount: state.savedPosts.length, 144 153 itemBuilder: (context, index) { 145 154 final savedPost = state.savedPosts[index]; 146 - return _SavedPostCard( 147 - savedPost: savedPost, 148 - onUnsave: () => context.read<SavedPostsCubit>().unsavePostById(savedPost.id), 155 + return StaggeredEntrance( 156 + itemKey: savedPost.postUri, 157 + index: index, 158 + seenKeys: _seenPostUris, 159 + child: _SavedPostCard( 160 + savedPost: savedPost, 161 + onUnsave: () => context.read<SavedPostsCubit>().unsavePostById(savedPost.id), 162 + ), 149 163 ); 150 164 }, 151 165 ),
+4 -3
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 4 4 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 5 5 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 6 6 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 7 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 7 8 8 9 const double _gridSpacing = 1; 9 10 const double _gridCardChromeHeight = 160; ··· 54 55 } 55 56 final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; 56 57 57 - return RefreshIndicator( 58 + return AnimatedRefreshIndicator( 58 59 onRefresh: onRefresh, 59 60 child: CustomScrollView( 60 61 controller: scrollController, ··· 80 81 } 81 82 82 83 Widget _buildSingleColumnGrid(BuildContext context) { 83 - return RefreshIndicator( 84 + return AnimatedRefreshIndicator( 84 85 onRefresh: onRefresh, 85 86 child: CustomScrollView( 86 87 controller: scrollController, ··· 105 106 } 106 107 107 108 Widget _buildLinear(BuildContext context) { 108 - return RefreshIndicator( 109 + return AnimatedRefreshIndicator( 109 110 onRefresh: onRefresh, 110 111 child: ListView.builder( 111 112 controller: scrollController,
+100 -17
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 2 3 import 'package:lazurite/core/logging/app_logger.dart'; 4 + import 'package:lazurite/core/theme/animation_tokens.dart'; 3 5 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 4 6 import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 5 7 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; ··· 78 80 count: repostCount, 79 81 isActive: isReposted, 80 82 isLoading: isLoadingRepost, 83 + animateOnTap: true, 81 84 onTap: isOffline ? null : onRepost, 82 85 activeColor: Colors.green, 83 86 onLongPress: !isOffline && onRepost != null ? () => _showRepostOptions(context) : null, ··· 89 92 count: likeCount, 90 93 isActive: isLiked, 91 94 isLoading: isLoadingLike, 95 + animateOnTap: true, 92 96 onTap: isOffline ? null : onLike, 93 97 activeColor: Colors.pink, 94 98 tooltip: isOffline ? offlineActionMessage('like this post') : null, ··· 98 102 activeIcon: Icons.bookmark, 99 103 count: saveCount, 100 104 isActive: isSaved, 105 + animateOnTap: true, 101 106 onTap: onSave != null ? () => _showSaveOptions(context) : null, 102 107 onLongPress: onLongPressSave, 103 108 color: context.colorScheme.onSurfaceVariant, ··· 204 209 this.color, 205 210 this.activeColor, 206 211 this.tooltip, 212 + this.animateOnTap = false, 207 213 }); 208 214 209 215 final IconData icon; ··· 216 222 final Color? color; 217 223 final Color? activeColor; 218 224 final String? tooltip; 225 + final bool animateOnTap; 226 + 227 + @override 228 + Widget build(BuildContext context) { 229 + return _ActionButtonBody( 230 + icon: icon, 231 + activeIcon: activeIcon, 232 + count: count, 233 + isActive: isActive, 234 + isLoading: isLoading, 235 + onTap: onTap, 236 + onLongPress: onLongPress, 237 + color: color, 238 + activeColor: activeColor, 239 + tooltip: tooltip, 240 + animateOnTap: animateOnTap, 241 + ); 242 + } 243 + } 244 + 245 + class _ActionButtonBody extends StatefulWidget { 246 + const _ActionButtonBody({ 247 + required this.icon, 248 + required this.activeIcon, 249 + required this.count, 250 + required this.isActive, 251 + required this.isLoading, 252 + required this.onTap, 253 + required this.onLongPress, 254 + required this.color, 255 + required this.activeColor, 256 + required this.tooltip, 257 + required this.animateOnTap, 258 + }); 259 + 260 + final IconData icon; 261 + final IconData activeIcon; 262 + final int count; 263 + final bool isActive; 264 + final bool isLoading; 265 + final VoidCallback? onTap; 266 + final VoidCallback? onLongPress; 267 + final Color? color; 268 + final Color? activeColor; 269 + final String? tooltip; 270 + final bool animateOnTap; 271 + 272 + @override 273 + State<_ActionButtonBody> createState() => _ActionButtonBodyState(); 274 + } 275 + 276 + class _ActionButtonBodyState extends State<_ActionButtonBody> { 277 + int _tapSequence = 0; 219 278 220 279 @override 221 280 Widget build(BuildContext context) { 222 - final defaultColor = color ?? context.colorScheme.onSurfaceVariant; 223 - final iconColor = isActive ? (activeColor ?? defaultColor) : defaultColor; 281 + final defaultColor = widget.color ?? context.colorScheme.onSurfaceVariant; 282 + final iconColor = widget.isActive ? (widget.activeColor ?? defaultColor) : defaultColor; 283 + final iconWidget = widget.isLoading 284 + ? SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor)) 285 + : AnimatedSwitcher( 286 + duration: Anim.fast, 287 + switchInCurve: Anim.enter, 288 + switchOutCurve: Anim.exit, 289 + transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), 290 + child: Icon( 291 + widget.isActive ? widget.activeIcon : widget.icon, 292 + key: ValueKey('${widget.isActive}-${widget.isLoading}'), 293 + size: 18, 294 + color: iconColor, 295 + ), 296 + ); 224 297 225 298 Widget button = InkWell( 226 - onTap: isLoading ? null : onTap, 227 - onLongPress: onLongPress, 299 + onTap: widget.isLoading ? null : _handleTap, 300 + onLongPress: widget.onLongPress, 228 301 borderRadius: BorderRadius.circular(999), 229 302 child: Padding( 230 303 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 231 304 child: Row( 232 305 mainAxisSize: MainAxisSize.min, 233 306 children: [ 234 - if (isLoading) 235 - SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor)) 236 - else 237 - Icon(isActive ? activeIcon : icon, size: 18, color: iconColor), 238 - if (count > 0) ...[ 307 + iconWidget, 308 + if (widget.count > 0) ...[ 239 309 const SizedBox(width: 4), 240 - Text(formatCount(count), style: context.textTheme.bodySmall?.copyWith(color: iconColor)), 310 + Text(formatCount(widget.count), style: context.textTheme.bodySmall?.copyWith(color: iconColor)), 241 311 ], 242 312 ], 243 313 ), 244 314 ), 245 315 ); 246 316 247 - if (onTap != null && !isLoading) { 248 - button = GestureDetector( 249 - onTap: onTap, 250 - onLongPress: onLongPress, 251 - child: AnimatedScale(scale: isActive ? 1.0 : 1.0, duration: const Duration(milliseconds: 100), child: button), 317 + if (widget.animateOnTap && _tapSequence > 0) { 318 + button = button.animate( 319 + key: ValueKey('action-${widget.icon.codePoint}-$_tapSequence'), 320 + effects: const [ 321 + ScaleEffect(begin: Offset(1, 1), end: Offset(1.3, 1.3), duration: Anim.actionBounceIn, curve: Anim.enter), 322 + ScaleEffect(begin: Offset(1.3, 1.3), end: Offset(1, 1), duration: Anim.actionBounceOut, curve: Anim.emphasis), 323 + ], 252 324 ); 253 325 } 254 326 255 - if (tooltip != null) { 256 - button = Tooltip(message: tooltip!, child: button); 327 + if (widget.tooltip != null) { 328 + button = Tooltip(message: widget.tooltip!, child: button); 257 329 } 258 330 259 331 return button; 332 + } 333 + 334 + void _handleTap() { 335 + if (widget.onTap == null) { 336 + return; 337 + } 338 + 339 + if (widget.animateOnTap) { 340 + setState(() => _tapSequence++); 341 + } 342 + widget.onTap!.call(); 260 343 } 261 344 }
+57 -44
lib/features/lists/presentation/list_members_screen.dart
··· 5 5 import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 6 6 import 'package:lazurite/features/lists/data/list_repository.dart'; 7 7 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 8 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 8 9 import 'package:lazurite/core/theme/theme_extensions.dart'; 9 10 10 11 /// Screen for adding and removing members from a list. ··· 32 33 final _searchController = TextEditingController(); 33 34 List<ProfileViewBasic> _searchResults = []; 34 35 bool _isSearching = false; 36 + final Set<String> _seenSearchResultDids = <String>{}; 37 + final Set<String> _seenMemberUris = <String>{}; 35 38 36 39 @override 37 40 void dispose() { ··· 114 117 final profile = _searchResults[index]; 115 118 final isAlreadyMember = currentDids.contains(profile.did); 116 119 117 - return ListTile( 118 - leading: CircleAvatar( 119 - backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 120 - backgroundColor: colorScheme.surfaceContainerHighest, 121 - child: profile.avatar == null 122 - ? Text( 123 - (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) 124 - .substring(0, 1) 125 - .toUpperCase(), 126 - ) 127 - : null, 120 + return StaggeredEntrance( 121 + itemKey: profile.did, 122 + index: index, 123 + seenKeys: _seenSearchResultDids, 124 + child: ListTile( 125 + leading: CircleAvatar( 126 + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 127 + backgroundColor: colorScheme.surfaceContainerHighest, 128 + child: profile.avatar == null 129 + ? Text( 130 + (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) 131 + .substring(0, 1) 132 + .toUpperCase(), 133 + ) 134 + : null, 135 + ), 136 + title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 137 + subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 138 + trailing: isAlreadyMember 139 + ? Icon(Icons.check_circle, color: colorScheme.primary) 140 + : IconButton( 141 + icon: const Icon(Icons.add_circle_outline), 142 + onPressed: () { 143 + context.read<ListBloc>().add(ListItemAdded(subjectDid: profile.did)); 144 + _searchController.clear(); 145 + setState(() => _searchResults = []); 146 + }, 147 + ), 128 148 ), 129 - title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 130 - subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 131 - trailing: isAlreadyMember 132 - ? Icon(Icons.check_circle, color: colorScheme.primary) 133 - : IconButton( 134 - icon: const Icon(Icons.add_circle_outline), 135 - onPressed: () { 136 - context.read<ListBloc>().add(ListItemAdded(subjectDid: profile.did)); 137 - _searchController.clear(); 138 - setState(() => _searchResults = []); 139 - }, 140 - ), 141 149 ); 142 150 }, 143 151 ), ··· 177 185 final item = state.items[index]; 178 186 final subject = item.subject; 179 187 180 - return ListTile( 181 - key: ValueKey(item.uri), 182 - leading: CircleAvatar( 183 - backgroundImage: subject.avatar != null ? NetworkImage(subject.avatar!) : null, 184 - backgroundColor: colorScheme.surfaceContainerHighest, 185 - child: subject.avatar == null 186 - ? Text( 187 - (subject.displayName?.isNotEmpty == true ? subject.displayName! : subject.handle) 188 - .substring(0, 1) 189 - .toUpperCase(), 190 - ) 191 - : null, 188 + return StaggeredEntrance( 189 + itemKey: item.uri.toString(), 190 + index: index, 191 + seenKeys: _seenMemberUris, 192 + child: ListTile( 193 + key: ValueKey(item.uri), 194 + leading: CircleAvatar( 195 + backgroundImage: subject.avatar != null ? NetworkImage(subject.avatar!) : null, 196 + backgroundColor: colorScheme.surfaceContainerHighest, 197 + child: subject.avatar == null 198 + ? Text( 199 + (subject.displayName?.isNotEmpty == true ? subject.displayName! : subject.handle) 200 + .substring(0, 1) 201 + .toUpperCase(), 202 + ) 203 + : null, 204 + ), 205 + title: Text(subject.displayName ?? subject.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 206 + subtitle: Text('@${subject.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 207 + onTap: () => navigateToProfile(context, subject.did), 208 + trailing: state.isMutating 209 + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 210 + : IconButton( 211 + icon: Icon(Icons.remove_circle_outline, color: colorScheme.error), 212 + onPressed: () => context.read<ListBloc>().add(ListItemRemoved(listItemUri: item.uri)), 213 + ), 192 214 ), 193 - title: Text(subject.displayName ?? subject.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 194 - subtitle: Text('@${subject.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 195 - onTap: () => navigateToProfile(context, subject.did), 196 - trailing: state.isMutating 197 - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 198 - : IconButton( 199 - icon: Icon(Icons.remove_circle_outline, color: colorScheme.error), 200 - onPressed: () => context.read<ListBloc>().add(ListItemRemoved(listItemUri: item.uri)), 201 - ), 202 215 ); 203 216 }, 204 217 ),
+32 -11
lib/features/lists/presentation/my_lists_screen.dart
··· 1 1 import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 2 2 import 'package:flutter/material.dart'; 3 + import 'package:flutter_animate/flutter_animate.dart'; 3 4 import 'package:flutter_bloc/flutter_bloc.dart'; 4 5 import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/core/theme/animation_tokens.dart'; 7 + import 'package:lazurite/core/theme/animation_utils.dart'; 5 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 9 import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; 7 10 import 'package:lazurite/features/lists/data/list_repository.dart'; 8 11 import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart'; 9 12 import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart'; 13 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 10 14 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 11 15 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 12 16 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 17 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 13 18 14 19 class MyListsScreen extends StatelessWidget { 15 20 const MyListsScreen({super.key}); ··· 33 38 34 39 class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderStateMixin { 35 40 late final TabController _tabController; 41 + final Set<String> _seenListUris = <String>{}; 36 42 37 43 @override 38 44 void initState() { ··· 108 114 ); 109 115 }, 110 116 ), 111 - floatingActionButton: FloatingActionButton( 112 - heroTag: 'my-lists-fab', 113 - onPressed: () => _showCreateDialog(context), 114 - child: const Icon(Icons.add), 115 - ), 117 + floatingActionButton: 118 + FloatingActionButton( 119 + heroTag: 'my-lists-fab', 120 + onPressed: () => _showCreateDialog(context), 121 + child: const Icon(Icons.add), 122 + ).animateIfAllowed( 123 + context, 124 + effects: const [ 125 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 126 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 127 + ], 128 + ), 116 129 ); 117 130 } 118 131 ··· 121 134 return const EmptyState(message: 'No lists yet', icon: Icons.list_alt_outlined); 122 135 } 123 136 124 - return RefreshIndicator( 137 + return AnimatedRefreshIndicator( 125 138 onRefresh: () => context.read<MyListsCubit>().refresh(), 126 139 child: ListView.builder( 127 140 itemCount: lists.length, 128 - itemBuilder: (context, index) => ListRowTile( 129 - key: ValueKey(lists[index].uri), 130 - list: lists[index], 131 - onTap: () => context.push('/list?uri=${Uri.encodeComponent(lists[index].uri.toString())}'), 132 - ), 141 + itemBuilder: (context, index) { 142 + final list = lists[index]; 143 + return StaggeredEntrance( 144 + itemKey: list.uri.toString(), 145 + index: index, 146 + seenKeys: _seenListUris, 147 + child: ListRowTile( 148 + key: ValueKey(list.uri), 149 + list: list, 150 + onTap: () => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'), 151 + ), 152 + ); 153 + }, 133 154 ), 134 155 ); 135 156 }
+21 -13
lib/features/messages/presentation/widgets/convo_list_pane.dart
··· 7 7 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 8 8 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 9 9 import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 10 11 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 11 12 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 12 13 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 13 15 import 'package:lazurite/core/theme/theme_extensions.dart'; 14 16 15 17 class ConvoListPane extends StatefulWidget { ··· 23 25 24 26 class _ConvoListPaneState extends State<ConvoListPane> { 25 27 final ScrollController _scrollController = ScrollController(); 28 + final Set<String> _seenConvoIds = <String>{}; 26 29 27 30 @override 28 31 void initState() { ··· 102 105 if (isOffline) { 103 106 return const _OfflineConvoState(); 104 107 } 105 - return RefreshIndicator( 108 + return AnimatedRefreshIndicator( 106 109 onRefresh: _onRefresh, 107 110 child: ListView( 108 111 controller: _scrollController, ··· 121 124 122 125 final currentUserDid = _currentUserDid(context); 123 126 124 - return RefreshIndicator( 127 + return AnimatedRefreshIndicator( 125 128 onRefresh: _onRefresh, 126 129 child: ListView.builder( 127 130 controller: _scrollController, 128 131 itemCount: filtered.length, 129 132 itemBuilder: (context, index) { 130 133 final convo = filtered[index]; 131 - return ConvoListItem( 132 - convo: convo, 133 - currentUserDid: currentUserDid, 134 - onTap: () => _openThread(context, convo, currentUserDid), 135 - onMuteTap: () { 136 - if (convo.muted) { 137 - context.read<ConvoListBloc>().add(ConvoUnmuted(convoId: convo.id)); 138 - } else { 139 - context.read<ConvoListBloc>().add(ConvoMuted(convoId: convo.id)); 140 - } 141 - }, 134 + return StaggeredEntrance( 135 + itemKey: convo.id, 136 + index: index, 137 + seenKeys: _seenConvoIds, 138 + child: ConvoListItem( 139 + convo: convo, 140 + currentUserDid: currentUserDid, 141 + onTap: () => _openThread(context, convo, currentUserDid), 142 + onMuteTap: () { 143 + if (convo.muted) { 144 + context.read<ConvoListBloc>().add(ConvoUnmuted(convoId: convo.id)); 145 + } else { 146 + context.read<ConvoListBloc>().add(ConvoMuted(convoId: convo.id)); 147 + } 148 + }, 149 + ), 142 150 ); 143 151 }, 144 152 ),
+9 -5
lib/features/notifications/presentation/widgets/notifications_pane.dart
··· 6 6 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 7 7 import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; 8 8 import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; 9 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 9 10 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 10 11 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 11 12 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 13 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 12 14 import 'package:lazurite/core/theme/theme_extensions.dart'; 13 15 14 16 class NotificationsPane extends StatefulWidget { ··· 20 22 21 23 class _NotificationsPaneState extends State<NotificationsPane> { 22 24 final ScrollController _scrollController = ScrollController(); 25 + final Set<String> _seenNotificationKeys = <String>{}; 23 26 24 27 @override 25 28 void initState() { ··· 85 88 86 89 final groupedNotifications = _groupNotificationsByDay(state.notifications); 87 90 88 - return RefreshIndicator( 91 + return AnimatedRefreshIndicator( 89 92 onRefresh: _onRefresh, 90 93 child: ListView.builder( 91 94 controller: _scrollController, ··· 96 99 if (item is String) { 97 100 return _DayHeader(title: item); 98 101 } else if (item is NotificationGroup) { 99 - if (item.count == 1) { 100 - return NotificationListItem(notification: item.latest); 101 - } 102 - return GroupedNotificationListItem(group: item); 102 + final key = '${item.latest.uri}:${item.latest.indexedAt.toIso8601String()}'; 103 + final child = item.count == 1 104 + ? NotificationListItem(notification: item.latest) 105 + : GroupedNotificationListItem(group: item); 106 + return StaggeredEntrance(itemKey: key, index: index, seenKeys: _seenNotificationKeys, child: child); 103 107 } else if (item == null) { 104 108 return const Center( 105 109 child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()),
+22 -8
lib/features/profile/presentation/follow_audit_screen.dart
··· 7 7 import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 8 8 import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 9 9 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 10 11 11 12 class FollowAuditScreen extends StatelessWidget { 12 13 const FollowAuditScreen({super.key}); ··· 439 440 } 440 441 } 441 442 442 - class _ResultsPanel extends StatelessWidget { 443 + class _ResultsPanel extends StatefulWidget { 443 444 const _ResultsPanel({required this.state, required this.visibleEntries}); 444 445 445 446 final FollowAuditState state; 446 447 final List<({int index, ClassifiedFollow item})> visibleEntries; 447 448 448 449 @override 450 + State<_ResultsPanel> createState() => _ResultsPanelState(); 451 + } 452 + 453 + class _ResultsPanelState extends State<_ResultsPanel> { 454 + final Set<String> _seenRows = <String>{}; 455 + 456 + @override 449 457 Widget build(BuildContext context) { 450 - if (state.status == FollowAuditStatus.initial) { 458 + if (widget.state.status == FollowAuditStatus.initial) { 451 459 return const Center(child: Text('Tap Scan to audit your follow list.')); 452 460 } 453 461 454 - if (state.results.isEmpty && 455 - (state.status == FollowAuditStatus.ready || state.status == FollowAuditStatus.complete)) { 462 + if (widget.state.results.isEmpty && 463 + (widget.state.status == FollowAuditStatus.ready || widget.state.status == FollowAuditStatus.complete)) { 456 464 return const Center(child: Text('No problematic follows found', key: Key('follow_audit_empty_message'))); 457 465 } 458 466 459 - if (visibleEntries.isEmpty && state.results.isNotEmpty) { 467 + if (widget.visibleEntries.isEmpty && widget.state.results.isNotEmpty) { 460 468 return const Center(child: Text('No results visible for the current filters.')); 461 469 } 462 470 463 471 return ListView.separated( 464 472 padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 465 - itemCount: visibleEntries.length, 473 + itemCount: widget.visibleEntries.length, 466 474 separatorBuilder: (context, index) => const SizedBox(height: 8), 467 475 itemBuilder: (context, index) { 468 - final entry = visibleEntries[index]; 469 - return _ResultRow(index: entry.index, item: entry.item); 476 + final entry = widget.visibleEntries[index]; 477 + final rowKey = '${entry.item.record.subjectDid}:${entry.item.record.rkey}'; 478 + return StaggeredEntrance( 479 + itemKey: rowKey, 480 + index: index, 481 + seenKeys: _seenRows, 482 + child: _ResultRow(index: entry.index, item: entry.item), 483 + ); 470 484 }, 471 485 ); 472 486 }
+105 -66
lib/features/profile/presentation/profile_screen.dart
··· 3 3 import 'package:bluesky/moderation.dart' as bsky_moderation; 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter/services.dart'; 6 + import 'package:flutter_animate/flutter_animate.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; 7 8 import 'package:go_router/go_router.dart'; 8 9 import 'package:intl/intl.dart'; 9 10 import 'package:lazurite/core/router/app_shell.dart'; 11 + import 'package:lazurite/core/theme/animation_tokens.dart'; 12 + import 'package:lazurite/core/theme/animation_utils.dart'; 10 13 import 'package:lazurite/core/theme/color_filters.dart'; 11 14 import 'package:lazurite/core/theme/feed_layout.dart'; 12 15 import 'package:lazurite/core/theme/spacing.dart'; ··· 67 70 68 71 late TabController _tabController; 69 72 late bool _showSuggestedTab; 73 + double _coverScrollOffset = 0; 70 74 71 75 @override 72 76 void initState() { ··· 167 171 if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), 168 172 ]; 169 173 170 - return NestedScrollView( 171 - headerSliverBuilder: (context, innerBoxIsScrolled) { 172 - return [ 173 - SliverAppBar( 174 - floating: true, 175 - pinned: true, 176 - snap: true, 177 - title: Text(_appBarTitle(profile)), 178 - leading: widget.showBackButton 179 - ? IconButton( 180 - icon: const Icon(Icons.arrow_back), 181 - onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 182 - ) 183 - : const AppShellMenuButton(), 184 - actions: [ 185 - if (profile != null && isOwnProfile) 174 + return NotificationListener<ScrollUpdateNotification>( 175 + onNotification: (notification) { 176 + if (notification.metrics.axis != Axis.vertical) { 177 + return false; 178 + } 179 + final offset = notification.metrics.pixels; 180 + if ((offset - _coverScrollOffset).abs() >= 1) { 181 + setState(() => _coverScrollOffset = offset); 182 + } 183 + return false; 184 + }, 185 + child: NestedScrollView( 186 + headerSliverBuilder: (context, innerBoxIsScrolled) { 187 + return [ 188 + SliverAppBar( 189 + floating: true, 190 + pinned: true, 191 + snap: true, 192 + title: Text(_appBarTitle(profile)), 193 + leading: widget.showBackButton 194 + ? IconButton( 195 + icon: const Icon(Icons.arrow_back), 196 + onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), 197 + ) 198 + : const AppShellMenuButton(), 199 + actions: [ 200 + if (profile != null && isOwnProfile) 201 + IconButton( 202 + key: const Key('profile_more_button'), 203 + icon: const Icon(Icons.more_vert), 204 + onPressed: () => _showOwnProfileMoreOptions(context, profile), 205 + ), 186 206 IconButton( 187 - key: const Key('profile_more_button'), 188 - icon: const Icon(Icons.more_vert), 189 - onPressed: () => _showOwnProfileMoreOptions(context, profile), 207 + icon: const Icon(Icons.settings_outlined), 208 + onPressed: () => context.go('/settings'), 190 209 ), 191 - IconButton( 192 - icon: const Icon(Icons.settings_outlined), 193 - onPressed: () => context.go('/settings'), 194 - ), 195 - ], 196 - ), 197 - SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 198 - SliverToBoxAdapter( 199 - child: switch (profileState.status) { 200 - ProfileStatus.loading => const Padding( 201 - padding: AppInsets.allLg, 202 - child: Center(child: CircularProgressIndicator()), 203 - ), 204 - ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 205 - _ => _buildProfileSummary(context, profile, isOwnProfile), 206 - }, 207 - ), 208 - SliverPersistentHeader( 209 - pinned: true, 210 - delegate: SliverTabBarDelegate( 211 - TabBar( 212 - controller: _tabController, 213 - tabs: [for (final label in _tabLabels) Tab(text: label)], 214 - onTap: (index) { 215 - if (index < _feedTabs.length) { 216 - _loadProfileAndFeed(filter: _feedTabs[index].filter); 217 - } 218 - }, 219 - isScrollable: true, 220 - tabAlignment: TabAlignment.start, 221 - labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 222 - unselectedLabelStyle: const TextStyle( 223 - fontSize: 11, 224 - fontWeight: FontWeight.w700, 225 - letterSpacing: 2.2, 210 + ], 211 + ), 212 + SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 213 + SliverToBoxAdapter( 214 + child: switch (profileState.status) { 215 + ProfileStatus.loading => const Padding( 216 + padding: AppInsets.allLg, 217 + child: Center(child: CircularProgressIndicator()), 226 218 ), 227 - indicatorWeight: 2, 219 + ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 220 + _ => _buildProfileSummary(context, profile, isOwnProfile), 221 + }, 222 + ), 223 + SliverPersistentHeader( 224 + pinned: true, 225 + delegate: SliverTabBarDelegate( 226 + TabBar( 227 + controller: _tabController, 228 + tabs: [for (final label in _tabLabels) Tab(text: label)], 229 + onTap: (index) { 230 + if (index < _feedTabs.length) { 231 + _loadProfileAndFeed(filter: _feedTabs[index].filter); 232 + } 233 + }, 234 + isScrollable: true, 235 + tabAlignment: TabAlignment.start, 236 + labelStyle: const TextStyle( 237 + fontSize: 11, 238 + fontWeight: FontWeight.w700, 239 + letterSpacing: 2.2, 240 + ), 241 + unselectedLabelStyle: const TextStyle( 242 + fontSize: 11, 243 + fontWeight: FontWeight.w700, 244 + letterSpacing: 2.2, 245 + ), 246 + indicatorWeight: 2, 247 + ), 228 248 ), 229 249 ), 230 - ), 231 - ]; 232 - }, 233 - body: TabBarView(controller: _tabController, children: tabChildren), 250 + ]; 251 + }, 252 + body: TabBarView(controller: _tabController, children: tabChildren), 253 + ), 234 254 ); 235 255 }, 236 256 ); 237 257 }, 238 258 ), 239 - floatingActionButton: _buildComposeFab(context), 259 + floatingActionButton: AnimatedSwitcher( 260 + duration: Anim.feedItem, 261 + switchInCurve: Anim.enter, 262 + switchOutCurve: Anim.exit, 263 + transitionBuilder: (child, animation) => FadeTransition( 264 + opacity: animation, 265 + child: ScaleTransition(scale: animation, child: child), 266 + ), 267 + child: _buildComposeFab(context), 268 + ), 240 269 ), 241 270 ); 242 271 } ··· 274 303 left: 0, 275 304 right: 0, 276 305 height: coverHeight, 277 - child: Container( 278 - decoration: BoxDecoration( 279 - border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), 306 + child: Transform.translate( 307 + offset: Offset(0, -1.0 * (_coverScrollOffset * 0.5).clamp(0, coverHeight * 0.3).toDouble()), 308 + child: Container( 309 + decoration: BoxDecoration( 310 + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), 311 + ), 312 + child: Opacity(opacity: 0.5, child: coverContent), 280 313 ), 281 - child: Opacity(opacity: 0.5, child: coverContent), 282 314 ), 283 315 ), 284 316 Positioned( ··· 655 687 return _SuggestedFollowsTab(actor: actor, onProfileTap: (target) => navigateToProfile(context, target.did)); 656 688 } 657 689 658 - Widget? _buildComposeFab(BuildContext context) { 690 + Widget _buildComposeFab(BuildContext context) { 659 691 return BlocBuilder<ProfileBloc, ProfileState>( 660 692 builder: (context, state) { 661 693 final profile = state.profile; 662 - if (profile == null) return const SizedBox.shrink(); 694 + if (profile == null) return const SizedBox.shrink(key: ValueKey('profile-compose-fab-empty')); 663 695 664 696 final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 665 697 final isOwnProfile = profile.did == currentUserDid; ··· 667 699 final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 668 700 669 701 return FloatingActionButton( 702 + key: const ValueKey('profile-compose-fab'), 670 703 heroTag: 'profile-compose-fab', 671 704 tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 672 705 onPressed: isOffline 673 706 ? null 674 707 : () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), 675 708 child: const Icon(Icons.add), 709 + ).animateIfAllowed( 710 + context, 711 + effects: const [ 712 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 713 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 714 + ], 676 715 ); 677 716 }, 678 717 );
+20 -2
lib/features/search/presentation/hashtag_screen.dart
··· 3 3 import 'package:bluesky/app_bsky_feed_post.dart'; 4 4 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 5 import 'package:flutter/material.dart'; 6 + import 'package:flutter_animate/flutter_animate.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; 7 8 import 'package:go_router/go_router.dart'; 9 + import 'package:lazurite/core/theme/animation_tokens.dart'; 10 + import 'package:lazurite/core/theme/animation_utils.dart'; 8 11 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 9 12 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 10 13 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; ··· 14 17 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 15 18 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 16 19 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 20 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 21 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 17 22 import 'package:lazurite/shared/utils/format_utils.dart'; 18 23 import 'package:lazurite/core/theme/theme_extensions.dart'; 19 24 ··· 28 33 29 34 class _HashtagScreenState extends State<HashtagScreen> { 30 35 final ScrollController _scrollController = ScrollController(); 36 + final Set<String> _seenPostUris = <String>{}; 31 37 32 38 @override 33 39 void initState() { ··· 142 148 onPressed: state.isMissingTag ? null : () => _openHashtagJumpSheet(state), 143 149 icon: const Icon(Icons.tag), 144 150 label: const Text('Jump to hashtag'), 151 + ).animateIfAllowed( 152 + context, 153 + effects: const [ 154 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 155 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 156 + ], 145 157 ); 146 158 }, 147 159 ), ··· 241 253 return Center(child: Text('No posts found for #${state.tag}.')); 242 254 } 243 255 244 - return RefreshIndicator( 256 + return AnimatedRefreshIndicator( 245 257 onRefresh: () => context.read<HashtagCubit>().refreshCurrent(), 246 258 child: ListView.builder( 247 259 controller: _scrollController, ··· 252 264 child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 253 265 ); 254 266 } 255 - return _HashtagPostCard(post: timeline.posts[index]); 267 + final post = timeline.posts[index]; 268 + return StaggeredEntrance( 269 + itemKey: post.uri.toString(), 270 + index: index, 271 + seenKeys: _seenPostUris, 272 + child: _HashtagPostCard(post: post), 273 + ); 256 274 }, 257 275 ), 258 276 );
+59 -25
lib/features/search/presentation/search_screen.dart
··· 3 3 import 'package:bluesky/app_bsky_feed_post.dart'; 4 4 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 5 import 'package:flutter/material.dart'; 6 + import 'package:flutter_animate/flutter_animate.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; 7 8 import 'package:go_router/go_router.dart'; 8 9 import 'package:lazurite/core/router/app_shell.dart'; 10 + import 'package:lazurite/core/theme/animation_tokens.dart'; 11 + import 'package:lazurite/core/theme/animation_utils.dart'; 9 12 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 10 13 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 11 14 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; ··· 19 22 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 20 23 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 21 24 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 25 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 22 26 import 'package:lazurite/shared/utils/format_utils.dart'; 23 27 import 'package:lazurite/core/theme/theme_extensions.dart'; 24 28 ··· 33 37 final TextEditingController _searchController = TextEditingController(); 34 38 final FocusNode _focusNode = FocusNode(); 35 39 final ScrollController _scrollController = ScrollController(); 40 + final Set<String> _seenResultKeys = <String>{}; 36 41 37 42 @override 38 43 void initState() { ··· 218 223 @override 219 224 Widget build(BuildContext context) { 220 225 return Scaffold( 221 - floatingActionButton: FloatingActionButton.extended( 222 - onPressed: _openJumpToProfileDialog, 223 - icon: const Icon(Icons.person_search), 224 - label: const Text('Jump to profile'), 225 - ), 226 + floatingActionButton: 227 + FloatingActionButton.extended( 228 + onPressed: _openJumpToProfileDialog, 229 + icon: const Icon(Icons.person_search), 230 + label: const Text('Jump to profile'), 231 + ).animateIfAllowed( 232 + context, 233 + effects: const [ 234 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 235 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 236 + ], 237 + ), 226 238 body: SafeArea( 227 239 child: BlocBuilder<SearchBloc, SearchState>( 228 240 builder: (context, state) { ··· 511 523 child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 512 524 ); 513 525 } 514 - return _PostViewCard(post: posts[index]); 526 + final post = posts[index]; 527 + return StaggeredEntrance( 528 + itemKey: post.uri.toString(), 529 + index: index, 530 + seenKeys: _seenResultKeys, 531 + child: _PostViewCard(post: post), 532 + ); 515 533 }, 516 534 ); 517 535 } ··· 531 549 child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 532 550 ); 533 551 } 534 - return _ActorResultTile(actor: actors[index]); 552 + final actor = actors[index]; 553 + return StaggeredEntrance( 554 + itemKey: actor.did, 555 + index: index, 556 + seenKeys: _seenResultKeys, 557 + child: _ActorResultTile(actor: actor), 558 + ); 535 559 }, 536 560 ); 537 561 } ··· 553 577 } 554 578 555 579 final feed = feeds[index]; 556 - return _FeedResultTile( 557 - feed: feed, 558 - onAdded: (displayName) { 559 - showAppSnackBar( 560 - context, 561 - 'Added $displayName to your saved feeds', 562 - actionLabel: 'Manage', 563 - onAction: () => GoRouter.maybeOf(context)?.push('/feeds'), 564 - ); 565 - }, 580 + return StaggeredEntrance( 581 + itemKey: feed.uri.toString(), 582 + index: index, 583 + seenKeys: _seenResultKeys, 584 + child: _FeedResultTile( 585 + feed: feed, 586 + onAdded: (displayName) { 587 + showAppSnackBar( 588 + context, 589 + 'Added $displayName to your saved feeds', 590 + actionLabel: 'Manage', 591 + onAction: () => GoRouter.maybeOf(context)?.push('/feeds'), 592 + ); 593 + }, 594 + ), 566 595 ); 567 596 }, 568 597 ); ··· 584 613 ); 585 614 } 586 615 final pack = packs[index]; 587 - return StarterPackCard( 588 - pack: pack, 589 - onTap: () { 590 - final router = GoRouter.maybeOf(context); 591 - if (router != null) { 592 - router.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'); 593 - } 594 - }, 616 + return StaggeredEntrance( 617 + itemKey: pack.uri.toString(), 618 + index: index, 619 + seenKeys: _seenResultKeys, 620 + child: StarterPackCard( 621 + pack: pack, 622 + onTap: () { 623 + final router = GoRouter.maybeOf(context); 624 + if (router != null) { 625 + router.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'); 626 + } 627 + }, 628 + ), 595 629 ); 596 630 }, 597 631 );
+10
lib/features/settings/bloc/settings_cubit.dart
··· 12 12 AppThemeVariant? initialVariant, 13 13 bool? initialUseSystemTheme, 14 14 FeedLayout? initialFeedLayout, 15 + bool? initialAnimationsEnabled, 15 16 bool? initialSimulateOffline, 16 17 int? initialThreadAutoCollapseDepth, 17 18 String? initialConstellationUrl, ··· 21 22 themeVariant: initialVariant ?? AppThemeVariant.dark, 22 23 useSystemTheme: initialUseSystemTheme ?? false, 23 24 feedLayout: initialFeedLayout ?? FeedLayout.card, 25 + animationsEnabled: initialAnimationsEnabled ?? true, 24 26 simulateOffline: initialSimulateOffline ?? false, 25 27 threadAutoCollapseDepth: initialThreadAutoCollapseDepth, 26 28 constellationUrl: initialConstellationUrl ?? 'https://constellation.microcosm.blue', ··· 34 36 static const String _keyUseSystemTheme = 'use_system_theme'; 35 37 static const String _keyFeedLayout = 'feed_layout'; 36 38 static const String _legacyKeyFeedArchitecture = 'feed_architecture'; 39 + static const String _keyAnimationsEnabled = 'animations_enabled'; 37 40 static const String _keySimulateOffline = 'simulate_offline'; 38 41 static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; 39 42 static const String _keyConstellationUrl = 'constellation_url'; ··· 48 51 final useSystemStr = await database.getSetting(_keyUseSystemTheme); 49 52 final feedLayoutStr = 50 53 await database.getSetting(_keyFeedLayout) ?? await database.getSetting(_legacyKeyFeedArchitecture); 54 + final animationsEnabledStr = await database.getSetting(_keyAnimationsEnabled); 51 55 final simulateOfflineStr = await database.getSetting(_keySimulateOffline); 52 56 final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); 53 57 final constellationUrlStr = await database.getSetting(_keyConstellationUrl); ··· 61 65 themeVariant: AppTheme.parseVariant(variantStr), 62 66 useSystemTheme: useSystemStr == 'true', 63 67 feedLayout: FeedLayout.fromString(feedLayoutStr), 68 + animationsEnabled: animationsEnabledStr != 'false', 64 69 simulateOffline: simulateOfflineStr == 'true', 65 70 threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 66 71 constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, ··· 96 101 await database.setSetting(_keyFeedLayout, layout.name); 97 102 await database.deleteSetting(_legacyKeyFeedArchitecture); 98 103 emit(state.copyWith(feedLayout: layout)); 104 + } 105 + 106 + Future<void> setAnimationsEnabled(bool value) async { 107 + await database.setSetting(_keyAnimationsEnabled, value.toString()); 108 + emit(state.copyWith(animationsEnabled: value)); 99 109 } 100 110 101 111 Future<void> setSimulateOffline(bool value) async {
+5
lib/features/settings/bloc/settings_state.dart
··· 11 11 required this.themeVariant, 12 12 required this.useSystemTheme, 13 13 this.feedLayout = FeedLayout.card, 14 + this.animationsEnabled = true, 14 15 this.simulateOffline = false, 15 16 this.threadAutoCollapseDepth, 16 17 this.constellationUrl = 'https://constellation.microcosm.blue', ··· 23 24 final AppThemeVariant themeVariant; 24 25 final bool useSystemTheme; 25 26 final FeedLayout feedLayout; 27 + final bool animationsEnabled; 26 28 final bool simulateOffline; 27 29 final int? threadAutoCollapseDepth; 28 30 final String constellationUrl; ··· 41 43 AppThemeVariant? themeVariant, 42 44 bool? useSystemTheme, 43 45 FeedLayout? feedLayout, 46 + bool? animationsEnabled, 44 47 bool? simulateOffline, 45 48 Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, 46 49 String? constellationUrl, ··· 53 56 themeVariant: themeVariant ?? this.themeVariant, 54 57 useSystemTheme: useSystemTheme ?? this.useSystemTheme, 55 58 feedLayout: feedLayout ?? this.feedLayout, 59 + animationsEnabled: animationsEnabled ?? this.animationsEnabled, 56 60 simulateOffline: simulateOffline ?? this.simulateOffline, 57 61 threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) 58 62 ? this.threadAutoCollapseDepth ··· 70 74 themeVariant, 71 75 useSystemTheme, 72 76 feedLayout, 77 + animationsEnabled, 73 78 simulateOffline, 74 79 threadAutoCollapseDepth, 75 80 constellationUrl,
+10
lib/features/settings/presentation/settings_screen.dart
··· 286 286 labelBuilder: (depth) => depth == null ? 'Off' : 'Depth $depth', 287 287 onChanged: settingsCubit.setThreadAutoCollapseDepth, 288 288 ), 289 + const Divider(height: 1), 290 + _SettingsTile( 291 + icon: Icons.motion_photos_off_outlined, 292 + title: 'Animations', 293 + subtitle: 'Turn off non-essential motion effects', 294 + trailing: Switch.adaptive( 295 + value: state.animationsEnabled, 296 + onChanged: settingsCubit.setAnimationsEnabled, 297 + ), 298 + ), 289 299 ], 290 300 ), 291 301 );
+22 -5
lib/features/starter_packs/presentation/actor_starter_packs_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 2 3 import 'package:flutter_bloc/flutter_bloc.dart'; 3 4 import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/core/theme/animation_tokens.dart'; 6 + import 'package:lazurite/core/theme/animation_utils.dart'; 4 7 import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 5 8 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 6 9 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 10 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 11 + import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 7 12 8 13 class ActorStarterPacksScreen extends StatelessWidget { 9 14 const ActorStarterPacksScreen({super.key, required this.actor}); ··· 24 29 const _ActorStarterPacksView({required this.actor}); 25 30 26 31 final String actor; 32 + static final Set<String> _seenPackUris = <String>{}; 27 33 28 34 @override 29 35 Widget build(BuildContext context) { ··· 40 46 onPressed: () => context.push('/create-starter-pack'), 41 47 tooltip: 'Create starter pack', 42 48 child: const Icon(Icons.add), 49 + ).animateIfAllowed( 50 + context, 51 + effects: const [ 52 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 53 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 54 + ], 43 55 ) 44 56 : null, 45 57 body: BlocBuilder<ActorStarterPacksCubit, ActorStarterPacksState>( ··· 68 80 return const Center(child: Text('No starter packs yet')); 69 81 } 70 82 71 - return RefreshIndicator( 83 + return AnimatedRefreshIndicator( 72 84 onRefresh: () => context.read<ActorStarterPacksCubit>().refresh(), 73 85 child: NotificationListener<ScrollNotification>( 74 86 onNotification: (notification) { ··· 91 103 } 92 104 93 105 final pack = state.starterPacks[index]; 94 - return StarterPackCard( 95 - key: ValueKey(pack.uri), 96 - pack: pack, 97 - onTap: () => context.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'), 106 + return StaggeredEntrance( 107 + itemKey: pack.uri.toString(), 108 + index: index, 109 + seenKeys: _seenPackUris, 110 + child: StarterPackCard( 111 + key: ValueKey(pack.uri), 112 + pack: pack, 113 + onTap: () => context.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'), 114 + ), 98 115 ); 99 116 }, 100 117 ),
+14 -2
lib/shared/presentation/helpers/snackbar_helper.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 3 + import 'package:lazurite/core/theme/animation_tokens.dart'; 4 + import 'package:lazurite/core/theme/animation_utils.dart'; 2 5 import 'package:lazurite/core/theme/theme_extensions.dart'; 3 6 4 7 ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showAppSnackBar( ··· 18 21 messenger.hideCurrentSnackBar(); 19 22 } 20 23 24 + final snackBarBehavior = behavior ?? SnackBarBehavior.floating; 25 + final animatedContent = Text(message).animateIfAllowed( 26 + context, 27 + effects: const [ 28 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 29 + SlideEffect(begin: Offset(0, 0.25), end: Offset.zero, duration: Anim.feedItem, curve: Anim.enter), 30 + ], 31 + ); 32 + 21 33 return messenger.showSnackBar( 22 34 SnackBar( 23 - content: Text(message), 24 - behavior: behavior, 35 + content: animatedContent, 36 + behavior: snackBarBehavior, 25 37 duration: duration ?? const Duration(seconds: 4), 26 38 backgroundColor: isError ? colorScheme.error : null, 27 39 action: actionLabel == null ? null : SnackBarAction(label: actionLabel, onPressed: onAction ?? () {}),
+85
lib/shared/presentation/widgets/animated_refresh_indicator.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:lazurite/core/theme/animation_tokens.dart'; 5 + import 'package:lazurite/core/theme/animation_utils.dart'; 6 + 7 + class AnimatedRefreshIndicator extends StatefulWidget { 8 + const AnimatedRefreshIndicator({super.key, required this.onRefresh, required this.child, this.displacement = 40}); 9 + 10 + final RefreshCallback onRefresh; 11 + final Widget child; 12 + final double displacement; 13 + 14 + @override 15 + State<AnimatedRefreshIndicator> createState() => _AnimatedRefreshIndicatorState(); 16 + } 17 + 18 + class _AnimatedRefreshIndicatorState extends State<AnimatedRefreshIndicator> with SingleTickerProviderStateMixin { 19 + late final AnimationController _rotationController; 20 + bool _refreshing = false; 21 + 22 + @override 23 + void initState() { 24 + super.initState(); 25 + _rotationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 700)); 26 + } 27 + 28 + @override 29 + void dispose() { 30 + _rotationController.dispose(); 31 + super.dispose(); 32 + } 33 + 34 + Future<void> _handleRefresh() async { 35 + if (_refreshing) { 36 + return; 37 + } 38 + 39 + setState(() => _refreshing = true); 40 + unawaited(_rotationController.repeat()); 41 + 42 + try { 43 + await widget.onRefresh(); 44 + } finally { 45 + _rotationController.stop(); 46 + if (mounted) { 47 + setState(() => _refreshing = false); 48 + } 49 + } 50 + } 51 + 52 + @override 53 + Widget build(BuildContext context) => Stack( 54 + children: [ 55 + RefreshIndicator(onRefresh: _handleRefresh, displacement: widget.displacement, child: widget.child), 56 + if (animationsAllowed(context)) 57 + Positioned( 58 + top: 12, 59 + right: 16, 60 + child: IgnorePointer( 61 + child: AnimatedOpacity( 62 + duration: Anim.fast, 63 + opacity: _refreshing ? 1 : 0, 64 + child: AnimatedScale( 65 + duration: Anim.refreshComplete, 66 + scale: _refreshing ? 1 : 0.8, 67 + curve: Anim.enter, 68 + child: RotationTransition( 69 + turns: _rotationController, 70 + child: DecoratedBox( 71 + decoration: BoxDecoration( 72 + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.88), 73 + borderRadius: BorderRadius.circular(999), 74 + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), 75 + ), 76 + child: const Padding(padding: EdgeInsets.all(8), child: Icon(Icons.sync, size: 16)), 77 + ), 78 + ), 79 + ), 80 + ), 81 + ), 82 + ), 83 + ], 84 + ); 85 + }
+26 -11
lib/shared/presentation/widgets/empty_state.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 3 + import 'package:lazurite/core/theme/animation_tokens.dart'; 4 + import 'package:lazurite/core/theme/animation_utils.dart'; 2 5 import 'package:lazurite/core/theme/theme_extensions.dart'; 3 6 4 7 class EmptyState extends StatelessWidget { ··· 21 24 Widget build(BuildContext context) { 22 25 final colorScheme = context.colorScheme; 23 26 final textTheme = context.textTheme; 27 + final content = Column( 28 + mainAxisSize: MainAxisSize.min, 29 + children: [ 30 + Icon(icon, size: 48, color: colorScheme.outline), 31 + const SizedBox(height: 12), 32 + Text( 33 + message, 34 + textAlign: TextAlign.center, 35 + style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), 36 + ), 37 + ..._subtitle(subtitle, textTheme, colorScheme), 38 + ..._action(action), 39 + ], 40 + ); 24 41 25 42 return Center( 26 43 child: Padding( 27 44 padding: padding, 28 - child: Column( 29 - mainAxisSize: MainAxisSize.min, 30 - children: [ 31 - Icon(icon, size: 48, color: colorScheme.outline), 32 - const SizedBox(height: 12), 33 - Text( 34 - message, 35 - textAlign: TextAlign.center, 36 - style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), 45 + child: content.animateIfAllowed( 46 + context, 47 + effects: const [ 48 + FadeEffect(duration: Anim.screenTransition, curve: Anim.enter), 49 + ScaleEffect( 50 + begin: Offset(0.95, 0.95), 51 + end: Offset(1, 1), 52 + duration: Anim.screenTransition, 53 + curve: Anim.enter, 37 54 ), 38 - ..._subtitle(subtitle, textTheme, colorScheme), 39 - ..._action(action), 40 55 ], 41 56 ), 42 57 ),
+25 -1
lib/shared/presentation/widgets/loading_state.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:lazurite/shared/presentation/widgets/shimmer_skeleton.dart'; 2 3 3 4 class LoadingState extends StatelessWidget { 4 5 const LoadingState({super.key, this.message, this.padding = const EdgeInsets.all(24)}); ··· 14 15 padding: padding, 15 16 child: Column( 16 17 mainAxisSize: MainAxisSize.min, 17 - children: [const CircularProgressIndicator(), ..._message(message, theme.textTheme, theme.colorScheme)], 18 + children: [ 19 + const CircularProgressIndicator(), 20 + const SizedBox(height: 16), 21 + const _LoadingSkeleton(), 22 + ..._message(message, theme.textTheme, theme.colorScheme), 23 + ], 18 24 ), 19 25 ), 20 26 ); ··· 31 37 ), 32 38 ]; 33 39 } 40 + 41 + class _LoadingSkeleton extends StatelessWidget { 42 + const _LoadingSkeleton(); 43 + 44 + @override 45 + Widget build(BuildContext context) { 46 + return const SizedBox( 47 + width: 220, 48 + child: Column( 49 + children: [ 50 + ShimmerSkeletonLine(width: 220, height: 12), 51 + SizedBox(height: 8), 52 + ShimmerSkeletonLine(width: 168, height: 12), 53 + ], 54 + ), 55 + ); 56 + } 57 + }
+24
lib/shared/presentation/widgets/shimmer_skeleton.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 3 + import 'package:lazurite/core/theme/animation_tokens.dart'; 4 + import 'package:lazurite/core/theme/animation_utils.dart'; 5 + 6 + class ShimmerSkeletonLine extends StatelessWidget { 7 + const ShimmerSkeletonLine({super.key, this.width, this.height = 14}); 8 + 9 + final double? width; 10 + final double height; 11 + 12 + @override 13 + Widget build(BuildContext context) { 14 + final theme = Theme.of(context); 15 + return Container( 16 + width: width, 17 + height: height, 18 + decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.zero), 19 + ).animateIfAllowed( 20 + context, 21 + effects: [ShimmerEffect(duration: Anim.shimmerCycle, color: theme.colorScheme.surfaceContainerHigh)], 22 + ); 23 + } 24 + }
+42
lib/shared/presentation/widgets/staggered_entrance.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 3 + import 'package:lazurite/core/theme/animation_tokens.dart'; 4 + import 'package:lazurite/core/theme/animation_utils.dart'; 5 + 6 + class StaggeredEntrance extends StatelessWidget { 7 + const StaggeredEntrance({ 8 + super.key, 9 + required this.child, 10 + required this.itemKey, 11 + required this.index, 12 + required this.seenKeys, 13 + }); 14 + 15 + final Widget child; 16 + final String itemKey; 17 + final int index; 18 + 19 + /// Widget keys that have been seen before, used to determine whether to animate the child. 20 + /// 21 + /// Mark immediately so rebuilds during the same frame do not restart the entrance sequence. 22 + final Set<String> seenKeys; 23 + 24 + @override 25 + Widget build(BuildContext context) { 26 + final hasSeen = seenKeys.contains(itemKey); 27 + if (hasSeen) { 28 + return child; 29 + } 30 + 31 + seenKeys.add(itemKey); 32 + 33 + return child.animateIfAllowed( 34 + context, 35 + delay: Anim.staggerFor(index), 36 + effects: const [ 37 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 38 + SlideEffect(begin: Offset(0, 0.05), end: Offset.zero, duration: Anim.feedItem, curve: Anim.enter), 39 + ], 40 + ); 41 + } 42 + }
+29
test/core/theme/animation_tokens_test.dart
··· 1 + import 'package:flutter/animation.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/core/theme/animation_tokens.dart'; 4 + 5 + void main() { 6 + test('defines expected animation tokens', () { 7 + expect(Anim.fast, const Duration(milliseconds: 150)); 8 + expect(Anim.normal, const Duration(milliseconds: 250)); 9 + expect(Anim.slow, const Duration(milliseconds: 400)); 10 + 11 + expect(Anim.feedItem, const Duration(milliseconds: 200)); 12 + expect(Anim.screenTransition, const Duration(milliseconds: 300)); 13 + expect(Anim.shimmerCycle, const Duration(milliseconds: 1200)); 14 + 15 + expect(Anim.enter, Curves.easeOut); 16 + expect(Anim.exit, Curves.easeIn); 17 + expect(Anim.emphasis, Curves.easeOutBack); 18 + 19 + expect(Anim.staggerOffset, const Duration(milliseconds: 50)); 20 + expect(Anim.maxStaggerItems, 10); 21 + }); 22 + 23 + test('staggerFor clamps index to max range', () { 24 + expect(Anim.staggerFor(0), const Duration(milliseconds: 0)); 25 + expect(Anim.staggerFor(1), const Duration(milliseconds: 50)); 26 + expect(Anim.staggerFor(9), const Duration(milliseconds: 450)); 27 + expect(Anim.staggerFor(50), const Duration(milliseconds: 450)); 28 + }); 29 + }
+74
test/core/theme/animation_utils_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_animate/flutter_animate.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/core/theme/app_theme.dart'; 7 + import 'package:lazurite/core/theme/animation_tokens.dart'; 8 + import 'package:lazurite/core/theme/animation_utils.dart'; 9 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 10 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 14 + 15 + class _AnimatedProbe extends StatelessWidget { 16 + const _AnimatedProbe(); 17 + 18 + @override 19 + Widget build(BuildContext context) { 20 + return const Text('probe').animateIfAllowed(context, effects: const [FadeEffect(duration: Anim.fast)]); 21 + } 22 + } 23 + 24 + void main() { 25 + Widget wrap(Widget child, {bool disableAnimations = false, SettingsCubit? settingsCubit}) { 26 + final base = MaterialApp( 27 + home: MediaQuery( 28 + data: MediaQueryData(disableAnimations: disableAnimations), 29 + child: Scaffold(body: Center(child: child)), 30 + ), 31 + ); 32 + 33 + if (settingsCubit == null) { 34 + return base; 35 + } 36 + 37 + return BlocProvider<SettingsCubit>.value(value: settingsCubit, child: base); 38 + } 39 + 40 + testWidgets('animateIfAllowed is disabled under widget tests', (tester) async { 41 + await tester.pumpWidget(wrap(const _AnimatedProbe())); 42 + await tester.pumpAndSettle(); 43 + 44 + expect(find.byType(Animate), findsNothing); 45 + expect(find.text('probe'), findsOneWidget); 46 + }); 47 + 48 + testWidgets('animateIfAllowed skips when platform reduced motion is enabled', (tester) async { 49 + await tester.pumpWidget(wrap(const _AnimatedProbe(), disableAnimations: true)); 50 + await tester.pumpAndSettle(); 51 + 52 + expect(find.byType(Animate), findsNothing); 53 + expect(find.text('probe'), findsOneWidget); 54 + }); 55 + 56 + testWidgets('animateIfAllowed skips when user disables animations', (tester) async { 57 + final settingsCubit = MockSettingsCubit(); 58 + const state = SettingsState( 59 + themePalette: AppThemePalette.oxocarbon, 60 + themeVariant: AppThemeVariant.dark, 61 + useSystemTheme: false, 62 + animationsEnabled: false, 63 + ); 64 + 65 + when(() => settingsCubit.state).thenReturn(state); 66 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: state); 67 + 68 + await tester.pumpWidget(wrap(const _AnimatedProbe(), settingsCubit: settingsCubit)); 69 + await tester.pumpAndSettle(); 70 + 71 + expect(find.byType(Animate), findsNothing); 72 + expect(find.text('probe'), findsOneWidget); 73 + }); 74 + }
+17
test/features/settings/bloc/settings_cubit_test.dart
··· 25 25 expect(cubit.state.themeVariant, AppThemeVariant.dark); 26 26 expect(cubit.state.useSystemTheme, false); 27 27 expect(cubit.state.feedLayout, FeedLayout.card); 28 + expect(cubit.state.animationsEnabled, true); 28 29 expect(cubit.state.simulateOffline, false); 29 30 expect(cubit.state.threadAutoCollapseDepth, isNull); 30 31 }); ··· 36 37 initialVariant: AppThemeVariant.light, 37 38 initialUseSystemTheme: true, 38 39 initialFeedLayout: FeedLayout.compact, 40 + initialAnimationsEnabled: false, 39 41 initialSimulateOffline: true, 40 42 initialThreadAutoCollapseDepth: 3, 41 43 ); ··· 43 45 expect(cubit.state.themeVariant, AppThemeVariant.light); 44 46 expect(cubit.state.useSystemTheme, true); 45 47 expect(cubit.state.feedLayout, FeedLayout.compact); 48 + expect(cubit.state.animationsEnabled, false); 46 49 expect(cubit.state.simulateOffline, true); 47 50 expect(cubit.state.threadAutoCollapseDepth, 3); 48 51 }); ··· 55 58 await database.setSetting('theme_variant', 'light'); 56 59 await database.setSetting('use_system_theme', 'true'); 57 60 await database.setSetting('feed_architecture', 'linear'); 61 + await database.setSetting('animations_enabled', 'false'); 58 62 await database.setSetting('simulate_offline', 'true'); 59 63 await database.setSetting('thread_auto_collapse_depth', '4'); 60 64 }, ··· 65 69 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.light) 66 70 .having((s) => s.useSystemTheme, 'useSystemTheme', true) 67 71 .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) 72 + .having((s) => s.animationsEnabled, 'animationsEnabled', false) 68 73 .having((s) => s.simulateOffline, 'simulateOffline', true) 69 74 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), 70 75 ], ··· 80 85 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.dark) 81 86 .having((s) => s.useSystemTheme, 'useSystemTheme', false) 82 87 .having((s) => s.feedLayout, 'feedLayout', FeedLayout.card) 88 + .having((s) => s.animationsEnabled, 'animationsEnabled', true) 83 89 .having((s) => s.simulateOffline, 'simulateOffline', false) 84 90 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), 85 91 ], ··· 166 172 verify: (cubit) async { 167 173 expect(await database.getSetting('feed_layout'), 'compact'); 168 174 expect(await database.getSetting('feed_architecture'), isNull); 175 + }, 176 + ); 177 + 178 + blocTest<SettingsCubit, SettingsState>( 179 + 'setAnimationsEnabled updates state and persists to database', 180 + build: () => SettingsCubit(database: database), 181 + act: (cubit) => cubit.setAnimationsEnabled(false), 182 + expect: () => [isA<SettingsState>().having((s) => s.animationsEnabled, 'animationsEnabled', false)], 183 + verify: (cubit) async { 184 + final value = await database.getSetting('animations_enabled'); 185 + expect(value, 'false'); 169 186 }, 170 187 ); 171 188
+32
test/features/settings/bloc/settings_state_test.dart
··· 82 82 expect(state1, isNot(equals(state2))); 83 83 }); 84 84 85 + test('inequality when animationsEnabled differs', () { 86 + const state1 = SettingsState( 87 + themePalette: AppThemePalette.oxocarbon, 88 + themeVariant: AppThemeVariant.dark, 89 + useSystemTheme: false, 90 + animationsEnabled: true, 91 + ); 92 + const state2 = SettingsState( 93 + themePalette: AppThemePalette.oxocarbon, 94 + themeVariant: AppThemeVariant.dark, 95 + useSystemTheme: false, 96 + animationsEnabled: false, 97 + ); 98 + 99 + expect(state1, isNot(equals(state2))); 100 + }); 101 + 85 102 test('inequality when simulateOffline differs', () { 86 103 const state1 = SettingsState( 87 104 themePalette: AppThemePalette.oxocarbon, ··· 128 145 themeVariant: AppThemeVariant.light, 129 146 useSystemTheme: true, 130 147 feedLayout: FeedLayout.compact, 148 + animationsEnabled: false, 131 149 simulateOffline: true, 132 150 threadAutoCollapseDepth: 3, 133 151 ); ··· 136 154 expect(updated.themeVariant, AppThemeVariant.light); 137 155 expect(updated.useSystemTheme, true); 138 156 expect(updated.feedLayout, FeedLayout.compact); 157 + expect(updated.animationsEnabled, false); 139 158 expect(updated.simulateOffline, true); 140 159 expect(updated.threadAutoCollapseDepth, 3); 141 160 expect(original.themePalette, AppThemePalette.oxocarbon); ··· 147 166 themeVariant: AppThemeVariant.light, 148 167 useSystemTheme: true, 149 168 feedLayout: FeedLayout.compact, 169 + animationsEnabled: false, 150 170 simulateOffline: true, 151 171 threadAutoCollapseDepth: 4, 152 172 ); ··· 157 177 expect(updated.themeVariant, AppThemeVariant.light); 158 178 expect(updated.useSystemTheme, true); 159 179 expect(updated.feedLayout, FeedLayout.compact); 180 + expect(updated.animationsEnabled, false); 160 181 expect(updated.simulateOffline, true); 161 182 expect(updated.threadAutoCollapseDepth, 4); 162 183 }); ··· 180 201 themeVariant: AppThemeVariant.light, 181 202 useSystemTheme: true, 182 203 feedLayout: FeedLayout.compact, 204 + animationsEnabled: false, 183 205 simulateOffline: true, 184 206 threadAutoCollapseDepth: 6, 185 207 ); ··· 188 210 expect(state.props, contains(AppThemeVariant.light)); 189 211 expect(state.props, contains(true)); 190 212 expect(state.props, contains(FeedLayout.compact)); 213 + expect(state.props, contains(false)); 191 214 expect(state.props, contains(true)); 192 215 expect(state.props, contains(6)); 193 216 }); ··· 208 231 useSystemTheme: false, 209 232 ); 210 233 expect(state.simulateOffline, isFalse); 234 + }); 235 + 236 + test('defaults animationsEnabled to true', () { 237 + const state = SettingsState( 238 + themePalette: AppThemePalette.oxocarbon, 239 + themeVariant: AppThemeVariant.dark, 240 + useSystemTheme: false, 241 + ); 242 + expect(state.animationsEnabled, isTrue); 211 243 }); 212 244 213 245 test('defaults threadAutoCollapseDepth to null', () {
+1
test/features/settings/presentation/settings_screen_test.dart
··· 117 117 expect(find.text('LAYOUT'), findsOneWidget); 118 118 expect(find.text('Feed Layout'), findsOneWidget); 119 119 expect(find.text('Thread Auto-Collapse'), findsOneWidget); 120 + expect(find.text('Animations'), findsOneWidget); 120 121 }); 121 122 122 123 testWidgets('shows the AT Protocol connection card for the authenticated account', (tester) async {
+15 -2
test/shared/presentation/widgets/empty_state_test.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_animate/flutter_animate.dart'; 2 3 import 'package:flutter_test/flutter_test.dart'; 3 4 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 4 5 5 6 void main() { 6 - Widget buildSubject(Widget child) { 7 - return MaterialApp(home: Scaffold(body: child)); 7 + Widget buildSubject(Widget child, {bool disableAnimations = false}) { 8 + return MaterialApp( 9 + home: MediaQuery( 10 + data: MediaQueryData(disableAnimations: disableAnimations), 11 + child: Scaffold(body: child), 12 + ), 13 + ); 8 14 } 9 15 10 16 testWidgets('renders message and icon', (tester) async { ··· 28 34 expect(find.text('No feeds pinned'), findsOneWidget); 29 35 expect(find.text('Add feeds to continue.'), findsOneWidget); 30 36 expect(find.text('Manage Feeds'), findsOneWidget); 37 + }); 38 + 39 + testWidgets('skips entrance animation when reduced motion is enabled', (tester) async { 40 + await tester.pumpWidget(buildSubject(const EmptyState(message: 'Nothing here'), disableAnimations: true)); 41 + 42 + expect(find.byType(Animate), findsNothing); 43 + expect(find.text('Nothing here'), findsOneWidget); 31 44 }); 32 45 }