storyig 0.1.0
storyig: ^0.1.0 copied to clipboard
Flutter widgets for Instagram-like story circles and a lightweight story creator.
example/lib/main.dart
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:storyig/storyig.dart';
void main() {
runApp(const StoryDemoApp());
}
class StoryDemoApp extends StatelessWidget {
const StoryDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.orange,
useMaterial3: true,
),
home: const StoryHomePage(),
);
}
}
class StoryHomePage extends StatefulWidget {
const StoryHomePage({super.key});
@override
State<StoryHomePage> createState() => _StoryHomePageState();
}
class _StoryHomePageState extends State<StoryHomePage> {
final ImagePicker _imagePicker = ImagePicker();
late List<StoryUser> _users;
Uint8List? _draftPreviewBytes;
StoryMediaType _draftMediaType = StoryMediaType.image;
String? _draftFileName;
@override
void initState() {
super.initState();
_users = [
const StoryUser(
id: 'me',
name: 'You',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
isCurrentUser: true,
),
const StoryUser(
id: 'u1',
name: 'Alex',
avatarUrl: 'https://i.pravatar.cc/150?img=11',
stories: [
StoryItem(
id: 's1',
mediaUrl: 'https://picsum.photos/500/900?1',
seen: false,
caption: 'Sunset walk',
),
StoryItem(
id: 's1b',
mediaUrl: 'https://picsum.photos/500/900?11',
seen: false,
caption: 'Coffee break',
),
],
),
const StoryUser(
id: 'u2',
name: 'Riya',
avatarUrl: 'https://i.pravatar.cc/150?img=26',
stories: [
StoryItem(
id: 's2',
mediaUrl: 'https://picsum.photos/500/900?2',
seen: true,
caption: 'Weekend vibes',
),
],
),
const StoryUser(
id: 'u3',
name: 'Sam',
avatarUrl: 'https://i.pravatar.cc/150?img=34',
stories: [
StoryItem(
id: 's3',
mediaUrl: 'https://picsum.photos/500/900?3',
seen: false,
caption: 'City lights',
),
],
),
];
}
Future<String?> _pickMedia(BuildContext context) async {
if (kIsWeb) {
return _pickWebMedia();
}
return _pickMobileMedia(context);
}
Future<String?> _pickWebMedia() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'mp4', 'mov'],
withData: true,
);
if (result == null || result.files.isEmpty) return null;
final file = result.files.single;
final ext = (file.extension ?? '').toLowerCase();
final mediaType = _mediaTypeFromExtension(ext);
setState(() {
_draftFileName = file.name;
_draftMediaType = mediaType;
_draftPreviewBytes = mediaType == StoryMediaType.image ? file.bytes : null;
});
if (file.bytes != null) {
final mime = mediaType == StoryMediaType.video
? _videoMimeFromExtension(ext)
: _imageMimeFromExtension(ext);
return 'data:$mime;base64,${base64Encode(file.bytes!)}';
}
return 'video://${file.name}';
}
Future<String?> _pickMobileMedia(BuildContext context) async {
final pickType = await _askMobilePickType(context);
if (pickType == null) return null;
XFile? file;
if (pickType == _PickType.image) {
file = await _imagePicker.pickImage(source: ImageSource.gallery);
} else {
file = await _imagePicker.pickVideo(source: ImageSource.gallery);
}
if (file == null) return null;
final pickedFile = file;
final ext = pickedFile.name.split('.').last.toLowerCase();
final mediaType = pickType == _PickType.video
? StoryMediaType.video
: StoryMediaType.image;
final bytes = await pickedFile.readAsBytes();
setState(() {
_draftFileName = pickedFile.name;
_draftMediaType = mediaType;
_draftPreviewBytes = mediaType == StoryMediaType.image ? bytes : null;
});
final mime = mediaType == StoryMediaType.video
? _videoMimeFromExtension(ext)
: _imageMimeFromExtension(ext);
return 'data:$mime;base64,${base64Encode(bytes)}';
}
Future<_PickType?> _askMobilePickType(BuildContext context) async {
return showModalBottomSheet<_PickType>(
context: context,
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_outlined),
title: const Text('Photo'),
onTap: () => Navigator.of(sheetContext).pop(_PickType.image),
),
ListTile(
leading: const Icon(Icons.videocam_outlined),
title: const Text('Video'),
onTap: () => Navigator.of(sheetContext).pop(_PickType.video),
),
],
),
);
},
);
}
StoryMediaType _mediaTypeFromExtension(String ext) {
if (<String>{'mp4', 'mov', 'm4v', 'webm'}.contains(ext)) {
return StoryMediaType.video;
}
return StoryMediaType.image;
}
String _imageMimeFromExtension(String ext) {
switch (ext) {
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
default:
return 'image/jpeg';
}
}
String _videoMimeFromExtension(String ext) {
switch (ext) {
case 'webm':
return 'video/webm';
case 'mov':
return 'video/quicktime';
case 'm4v':
return 'video/x-m4v';
default:
return 'video/mp4';
}
}
Future<void> _submitStory(StoryDraft draft) async {
final meIndex = _users.indexWhere((user) => user.isCurrentUser);
if (meIndex < 0) return;
final newStory = StoryItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
mediaUrl: draft.mediaPath,
caption: draft.caption,
createdAt: DateTime.now(),
seen: false,
mediaType: _draftMediaType,
duration: _draftMediaType == StoryMediaType.video
? const Duration(seconds: 8)
: const Duration(seconds: 5),
);
final me = _users[meIndex];
final updatedMe = me.copyWith(
stories: [...me.stories, newStory],
);
setState(() {
_users = [..._users]..[meIndex] = updatedMe;
_draftPreviewBytes = null;
_draftFileName = null;
_draftMediaType = StoryMediaType.image;
});
}
void _openStoryViewerForUser(StoryUser user) {
final userIndex = _users.indexWhere((u) => u.id == user.id);
if (userIndex < 0 || _users[userIndex].stories.isEmpty) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => StoryViewer(
users: _users.where((u) => u.stories.isNotEmpty).toList(),
initialUserIndex: _indexInStoryUsers(user.id),
onStorySeen: (viewerUserIndex, storyIndex, _) {
_markStorySeen(viewerUserIndex, storyIndex);
},
),
),
);
}
int _indexInStoryUsers(String userId) {
final storyUsers = _users.where((u) => u.stories.isNotEmpty).toList();
return storyUsers.indexWhere((u) => u.id == userId);
}
void _markStorySeen(int viewerUserIndex, int storyIndex) {
final storyUsers = _users.where((u) => u.stories.isNotEmpty).toList();
if (viewerUserIndex < 0 || viewerUserIndex >= storyUsers.length) return;
final viewerUser = storyUsers[viewerUserIndex];
final realIndex = _users.indexWhere((u) => u.id == viewerUser.id);
if (realIndex < 0) return;
final realUser = _users[realIndex];
if (storyIndex < 0 || storyIndex >= realUser.stories.length) return;
if (realUser.stories[storyIndex].seen) return;
final updatedStories = [...realUser.stories];
updatedStories[storyIndex] = updatedStories[storyIndex].copyWith(seen: true);
setState(() {
_users = [..._users]..[realIndex] = realUser.copyWith(stories: updatedStories);
});
}
Widget _buildDraftPreview(BuildContext context, String mediaPath) {
if (_draftMediaType == StoryMediaType.video) {
return Container(
color: Colors.black,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.videocam, color: Colors.white70, size: 52),
const SizedBox(height: 8),
Text(
_draftFileName ?? 'Video selected',
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
],
),
);
}
if (_draftPreviewBytes != null) {
return Image.memory(_draftPreviewBytes!, fit: BoxFit.cover);
}
return Image.network(
mediaPath,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(child: Text('Preview unavailable')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('StoryIG Demo')),
body: Column(
children: [
StoryCircleList(
users: _users,
onAddTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => StoryCreator(
onPickMedia: _pickMedia,
onSubmit: _submitStory,
previewBuilder: _buildDraftPreview,
),
),
);
},
onUserTap: _openStoryViewerForUser,
),
const Divider(height: 1),
const Expanded(
child: Center(
child: Text('Feed goes here'),
),
),
],
),
);
}
}
enum _PickType {
image,
video,
}