storyig 0.1.3
storyig: ^0.1.3 copied to clipboard
Flutter widgets for Instagram-like story circles and a lightweight story creator.
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
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),
),
],
),
);
},
);
}
Future<StoryMusicTrack?> _pickLocalMusic(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['mp3', 'm4a', 'aac', 'wav', 'ogg'],
withData: kIsWeb,
);
if (result == null || result.files.isEmpty) return null;
final file = result.files.single;
final ext = (file.extension ?? '').toLowerCase();
String? url;
if (kIsWeb) {
if (file.bytes == null) return null;
final mime = _audioMimeFromExtension(ext);
url = 'data:$mime;base64,${base64Encode(file.bytes!)}';
} else {
url = file.path;
}
if (url == null || url.isEmpty) return null;
return StoryMusicTrack(
id: 'local-${DateTime.now().millisecondsSinceEpoch}',
title: file.name,
artist: 'Local file',
url: url,
isLocal: true,
);
}
Future<List<StoryMusicTrack>> _searchOnlineMusic(BuildContext context, String query) async {
final q = query.trim();
if (q.isEmpty) return const [];
final uri = Uri.https('itunes.apple.com', '/search', {
'term': q,
'entity': 'song',
'limit': '20',
});
final response = await http.get(uri);
if (response.statusCode != 200) return const [];
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final results = (decoded['results'] as List<dynamic>? ?? const []);
return results
.whereType<Map<String, dynamic>>()
.where((e) => (e['previewUrl'] as String?) != null)
.map(
(e) => StoryMusicTrack(
id: 'itunes-${e['trackId'] ?? e['collectionId'] ?? e['previewUrl']}',
title: (e['trackName'] as String?) ?? 'Unknown Track',
artist: (e['artistName'] as String?) ?? 'Unknown Artist',
url: e['previewUrl'] as String,
),
)
.toList();
}
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';
}
}
String _audioMimeFromExtension(String ext) {
switch (ext) {
case 'wav':
return 'audio/wav';
case 'ogg':
return 'audio/ogg';
case 'aac':
return 'audio/aac';
case 'm4a':
return 'audio/mp4';
default:
return 'audio/mpeg';
}
}
Future<void> _submitStory(StoryDraft draft) async {
final meIndex = _users.indexWhere((user) => user.isCurrentUser);
if (meIndex < 0) return;
final hasMusic = draft.music != null;
final musicSuffix = hasMusic
? ' • ${draft.music!.title} (${_formatSeconds(draft.music!.startAtSeconds)}-${_formatSeconds(draft.music!.startAtSeconds + draft.music!.clipDurationSeconds)})'
: '';
final newStory = StoryItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
mediaUrl: draft.mediaPath,
caption: '${draft.caption ?? ''}$musicSuffix'.trim(),
createdAt: DateTime.now(),
seen: false,
mediaType: _draftMediaType,
duration: hasMusic
? const Duration(seconds: 30)
: _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;
});
}
String _formatSeconds(int total) {
final minutes = total ~/ 60;
final seconds = total % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
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,
onPickLocalMusic: _pickLocalMusic,
onSearchOnlineMusic: _searchOnlineMusic,
),
),
);
},
onUserTap: _openStoryViewerForUser,
),
const Divider(height: 1),
const Expanded(
child: Center(
child: Text('Feed goes here'),
),
),
],
),
);
}
}
enum _PickType {
image,
video,
}