600 lines
18 KiB
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(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|