[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

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

feat(feed): skeleton loading in feed page

+319 -28
+2 -9
lib/src/features/feed/ui/pages/feed_page.dart
··· 7 7 import 'package:spark/src/features/feed/providers/feed_refresh_trigger_provider.dart'; 8 8 import 'package:spark/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; 9 9 import 'package:spark/src/features/feed/ui/widgets/feed/snappy_page_scroll_physics.dart'; 10 + import 'package:spark/src/features/feed/ui/widgets/post/feed_post_skeleton.dart'; 10 11 import 'package:spark/src/features/feed/ui/widgets/post/feed_post_widget.dart'; 11 12 import 'package:spark/src/features/feed/ui/widgets/post/no_more_posts.dart'; 12 13 import 'package:spark/src/features/settings/providers/settings_provider.dart'; ··· 151 152 key: _refreshIndicatorKey, 152 153 onRefresh: onRefresh, 153 154 child: state.loadingFirstLoad 154 - ? const Center(child: CircularProgressIndicator()) 155 + ? const FeedPostSkeleton() 155 156 : state.error 156 157 ? Center( 157 158 child: Column( ··· 223 224 decoration: BoxDecoration(color: AppColors.black), 224 225 ); 225 226 } 226 - } 227 - // Handle first load state 228 - else if (state.length == 0 && state.loadingFirstLoad) { 229 - return shouldBeActive 230 - ? const Center(child: CircularProgressIndicator()) 231 - : const DecoratedBox( 232 - decoration: BoxDecoration(color: AppColors.black), 233 - ); 234 227 } 235 228 // Handle empty state 236 229 else if (state.length == 0 && !state.loadingFirstLoad) {
+5 -1
lib/src/features/feed/ui/pages/feeds_page.dart
··· 168 168 }, 169 169 ) 170 170 else 171 - const Center(child: CircularProgressIndicator()), 171 + // Show black background briefly while feeds initialize 172 + // The FeedPage will show skeleton once it renders 173 + const DecoratedBox( 174 + decoration: BoxDecoration(color: AppColors.black), 175 + ), 172 176 // Always show FeedsBar once we have a controller 173 177 // The controller is created as soon as we have activeFeed, 174 178 // keeping it visible through the initialization transition
+22 -2
lib/src/features/feed/ui/widgets/images/image_carousel.dart
··· 25 25 late List<Widget> _cachedPages; 26 26 int currentIndex = 0; 27 27 bool _imagesPreloaded = false; 28 + // Track which images have already been revealed to prevent animation restart 29 + final Set<int> _revealedImages = {}; 28 30 29 31 @override 30 32 void initState() { ··· 97 99 width: double.infinity, 98 100 gaplessPlayback: true, 99 101 frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { 100 - if (wasSynchronouslyLoaded || frame != null) { 102 + if (wasSynchronouslyLoaded) { 101 103 return child; 102 104 } 103 - return const Center(child: CircularProgressIndicator()); 105 + if (frame != null) { 106 + // Check if this image has already been revealed to prevent 107 + // animation restart on subsequent frameBuilder calls 108 + if (_revealedImages.contains(index)) { 109 + return child; 110 + } 111 + // Mark as revealed before animating 112 + _revealedImages.add(index); 113 + // Use TweenAnimationBuilder for smooth fade-in on first render 114 + return TweenAnimationBuilder<double>( 115 + tween: Tween(begin: 0.0, end: 1.0), 116 + duration: const Duration(milliseconds: 300), 117 + curve: Curves.easeIn, 118 + builder: (context, opacity, _) { 119 + return Opacity(opacity: opacity, child: child); 120 + }, 121 + ); 122 + } 123 + return const SizedBox.shrink(); 104 124 }, 105 125 errorBuilder: (context, error, stackTrace) => const Center( 106 126 child: Icon(FluentIcons.error_circle_24_regular),
+245
lib/src/features/feed/ui/widgets/post/feed_post_skeleton.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:skeletonizer/skeletonizer.dart'; 3 + import 'package:spark/src/core/ui/foundation/colors.dart'; 4 + 5 + /// A skeleton loading placeholder for feed posts. 6 + /// Displays a shimmer animation over placeholder content that mimics 7 + /// the structure of a real feed post with InfoBar and SideActionBar. 8 + class FeedPostSkeleton extends StatelessWidget { 9 + const FeedPostSkeleton({super.key}); 10 + 11 + @override 12 + Widget build(BuildContext context) { 13 + final bottomPadding = MediaQuery.of(context).padding.bottom; 14 + 15 + return DecoratedBox( 16 + decoration: const BoxDecoration(color: AppColors.black), 17 + child: Stack( 18 + children: [ 19 + // Gradient overlay (matching PostOverlay) 20 + Positioned( 21 + left: 0, 22 + right: 0, 23 + bottom: 0, 24 + child: IgnorePointer( 25 + child: Container( 26 + height: 250 + bottomPadding, 27 + decoration: BoxDecoration( 28 + gradient: LinearGradient( 29 + begin: Alignment.bottomCenter, 30 + end: Alignment.topCenter, 31 + colors: [ 32 + Colors.black87.withAlpha(200), 33 + Colors.black54.withAlpha(100), 34 + Colors.transparent, 35 + ], 36 + stops: const [0.0, 0.6, 1.0], 37 + ), 38 + ), 39 + ), 40 + ), 41 + ), 42 + 43 + // Skeleton content overlay 44 + Positioned.fill( 45 + child: Skeletonizer( 46 + effect: const ShimmerEffect( 47 + baseColor: Color(0xFF2A2A2A), 48 + highlightColor: Color(0xFF3A3A3A), 49 + ), 50 + child: Column( 51 + mainAxisAlignment: MainAxisAlignment.end, 52 + crossAxisAlignment: CrossAxisAlignment.stretch, 53 + children: [ 54 + const Spacer(), 55 + Row( 56 + crossAxisAlignment: CrossAxisAlignment.end, 57 + children: [ 58 + // Skeleton Info Bar (Left side) 59 + const Expanded(child: _SkeletonInfoBar()), 60 + 61 + // Skeleton Side Action Bar (Right side) 62 + Padding( 63 + padding: const EdgeInsets.only(right: 8, bottom: 8), 64 + child: const _SkeletonSideActionBar(), 65 + ), 66 + ], 67 + ), 68 + 69 + // Bottom padding for navigation bar 70 + SizedBox(height: 16 + bottomPadding), 71 + ], 72 + ), 73 + ), 74 + ), 75 + ], 76 + ), 77 + ); 78 + } 79 + } 80 + 81 + /// Skeleton placeholder for the InfoBar (author info and caption). 82 + class _SkeletonInfoBar extends StatelessWidget { 83 + const _SkeletonInfoBar(); 84 + 85 + @override 86 + Widget build(BuildContext context) { 87 + return Column( 88 + crossAxisAlignment: CrossAxisAlignment.start, 89 + mainAxisSize: MainAxisSize.min, 90 + children: [ 91 + // Author info row 92 + Row( 93 + crossAxisAlignment: CrossAxisAlignment.start, 94 + children: [ 95 + // Avatar placeholder 96 + Padding( 97 + padding: const EdgeInsets.all(8), 98 + child: Skeleton.leaf( 99 + child: Container( 100 + width: 36, 101 + height: 36, 102 + decoration: const BoxDecoration( 103 + shape: BoxShape.circle, 104 + color: Colors.white, 105 + ), 106 + ), 107 + ), 108 + ), 109 + 110 + // Name and handle 111 + Expanded( 112 + child: Column( 113 + crossAxisAlignment: CrossAxisAlignment.start, 114 + children: [ 115 + // Display name placeholder 116 + Skeleton.leaf( 117 + child: Container( 118 + width: 120, 119 + height: 16, 120 + decoration: BoxDecoration( 121 + color: Colors.white, 122 + borderRadius: BorderRadius.circular(4), 123 + ), 124 + ), 125 + ), 126 + const SizedBox(height: 4), 127 + // Handle placeholder 128 + Skeleton.leaf( 129 + child: Container( 130 + width: 80, 131 + height: 12, 132 + decoration: BoxDecoration( 133 + color: Colors.white, 134 + borderRadius: BorderRadius.circular(4), 135 + ), 136 + ), 137 + ), 138 + ], 139 + ), 140 + ), 141 + ], 142 + ), 143 + 144 + const SizedBox(height: 10), 145 + 146 + // Description placeholder (2 lines) 147 + Padding( 148 + padding: const EdgeInsets.only(left: 8), 149 + child: Column( 150 + crossAxisAlignment: CrossAxisAlignment.start, 151 + children: [ 152 + Skeleton.leaf( 153 + child: Container( 154 + width: double.infinity, 155 + height: 14, 156 + margin: const EdgeInsets.only(right: 60), 157 + decoration: BoxDecoration( 158 + color: Colors.white, 159 + borderRadius: BorderRadius.circular(4), 160 + ), 161 + ), 162 + ), 163 + const SizedBox(height: 6), 164 + Skeleton.leaf( 165 + child: Container( 166 + width: 200, 167 + height: 14, 168 + decoration: BoxDecoration( 169 + color: Colors.white, 170 + borderRadius: BorderRadius.circular(4), 171 + ), 172 + ), 173 + ), 174 + ], 175 + ), 176 + ), 177 + ], 178 + ); 179 + } 180 + } 181 + 182 + /// Skeleton placeholder for the SideActionBar (action buttons). 183 + class _SkeletonSideActionBar extends StatelessWidget { 184 + const _SkeletonSideActionBar(); 185 + 186 + @override 187 + Widget build(BuildContext context) { 188 + return Column( 189 + mainAxisSize: MainAxisSize.min, 190 + children: [ 191 + // Like button 192 + _SkeletonActionItem(hasLabel: true), 193 + const SizedBox(height: 13), 194 + // Comment button 195 + _SkeletonActionItem(hasLabel: true), 196 + const SizedBox(height: 13), 197 + // Repost button 198 + _SkeletonActionItem(hasLabel: true), 199 + const SizedBox(height: 13), 200 + // Share button 201 + _SkeletonActionItem(hasLabel: false), 202 + ], 203 + ); 204 + } 205 + } 206 + 207 + /// A single skeleton action button placeholder. 208 + class _SkeletonActionItem extends StatelessWidget { 209 + const _SkeletonActionItem({this.hasLabel = false}); 210 + 211 + final bool hasLabel; 212 + 213 + @override 214 + Widget build(BuildContext context) { 215 + return Column( 216 + children: [ 217 + // Icon placeholder (circular) 218 + Skeleton.leaf( 219 + child: Container( 220 + width: 32, 221 + height: 32, 222 + decoration: const BoxDecoration( 223 + shape: BoxShape.circle, 224 + color: Colors.white, 225 + ), 226 + ), 227 + ), 228 + if (hasLabel) ...[ 229 + const SizedBox(height: 4), 230 + // Count label placeholder 231 + Skeleton.leaf( 232 + child: Container( 233 + width: 20, 234 + height: 12, 235 + decoration: BoxDecoration( 236 + color: Colors.white, 237 + borderRadius: BorderRadius.circular(4), 238 + ), 239 + ), 240 + ), 241 + ], 242 + ], 243 + ); 244 + } 245 + }
+5 -4
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 181 181 } 182 182 183 183 if (_postFuture == null) { 184 - return const Center(child: CircularProgressIndicator()); 184 + // Show black background - skeleton is shown at feed_page level 185 + return const DecoratedBox( 186 + decoration: BoxDecoration(color: AppColors.black), 187 + ); 185 188 } 186 189 187 190 // If user is not on feeds tab, show empty container to dispose video ··· 348 351 ), 349 352 ); 350 353 } 354 + // Show black background - skeleton is shown at feed_page level 351 355 return const DecoratedBox( 352 356 decoration: BoxDecoration(color: AppColors.black), 353 - child: Center( 354 - child: CircularProgressIndicator(color: AppColors.white), 355 - ), 356 357 ); 357 358 }, 358 359 );
+40 -12
lib/src/features/feed/ui/widgets/videos/video_player.dart
··· 6 6 import 'package:get_it/get_it.dart'; 7 7 import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 8 8 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 9 + import 'package:spark/src/core/ui/foundation/colors.dart'; 9 10 import 'package:spark/src/core/utils/logging/logging.dart'; 10 11 import 'package:spark/src/features/feed/providers/feed_provider.dart'; 11 12 import 'package:spark/src/features/feed/providers/feed_state.dart'; ··· 44 45 45 46 late AnimationController _bounceController; 46 47 late Animation<double> _bounceAnimation; 48 + 49 + // For fade-in animation 50 + late AnimationController _fadeController; 51 + late Animation<double> _fadeAnimation; 47 52 48 53 int? _lastNavigationIndex; 49 54 int? _lastFeedIndex; ··· 67 72 ).animate( 68 73 CurvedAnimation(parent: _bounceController, curve: Curves.elasticOut), 69 74 ); 75 + _fadeController = AnimationController( 76 + duration: const Duration(milliseconds: 400), 77 + vsync: this, 78 + ); 79 + _fadeAnimation = CurvedAnimation( 80 + parent: _fadeController, 81 + curve: Curves.easeIn, 82 + ); 70 83 initVideoPlayer(); 71 84 GetIt.I<LogService>() 72 85 .getLogger('PostVideoPlayer') ··· 85 98 @override 86 99 void dispose() { 87 100 _bounceController.dispose(); 101 + _fadeController.dispose(); 88 102 videoController?.dispose(); 89 103 super.dispose(); 90 104 } ··· 144 158 setState(() { 145 159 videoController = videoControllerTemp; 146 160 }); 161 + // Start fade-in animation 162 + _fadeController.forward(); 147 163 } catch (e) { 148 164 if (!mounted) return; 149 165 } ··· 215 231 @override 216 232 Widget build(BuildContext context) { 217 233 if (!isInitialized) { 218 - return const Center(child: CircularProgressIndicator()); 234 + // Show thumbnail while video is initializing 235 + return widget.thumbnail.isNotEmpty 236 + ? Image.network( 237 + widget.thumbnail, 238 + fit: BoxFit.contain, 239 + width: double.infinity, 240 + height: double.infinity, 241 + ) 242 + : const DecoratedBox( 243 + decoration: BoxDecoration(color: AppColors.black), 244 + ); 219 245 } 220 246 221 247 final navigationState = ref.watch(navigationProvider); ··· 299 325 alignment: Alignment.center, 300 326 children: [ 301 327 Positioned.fill( 302 - child: 303 - videoSize != null && videoSize.width > 0 && videoSize.height > 0 304 - ? FittedBox( 305 - fit: fitMode, 306 - child: SizedBox( 307 - width: videoSize.width, 308 - height: videoSize.height, 309 - child: BetterPlayer(controller: videoController!), 310 - ), 311 - ) 312 - : BetterPlayer(controller: videoController!), 328 + child: FadeTransition( 329 + opacity: _fadeAnimation, 330 + child: videoSize != null && videoSize.width > 0 && videoSize.height > 0 331 + ? FittedBox( 332 + fit: fitMode, 333 + child: SizedBox( 334 + width: videoSize.width, 335 + height: videoSize.height, 336 + child: BetterPlayer(controller: videoController!), 337 + ), 338 + ) 339 + : BetterPlayer(controller: videoController!), 340 + ), 313 341 ), 314 342 Positioned.fill( 315 343 child: GestureDetector(