storyig 0.1.3 copy "storyig: ^0.1.3" to clipboard
storyig: ^0.1.3 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: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,
}
0
likes
140
points
15
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter widgets for Instagram-like story circles and a lightweight story creator.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, just_audio, video_player

More

Packages that depend on storyig