import 'package:cached_network_image/cached_network_image.dart'; import 'package:didvan/config/auth_config.dart'; import 'package:didvan/models/story_model.dart'; import 'package:didvan/services/content/content_service.dart'; import 'package:didvan/services/story_read_cache.dart'; import 'package:didvan/services/story_service.dart'; import 'package:didvan/views/widgets/shimmer_placeholder.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; class StoryViewerPage extends StatefulWidget { final List stories; final int tappedIndex; const StoryViewerPage({ super.key, required this.stories, required this.tappedIndex, }); @override State createState() => _StoryViewerPageState(); } class _StoryViewerPageState extends State { late PageController _pageController; double _currentPage = 0.0; @override void initState() { super.initState(); _pageController = PageController( initialPage: widget.tappedIndex, viewportFraction: 1.0, ); _currentPage = widget.tappedIndex.toDouble(); _pageController.addListener(() { final p = _pageController.page; if (p == null) return; setState(() => _currentPage = p); }); } @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, resizeToAvoidBottomInset: true, body: Directionality( textDirection: TextDirection.ltr, child: PageView.builder( controller: _pageController, physics: const BouncingScrollPhysics(), itemCount: widget.stories.length, itemBuilder: (context, index) { final userStories = widget.stories[index]; final value = (_currentPage - index).clamp(-1.0, 1.0); final rotationY = value * 0.85; final transform = Matrix4.identity() ..setEntry(3, 2, 0.0015) ..rotateY(rotationY); return Transform( alignment: value > 0 ? Alignment.centerRight : Alignment.centerLeft, transform: transform, child: UserStoryViewer( key: ValueKey(userStories.user.name + index.toString()), userStories: userStories, onComplete: () { if (index < widget.stories.length - 1) { _pageController.nextPage( duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ); } else { Navigator.of(context).pop(); } }, onPreviousGroup: () { if (index > 0) { _pageController.previousPage( duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ); } }, ), ); }, ), ), ); } } class UserStoryViewer extends StatefulWidget { final UserStories userStories; final VoidCallback onComplete; final VoidCallback? onPreviousGroup; const UserStoryViewer({ super.key, required this.userStories, required this.onComplete, this.onPreviousGroup, }); @override State createState() => _UserStoryViewerState(); } class _UserStoryViewerState extends State with TickerProviderStateMixin { late AnimationController _progressController; late AnimationController _mediaFadeController; late AnimationController _dragReturnController; VideoPlayerController? _videoController; ImageStream? _imageStream; ImageStreamListener? _imageListener; int _currentStoryIndex = 0; bool _isLoading = true; double _dragDy = 0; double _dragReturnStart = 0; bool _dismissing = false; bool _headerReady = false; @override void initState() { super.initState(); _progressController = AnimationController(vsync: this); _mediaFadeController = AnimationController( vsync: this, duration: const Duration(milliseconds: 250), ); _dragReturnController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), )..addListener(() { setState(() => _dragDy = _dragReturnStart * (1 - _dragReturnController.value)); }); _currentStoryIndex = widget.userStories.stories .indexWhere((story) => !story.isViewed.value); if (_currentStoryIndex == -1) _currentStoryIndex = 0; _loadStory(story: widget.userStories.stories[_currentStoryIndex]); _progressController.addStatusListener((status) { if (status == AnimationStatus.completed) _nextStory(); }); // Delay header render until Hero flight settles so title + progress bars // don't render inside the small circle rect during the opening animation. Future.delayed(const Duration(milliseconds: 320), () { if (mounted) setState(() => _headerReady = true); }); } @override void dispose() { _progressController.dispose(); _mediaFadeController.dispose(); _dragReturnController.dispose(); _videoController?.dispose(); _detachImageListener(); super.dispose(); } void _onDragUpdate(DragUpdateDetails d) { if (_dismissing) return; if (_dragReturnController.isAnimating) _dragReturnController.stop(); if (_dragDy == 0) _pauseStory(); setState(() => _dragDy += d.delta.dy); } void _onDragEnd(DragEndDetails d) { if (_dismissing) return; final vel = d.primaryVelocity ?? 0; if (_dragDy.abs() > 130 || vel.abs() > 700) { _dismissing = true; Navigator.of(context).pop(); } else { _dragReturnStart = _dragDy; _dragReturnController.forward(from: 0).then((_) => _resumeStory()); } } void _detachImageListener() { if (_imageStream != null && _imageListener != null) { _imageStream!.removeListener(_imageListener!); } _imageStream = null; _imageListener = null; } void _markRead(StoryItem story) { if (story.isViewed.value) return; if (!AuthConfig.useLegacyApi && story.contentUuid != null) { ContentService.instance.markRead(story.contentUuid!); StoryReadCache.instance.add(story.contentUuid); } else { StoryService.markStoryAsViewed(widget.userStories.id, story.id); } story.isViewed.value = true; } void _loadStory({required StoryItem story}) { _markRead(story); _progressController.stop(); _progressController.reset(); _videoController?.dispose(); _videoController = null; _detachImageListener(); _mediaFadeController.reset(); setState(() => _isLoading = true); switch (story.media) { case MediaType.image: case MediaType.gif: _loadImage(story); break; case MediaType.video: _loadVideo(story); break; } } void _loadImage(StoryItem story) { final ImageProvider provider = story.media == MediaType.gif ? NetworkImage(story.url) as ImageProvider : CachedNetworkImageProvider(story.url); final stream = provider.resolve(const ImageConfiguration()); final listener = ImageStreamListener( (info, _) { if (!mounted) return; _onMediaReady(duration: story.duration); }, onError: (error, stack) { if (!mounted) return; debugPrint('story image load failed: $error'); _nextStory(); }, ); _imageStream = stream; _imageListener = listener; stream.addListener(listener); } void _loadVideo(StoryItem story) { _videoController = VideoPlayerController.networkUrl(Uri.parse(story.url)); _videoController!.initialize().then((_) { if (!mounted) return; if (_videoController!.value.isInitialized && _videoController!.value.duration > Duration.zero) { _videoController!.play(); _onMediaReady(duration: _videoController!.value.duration); } else { debugPrint('story video invalid duration, skipping'); _nextStory(); } }).catchError((error) { if (!mounted) return; debugPrint('story video load failed: $error'); _nextStory(); }).timeout(const Duration(seconds: 12), onTimeout: () { if (!mounted) return; debugPrint('story video load timed out'); _nextStory(); }); } void _onMediaReady({required Duration duration}) { if (!mounted) return; setState(() => _isLoading = false); _mediaFadeController.forward(); _progressController.duration = duration; _progressController.forward(); } void _nextStory() { if (_currentStoryIndex < widget.userStories.stories.length - 1) { setState(() => _currentStoryIndex++); _loadStory(story: widget.userStories.stories[_currentStoryIndex]); } else { widget.onComplete(); } } void _previousStory() { if (_currentStoryIndex > 0) { setState(() => _currentStoryIndex--); _loadStory(story: widget.userStories.stories[_currentStoryIndex]); } else { widget.onPreviousGroup?.call(); } } void _pauseStory() { _progressController.stop(); _videoController?.pause(); } void _resumeStory() { if (_isLoading) return; _progressController.forward(); _videoController?.play(); } @override Widget build(BuildContext context) { final story = widget.userStories.stories[_currentStoryIndex]; final dragProgress = (_dragDy.abs() / 400).clamp(0.0, 1.0); final scale = 1.0 - dragProgress * 0.45; final radius = dragProgress * 40.0; return Scaffold( backgroundColor: Colors.black.withValues(alpha: (1.0 - dragProgress * 0.7).clamp(0.0, 1.0)), body: GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragUpdate: _onDragUpdate, onVerticalDragEnd: _onDragEnd, child: Transform.translate( offset: Offset(0, _dragDy), child: Transform.scale( scale: scale, child: Hero( tag: 'story-${widget.userStories.user.name}', child: Material( color: Colors.transparent, child: ClipRRect( borderRadius: BorderRadius.circular(radius), child: _buildContent(story), ), ), ), ), ), ), ); } Widget _buildContent(StoryItem story) { return Stack( fit: StackFit.expand, children: [ FadeTransition( opacity: CurvedAnimation( parent: _mediaFadeController, curve: Curves.easeOut, ), child: _buildMediaViewer(story), ), if (_isLoading) const Positioned.fill( child: ShimmerPlaceholder( width: double.infinity, height: double.infinity, ), ), // top gradient for readability Positioned( top: 0, left: 0, right: 0, height: 160, child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.55), Colors.transparent, ], ), ), ), ), ), Row( children: [ Expanded( child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: _previousStory, onLongPress: _pauseStory, onLongPressUp: _resumeStory, ), ), Expanded( child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: _nextStory, onLongPress: _pauseStory, onLongPressUp: _resumeStory, ), ), ], ), if (_headerReady) _buildStoryHeader(), ], ); } Widget _buildMediaViewer(StoryItem story) { switch (story.media) { case MediaType.image: return CachedNetworkImage( imageUrl: story.url, fit: BoxFit.cover, width: double.infinity, height: double.infinity, placeholder: (_, __) => const SizedBox.shrink(), errorWidget: (_, __, ___) => const SizedBox.shrink(), ); case MediaType.gif: return Image.network( story.url, fit: BoxFit.cover, width: double.infinity, height: double.infinity, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ); case MediaType.video: if (_videoController?.value.isInitialized ?? false) { return FittedBox( fit: BoxFit.cover, child: SizedBox( width: _videoController!.value.size.width, height: _videoController!.value.size.height, child: VideoPlayer(_videoController!), ), ); } return const SizedBox.shrink(); } } Widget _buildStoryHeader() { return Positioned( top: 40.0, left: 12.0, right: 12.0, child: TweenAnimationBuilder( tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, builder: (context, t, child) => Transform.translate( offset: Offset(0, -20 * (1 - t)), child: Opacity(opacity: t, child: child), ), child: Column( children: [ Directionality( textDirection: TextDirection.ltr, child: Row( children: widget.userStories.stories .asMap() .entries .map((e) => _AnimatedBar( animationController: _progressController, position: e.key, currentIndex: _currentStoryIndex, )) .toList(), ), ), Padding( padding: const EdgeInsets.only(top: 12.0), child: _UserInfo( user: widget.userStories.user, counter: '${_currentStoryIndex + 1}/${widget.userStories.stories.length}', ), ), ], ), ), ); } } class _AnimatedBar extends StatelessWidget { final AnimationController animationController; final int position; final int currentIndex; const _AnimatedBar({ required this.animationController, required this.position, required this.currentIndex, }); @override Widget build(BuildContext context) { return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2.0), child: ClipRRect( borderRadius: BorderRadius.circular(30.0), child: AnimatedBuilder( animation: animationController, builder: (context, _) { final value = (position < currentIndex) ? 1.0 : (position == currentIndex ? animationController.value : 0.0); return SizedBox( height: 3.5, child: Stack( children: [ Container(color: Colors.white.withValues(alpha: 0.35)), FractionallySizedBox( widthFactor: value, child: Container(color: Colors.white), ), ], ), ); }, ), ), ), ); } } class _UserInfo extends StatelessWidget { final User user; final String counter; const _UserInfo({required this.user, required this.counter}); @override Widget build(BuildContext context) { return Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color.fromARGB(193, 255, 255, 255), border: Border.all(color: const Color.fromARGB(197, 255, 255, 255).withValues(alpha: 0.4), width: 1), ), child: ClipOval( child: Padding( padding: const EdgeInsets.all(2), child: Image.asset( user.profileImageUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon(Icons.person, color: Colors.white70, size: 22), ), ), ), ), const SizedBox(width: 10.0), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( user.name, style: const TextStyle( color: Colors.white, fontSize: 16.0, fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), // const SizedBox(height: 2), // Text( // counter, // style: TextStyle( // color: Colors.white.withValues(alpha: 0.7), // fontSize: 11.0, // ), // ), ], ), ), IconButton( icon: const Icon(Icons.close, size: 26, color: Colors.white), onPressed: () => Navigator.of(context).pop(), ), ], ); } }