[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): nicer carousel dots

+275 -23
+275 -23
lib/src/features/feed/ui/widgets/images/image_carousel.dart
··· 122 122 123 123 @override 124 124 Widget build(BuildContext context) { 125 - final safeBottom = MediaQuery.of(context).padding.bottom; 126 125 final hasMultipleImages = widget.imageUrls.length > 1; 127 126 128 127 // If only one image, show it directly without carousel ··· 144 143 }); 145 144 }, 146 145 ), 147 - Align( 148 - alignment: Alignment.bottomCenter, 149 - child: Padding( 150 - padding: EdgeInsets.only(bottom: safeBottom), 151 - child: Row( 152 - mainAxisAlignment: MainAxisAlignment.center, 153 - children: [ 154 - ...List.generate( 155 - widget.imageUrls.length, 156 - (index) => Container( 157 - width: 8, 158 - height: 8, 159 - margin: const EdgeInsets.symmetric(horizontal: 4), 160 - decoration: BoxDecoration( 161 - shape: BoxShape.circle, 162 - color: currentIndex == index 163 - ? Colors.white 164 - : Colors.white.withAlpha(128), 165 - ), 166 - ), 167 - ), 168 - ], 146 + Positioned( 147 + // Position dots above the post overlay content area 148 + // The overlay has content (InfoBar, SideActionBar) in the bottom ~150px 149 + bottom: 180, 150 + left: 0, 151 + right: 0, 152 + child: Center( 153 + child: _ScrollingDotIndicator( 154 + itemCount: widget.imageUrls.length, 155 + currentIndex: currentIndex, 169 156 ), 170 157 ), 171 158 ), 172 159 ], 173 160 ); 161 + } 162 + } 163 + 164 + /// A compact dot indicator that shows max 5 dots at a time 165 + /// with smooth scrolling animation where dots slide in/out 166 + class _ScrollingDotIndicator extends StatefulWidget { 167 + const _ScrollingDotIndicator({ 168 + required this.itemCount, 169 + required this.currentIndex, 170 + }); 171 + 172 + final int itemCount; 173 + final int currentIndex; 174 + 175 + @override 176 + State<_ScrollingDotIndicator> createState() => _ScrollingDotIndicatorState(); 177 + } 178 + 179 + class _ScrollingDotIndicatorState extends State<_ScrollingDotIndicator> 180 + with SingleTickerProviderStateMixin { 181 + static const int _maxVisibleDots = 5; 182 + static const double _dotSize = 6.0; 183 + static const double _dotSpacing = 4.0; 184 + static const double _dotTotalWidth = _dotSize + _dotSpacing; 185 + 186 + // Dot positions (indices 0-4 for 5 dots) 187 + static const int _secondPosition = 1; // Second from left 188 + static const int _centerPosition = 2; // Center 189 + static const int _fourthPosition = 3; // Second from right (fourth) 190 + 191 + late AnimationController _controller; 192 + late Animation<double> _scrollAnimation; 193 + double _currentScrollOffset = 0; 194 + double _targetScrollOffset = 0; 195 + int _previousIndex = 0; 196 + bool _scrollingForward = true; 197 + bool _wasScrollingForward = true; 198 + bool _useCenter = false; // True when we just changed direction 199 + 200 + @override 201 + void initState() { 202 + super.initState(); 203 + _controller = AnimationController( 204 + duration: const Duration(milliseconds: 200), 205 + vsync: this, 206 + ); 207 + _scrollAnimation = Tween<double>(begin: 0, end: 0).animate( 208 + CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), 209 + ); 210 + _previousIndex = widget.currentIndex; 211 + _targetScrollOffset = _calculateScrollOffset(widget.currentIndex); 212 + _currentScrollOffset = _targetScrollOffset; 213 + } 214 + 215 + @override 216 + void didUpdateWidget(_ScrollingDotIndicator oldWidget) { 217 + super.didUpdateWidget(oldWidget); 218 + if (oldWidget.currentIndex != widget.currentIndex) { 219 + _wasScrollingForward = _scrollingForward; 220 + _scrollingForward = widget.currentIndex > _previousIndex; 221 + 222 + // Check if direction changed 223 + if (_scrollingForward != _wasScrollingForward) { 224 + // Direction changed - use center position for this scroll 225 + _useCenter = true; 226 + } else if (_useCenter) { 227 + // Same direction after using center - now move to edge 228 + _useCenter = false; 229 + } 230 + 231 + _previousIndex = widget.currentIndex; 232 + _animateToIndex(widget.currentIndex); 233 + } 234 + } 235 + 236 + @override 237 + void dispose() { 238 + _controller.dispose(); 239 + super.dispose(); 240 + } 241 + 242 + double _calculateScrollOffset(int index) { 243 + if (widget.itemCount <= _maxVisibleDots) { 244 + return 0; 245 + } 246 + 247 + final maxOffset = (widget.itemCount - _maxVisibleDots).toDouble(); 248 + 249 + // Determine which position the active dot should be at 250 + int dotPosition; 251 + if (_useCenter) { 252 + // Just changed direction - use center 253 + dotPosition = _centerPosition; 254 + } else if (_scrollingForward) { 255 + // Scrolling forward: active dot at fourth position (second from right) 256 + dotPosition = _fourthPosition; 257 + } else { 258 + // Scrolling backward: active dot at second position (second from left) 259 + dotPosition = _secondPosition; 260 + } 261 + 262 + final offset = index - dotPosition.toDouble(); 263 + return offset.clamp(0, maxOffset); 264 + } 265 + 266 + void _animateToIndex(int index) { 267 + final newOffset = _calculateScrollOffset(index); 268 + if (newOffset != _targetScrollOffset) { 269 + // Get the current visual position - use the animation value if animating, 270 + // otherwise use the stored offset. This prevents jumps when new animations 271 + // start while previous ones are still in progress. 272 + final currentVisualPosition = _controller.isAnimating 273 + ? _scrollAnimation.value 274 + : _currentScrollOffset; 275 + 276 + // Update the stored offset to match the current visual position 277 + // This ensures continuity when rapid swipes occur 278 + _currentScrollOffset = currentVisualPosition; 279 + 280 + _scrollAnimation = Tween<double>( 281 + begin: currentVisualPosition, 282 + end: newOffset, 283 + ).animate( 284 + CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), 285 + ); 286 + _targetScrollOffset = newOffset; 287 + _controller 288 + ..reset() 289 + ..forward().then((_) { 290 + _currentScrollOffset = newOffset; 291 + }); 292 + } 293 + } 294 + 295 + @override 296 + Widget build(BuildContext context) { 297 + if (widget.itemCount <= 1) return const SizedBox.shrink(); 298 + 299 + // Calculate visible width based on number of dots to show 300 + final visibleDotCount = widget.itemCount.clamp(1, _maxVisibleDots); 301 + final visibleWidth = visibleDotCount * _dotTotalWidth; 302 + 303 + return Container( 304 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), 305 + decoration: BoxDecoration( 306 + color: Colors.black.withAlpha(100), 307 + borderRadius: BorderRadius.circular(12), 308 + ), 309 + child: AnimatedBuilder( 310 + animation: _scrollAnimation, 311 + builder: (context, child) { 312 + final scrollOffset = 313 + _controller.isAnimating 314 + ? _scrollAnimation.value 315 + : _targetScrollOffset; 316 + 317 + return SizedBox( 318 + width: visibleWidth, 319 + height: _dotSize, 320 + child: ClipRect( 321 + child: Stack( 322 + clipBehavior: Clip.none, 323 + children: _buildDots(scrollOffset), 324 + ), 325 + ), 326 + ); 327 + }, 328 + ), 329 + ); 330 + } 331 + 332 + List<Widget> _buildDots(double scrollOffset) { 333 + final dots = <Widget>[]; 334 + final maxOffset = (widget.itemCount - _maxVisibleDots).toDouble(); 335 + final hasMoreBefore = scrollOffset > 0; 336 + final hasMoreAfter = scrollOffset < maxOffset; 337 + 338 + for (var i = 0; i < widget.itemCount; i++) { 339 + // Calculate position relative to scroll offset 340 + final relativePosition = i - scrollOffset; 341 + 342 + // Skip dots that are way outside the visible area 343 + if (relativePosition < -1 || relativePosition > _maxVisibleDots) { 344 + continue; 345 + } 346 + 347 + // Calculate horizontal position 348 + final xPosition = relativePosition * _dotTotalWidth + _dotSpacing / 2; 349 + 350 + // Calculate scale based on position 351 + // Edge dots are smaller (0.6) when there are more dots in that direction 352 + final scale = _calculateDotScale( 353 + relativePosition, 354 + hasMoreBefore, 355 + hasMoreAfter, 356 + ); 357 + 358 + final isActive = i == widget.currentIndex; 359 + final size = _dotSize * scale; 360 + 361 + dots.add( 362 + Positioned( 363 + left: xPosition + (_dotSize - size) / 2, 364 + top: (_dotSize - size) / 2, 365 + child: Opacity( 366 + opacity: scale.clamp(0.0, 1.0), 367 + child: Container( 368 + width: size, 369 + height: size, 370 + decoration: BoxDecoration( 371 + shape: BoxShape.circle, 372 + color: isActive ? Colors.white : Colors.white.withAlpha(128), 373 + ), 374 + ), 375 + ), 376 + ), 377 + ); 378 + } 379 + 380 + return dots; 381 + } 382 + 383 + double _calculateDotScale( 384 + double relativePosition, 385 + bool hasMoreBefore, 386 + bool hasMoreAfter, 387 + ) { 388 + const edgeScale = 0.6; 389 + const edgeZone = 0.5; // How far from edge before dot is full size 390 + 391 + // Left side: dots entering/exiting or at edge 392 + if (relativePosition < edgeZone) { 393 + if (relativePosition < 0) { 394 + // Dot is outside visible area (entering/exiting on left) 395 + // Scale from 0 (at -1) to edgeScale or 1.0 (at 0) depending on hasMoreBefore 396 + final targetScale = hasMoreBefore ? edgeScale : 1.0; 397 + return ((1 + relativePosition) * targetScale).clamp(0.0, 1.0); 398 + } else if (hasMoreBefore) { 399 + // Dot is in the left edge zone with more dots before 400 + // Scale from edgeScale (at 0) to 1.0 (at edgeZone) 401 + return (edgeScale + (relativePosition / edgeZone) * (1 - edgeScale)) 402 + .clamp(0.0, 1.0); 403 + } 404 + } 405 + 406 + // Right side: dots entering/exiting or at edge 407 + final rightEdgeStart = _maxVisibleDots - 1 - edgeZone; 408 + if (relativePosition > rightEdgeStart) { 409 + final distanceFromRight = _maxVisibleDots - 1 - relativePosition; 410 + 411 + if (relativePosition > _maxVisibleDots - 1) { 412 + // Dot is outside visible area (entering/exiting on right) 413 + // Scale from 0 (at _maxVisibleDots) to edgeScale or 1.0 (at _maxVisibleDots-1) 414 + final targetScale = hasMoreAfter ? edgeScale : 1.0; 415 + return ((1 + distanceFromRight) * targetScale).clamp(0.0, 1.0); 416 + } else if (hasMoreAfter) { 417 + // Dot is in the right edge zone with more dots after 418 + // Scale from edgeScale (at _maxVisibleDots-1) to 1.0 (at rightEdgeStart) 419 + return (edgeScale + (distanceFromRight / edgeZone) * (1 - edgeScale)) 420 + .clamp(0.0, 1.0); 421 + } 422 + } 423 + 424 + // Middle dots are full size 425 + return 1.0; 174 426 } 175 427 } 176 428