didvan-app/lib/views/story_viewer/story_viewer_page.dart

600 lines
18 KiB
Dart

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<UserStories> stories;
final int tappedIndex;
const StoryViewerPage({
super.key,
required this.stories,
required this.tappedIndex,
});
@override
State<StoryViewerPage> createState() => _StoryViewerPageState();
}
class _StoryViewerPageState extends State<StoryViewerPage> {
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<UserStoryViewer> createState() => _UserStoryViewerState();
}
class _UserStoryViewerState extends State<UserStoryViewer>
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<double>(
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: <Widget>[
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(),
),
],
);
}
}