412 lines
12 KiB
Dart
412 lines
12 KiB
Dart
// lib/views/story_viewer/story_viewer_page.dart
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:didvan/models/story_model.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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_pageController = PageController(initialPage: widget.tappedIndex);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: PageView.builder(
|
|
controller: _pageController,
|
|
itemCount: widget.stories.length,
|
|
itemBuilder: (context, index) {
|
|
final userStories = widget.stories[index];
|
|
return UserStoryViewer(
|
|
key: ValueKey(userStories.user.name + index.toString()),
|
|
userStories: userStories,
|
|
onComplete: () {
|
|
if (index < widget.stories.length - 1) {
|
|
_pageController.nextPage(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeIn,
|
|
);
|
|
} else {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserStoryViewer extends StatefulWidget {
|
|
final UserStories userStories;
|
|
final VoidCallback onComplete;
|
|
|
|
const UserStoryViewer({
|
|
super.key,
|
|
required this.userStories,
|
|
required this.onComplete,
|
|
});
|
|
|
|
@override
|
|
State<UserStoryViewer> createState() => _UserStoryViewerState();
|
|
}
|
|
|
|
class _UserStoryViewerState extends State<UserStoryViewer>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
VideoPlayerController? _videoController;
|
|
int _currentStoryIndex = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(vsync: this);
|
|
|
|
// final allStoriesInGroupViewed =
|
|
// widget.userStories.stories.every((story) => story.isViewed.value);
|
|
|
|
// if (allStoriesInGroupViewed) {
|
|
// for (final story in widget.userStories.stories) {
|
|
// story.isViewed.value = false;
|
|
// }
|
|
// }
|
|
|
|
_currentStoryIndex =
|
|
widget.userStories.stories.indexWhere((story) => !story.isViewed.value);
|
|
|
|
if (_currentStoryIndex == -1) {
|
|
_currentStoryIndex = 0;
|
|
}
|
|
|
|
_loadStory(story: widget.userStories.stories[_currentStoryIndex]);
|
|
|
|
_animationController.addStatusListener((status) {
|
|
if (status == AnimationStatus.completed) {
|
|
_nextStory();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
_videoController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _loadStory({required StoryItem story}) {
|
|
if (!story.isViewed.value) {
|
|
StoryService.markStoryAsViewed(widget.userStories.id, story.id);
|
|
story.isViewed.value = true;
|
|
}
|
|
|
|
_animationController.stop();
|
|
_animationController.reset();
|
|
_videoController?.dispose();
|
|
_videoController = null;
|
|
|
|
switch (story.media) {
|
|
case MediaType.image:
|
|
case MediaType.gif:
|
|
_animationController.duration = story.duration;
|
|
_animationController.forward();
|
|
break;
|
|
case MediaType.video:
|
|
_videoController =
|
|
VideoPlayerController.networkUrl(Uri.parse(story.url));
|
|
_videoController!.initialize().then((_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
if (_videoController!.value.isInitialized &&
|
|
_videoController!.value.duration > Duration.zero) {
|
|
_animationController.duration =
|
|
_videoController!.value.duration;
|
|
_videoController!.play();
|
|
_animationController.forward();
|
|
} else {
|
|
// ignore: avoid_print
|
|
print(
|
|
"Video failed to initialize or has zero duration. Skipping.");
|
|
_nextStory();
|
|
}
|
|
});
|
|
}
|
|
}).catchError((error) {
|
|
// ignore: avoid_print
|
|
print("Error loading video: $error. Skipping.");
|
|
if (mounted) {
|
|
_nextStory();
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|
|
|
|
void _pauseStory() {
|
|
_animationController.stop();
|
|
_videoController?.pause();
|
|
}
|
|
|
|
void _resumeStory() {
|
|
_animationController.forward();
|
|
_videoController?.play();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final story = widget.userStories.stories[_currentStoryIndex];
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
_buildMediaViewer(story),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: _previousStory,
|
|
onLongPress: _pauseStory,
|
|
onLongPressUp: _resumeStory,
|
|
child: Container(color: Colors.transparent),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: _nextStory,
|
|
onLongPress: _pauseStory,
|
|
onLongPressUp: _resumeStory,
|
|
child: Container(color: Colors.transparent),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
_buildStoryHeader(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMediaViewer(StoryItem story) {
|
|
switch (story.media) {
|
|
case MediaType.image:
|
|
return CachedNetworkImage(
|
|
placeholder: (context, url) => const ShimmerPlaceholder(),
|
|
imageUrl: story.url,
|
|
fit: BoxFit.fill,
|
|
width: double.infinity,
|
|
height: double.infinity);
|
|
case MediaType.gif:
|
|
return Image.network(story.url,
|
|
fit: BoxFit.fill, width: double.infinity, height: double.infinity);
|
|
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 Center(child: CircularProgressIndicator());
|
|
}
|
|
}
|
|
|
|
Widget _buildStoryHeader() {
|
|
return Positioned(
|
|
top: 35.0,
|
|
left: 10.0,
|
|
right: 10.0,
|
|
child: Column(
|
|
children: [
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Row(
|
|
children: widget.userStories.stories
|
|
.asMap()
|
|
.map((i, e) {
|
|
return MapEntry(
|
|
i,
|
|
_AnimatedBar(
|
|
animationController: _animationController,
|
|
position: i,
|
|
currentIndex: _currentStoryIndex,
|
|
),
|
|
);
|
|
})
|
|
.values
|
|
.toList(),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10.0),
|
|
child: _UserInfo(user: widget.userStories.user),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 Flexible(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 1.5),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return Stack(
|
|
children: <Widget>[
|
|
Container(
|
|
height: 6.0,
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: position < currentIndex
|
|
? Colors.white
|
|
// ignore: deprecated_member_use
|
|
: Colors.white.withOpacity(0.5),
|
|
border: Border.all(color: Colors.black26, width: 0.8),
|
|
borderRadius: BorderRadius.circular(30.0),
|
|
),
|
|
),
|
|
if (position == currentIndex)
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: AnimatedBuilder(
|
|
animation: animationController,
|
|
builder: (context, child) {
|
|
return Container(
|
|
height: 6.0,
|
|
width:
|
|
constraints.maxWidth * animationController.value,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(30.0),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UserInfo extends StatelessWidget {
|
|
final User user;
|
|
|
|
const _UserInfo({required this.user});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: <Widget>[
|
|
CircleAvatar(
|
|
radius: 20.0,
|
|
backgroundColor: Colors.grey[300],
|
|
child: ClipOval(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(0.0),
|
|
child: Image.asset(
|
|
user.profileImageUrl,
|
|
width: 40.0,
|
|
height: 40.0,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return const Icon(Icons.person,
|
|
color: Colors.grey, size: 40.0);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10.0),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
user.name,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18.0,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, size: 30.0, color: Colors.white),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |