Skip to content

Build scalable/complex Nostr apps effortlessly — even as a beginner!

License

Notifications You must be signed in to change notification settings

anasfik/flutter_nostr

Repository files navigation

🚀 flutter_nostr

Flutter Dart Nostr License

Beautiful, pragmatic Flutter primitives for building Nostr-powered feeds and social UX

📖 Documentation🎯 Quick Start💡 Examples🔧 API Reference🤝 Contributing

New Project

✨ What is flutter_nostr?

flutter_nostr is a powerful Flutter package that provides building blocks for creating Nostr-powered social applications. It handles the complexity of fetching events, enriching them with related data, and rendering them in beautiful, performant Flutter widgets.

🎯 Key Features

  • 🔄 Parallel Data Fetching: Efficiently fetch related data (profiles, reactions, etc.) using typed parallel requests
  • 📱 Flutter-Native: Built specifically for Flutter with proper state management and widget lifecycle
  • 🎨 Customizable: Flexible builder patterns and adapters for any data structure
  • Performant: Smart caching, pagination, and visibility-based loading
  • 🔗 Type-Safe: Full TypeScript-like type safety with Dart generics
  • 📚 Example-Rich: Complete example app with multiple use cases

🚀 Quick Start

1. Installation

Add to your pubspec.yaml:

dependencies:
  flutter_nostr: ^0.1.0

2. Initialize

import 'package:flutter/material.dart';
import 'package:flutter_nostr/flutter_nostr.dart';

void main() async {
  // Initialize with one or more relays
  await FlutterNostr.init(relays: [
    'wss://relay.nostr.band',
    'wss://nos.lol',
  ]);

  runApp(const MyApp());
}

3. Enjoy the package capabilities.

Feeds

Feeds are the core building block of social applications, it is that simple scrollable list of events enriched with related data customized to your needs, with support to all states like loading, error, empty, pull-to-refresh, infinite scroll and more.

Simple Feed (1-layer Feed)

Key Components

The main component that you will always use to build a feed is FlutterNostrFeed, it is responsible for fetching the data and providing it to the builder function.

Inside the builder function, you can take full control on how/what you want to support in your feed, or you can simply use the pre-built FlutterNostrFeedList which abstract general handling for a feed like loading, error handling... and only prompt you to build the UI for your list of items.

The simple feed is refered to a feed that only loads requests sequentianlly only, and does require loading request events only

Example:

FlutterNostrFeed(
  filters: [
    NostrFilter(
      limit: 10, // limit each loading to 10 events
      kinds: [30402], // listings kinds
    ),
  ],.

  builder: (context, data, options) {
    return FlutterNostrFeedList(
      data: data,
      options: options,
      itemBuilder: (context, event, index, data, options) {
         return ListTile(
           title: Text(event.content ?? 'No content'),
           subtitle: Text('Author: ${event.pubkey.substring(0, 8)}...'),
         );
      },
    );
  },
),

That's it! 🎉 You now have a fully functional Nostr listings feed with:

  • ✅ Pull-to-refresh
  • ✅ Infinite scroll
  • ✅ Loading states
  • ✅ Error handling
  • ✅ Smooth and lag-free rendering (using ListView.builder and some other techniques..)
  • ✅ Much more handling that you propably don't know about but happens under the hood.

Rich Feed (Multi-layer Feed)

In order for a Nostr Feed to be functional and usable, loading more entities that relates to what actually was requested initially is requested. as an example, creating a posts feed shoulf also fetch for these posts authors details such name, username, picture...

This is where the package also abstracts the complexity of doing so in a multi-layer feed, which basically have parallel loading for the initial requested events, so lets take this use case:

  • When the posts feed is loaded. (Layer 1)
  • For each post (Loaded in Layer 1) that the end-user sees, more parallel requests executes to get their authors user details, reactions, zaps, comments, referenced events/pubkeys if any with nevent ,npub,note, nprofile..., parent events if any... (Layer 2)
  • For each post (Loaded in Layer 1) reaction/comment (Loaded in Layer 2), more parallel requests execute to get their user details or more related data (Layer 3)
  • More parallel fetching if needed...

With this mechanism, you will be basically be able to build your goal feed even if it will require much more fetching layers.

Key Components

Note: I assume you read the Simple Feed section before continuing.

  • The FlutterNostrFeed still the main component that you will use to build a feed, but now you will also use the parallelRequestRequestsHandler parameter to define your parallel requests using the already loaded data from previous layers.
  • Each parallel request is represented by the ParallelRequest<T> class, which is a typed request that holds the filters to be used to fetch the related data, and an adapter function to convert the fetched NostrEvent into the desired type T.
  • Each parallel request is identified from other parallel requests if any with the ParallelRequestId<T>, where you basically create a unique id for each request you want to make.
  • Results of each request is passed to the builder via the FeedBuilderData data parameter, where you can access the results of each request by its ParallelRequestId<T>.
  • To execute more parallel requests based on the results of previous parallel requests, you can use the .then<U>() method on the ParallelRequest<T> instance to chain more requests, like:
ParallelRequest<T>(
  //...
).then<U>((List<T> previousResults) {
  return ParallelRequest<U>(
    //...
  );
}).then<V>((List<U> previousResults) {
  return ParallelRequest<V>(
    //...
  );
}).then<W>((List<V> previousResults) {
  return ParallelRequest<W>(
    //...
  );
});

Example 1: Feed with User Profiles

 final profileFetchRequestId = ParallelRequestId<UserInfo>(id: 'unique-id-1');

FlutterNostrFeed(
  filters: [
    NostrFilter(
      limit: 25,
      kinds: [1], // posts kinds
    ),
  ],
  parallelRequestRequestsHandler: (_, List<NostrEvent> postEvents) {
    return ParallelRequest(
      id: profileFetchRequestId,
      filters: [
        NostrFilter(
          kinds: [0], // user details kind
          authors: postEvents.map((e) => e.pubkey).toList(),
        ),
      ],
      adapter: (event) {
        return UserInfo.fromEvent(event);
      },
    );
  },
  builder: (context, data, options) {
    return FlutterNostrFeedList(
      data: data,
      options: options,
      itemBuilder: (context, NostrEvent postEvent, index, data, options) {
        final postContent = postEvent.content != null ? postEvent.content! : "";

        // This is how we access the requests results for a specific parallel request by its id
        final profileFetchResults = data.parallelRequestResultsFor(
          profileFetchRequestId,
        );

        List<UserInfo> userResults = profileFetchResults?.adaptedResults ?? [];

        UserInfo? user =
            userResults
                .where((element) => element.event.pubkey == postEvent.pubkey)
                .firstOrNull;

        final postOwnerName =
            user?.name.isEmpty ?? true ? "Loading Or Unknown" : user!.name;

        return ListTile(
          title: Text(postOwnerName),
          subtitle: Text(postContent),
        );
      },
    );
  },
),

Example 2: Feed with user profiles, user followings and user followers (Multi-Layer Feed)

 final profileFetchRequestId = ParallelRequestId<UserInfo>(id: 'unique-id-1');
 final followingsFetchRequestId = ParallelRequestId<UserFollowings>(id: 'unique-id-2');

FlutterNostrFeed(
  filters: [
    NostrFilter(
      limit: 25,
      kinds: [1], // posts kinds
    ),
  ],
  parallelRequestRequestsHandler: (_, List<NostrEvent> postEvents) {
    return ParallelRequest(
      id: profileFetchRequestId,
      filters: [
        NostrFilter(
          kinds: [0], // user details kind
          authors: postEvents.map((e) => e.pubkey).toList(),
        ),
      ],
      adapter: (event) {
        return UserInfo.fromEvent(event);
      },
    ).then<UserFollowings>((List<UserInfo> users) {
      return ParallelRequest(
        id: followingsFetchRequestId,
        filters: [
          NostrFilter(
            kinds: [3], // user followings kind
            authors: users.map((u) => u.event.pubkey).toList(),
          ),
        ],
        adapter: (event) {
          return UserFollowings.fromEvent(event);
        },
      );
    });
  },
  builder: (context, data, options) {
    return FlutterNostrFeedList(
      data: data,
      options: options,
      itemBuilder: (context, NostrEvent postEvent, index, data, options) {
        final postContent = postEvent.content != null ? postEvent.content! : "";

        final profileFetchResults = data.parallelRequestResultsFor(
          profileFetchRequestId,
        );

        final followingsFetchResults = data.parallelRequestResultsFor(
          followingsFetchRequestId,
        );

        List<UserInfo> userResults = profileFetchResults?.adaptedResults ?? [];

        List<UserFollowings> followingsResults =
            followingsFetchResults?.adaptedResults ?? [];

        UserInfo? user =
            userResults
                .where((element) => element.event.pubkey == postEvent.pubkey)
                .firstOrNull;

        UserFollowings? userFollowings =
            followingsResults
                .where((element) => element.pubkey == postEvent.pubkey)
                .firstOrNull;

        final postOwnerName =
            user?.name.isEmpty ?? true ? "Loading Or Unknown" : user!.name;

        final postOwnerFollowingsCount = userFollowings?.followings.length ?? 0;

        return ListTile(
          title: Text(postOwnerName),
          subtitle: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(postContent),
              SizedBox(height: 4),
              Text(
                'Followings: $postOwnerFollowingsCount',
                style: TextStyle(fontSize: 12, color: Colors.grey[600]),
              ),
            ],
          ),
        );
      },
    );
  },
)

One-Time Event Fetching

Sometimes you don't need a full feed with pagination and infinite scroll—you just need to fetch a specific event or a small set of events once. The package provides two simple widgets for these use cases.

OneTimeEventBuilder

Use OneTimeEventBuilder when you need to fetch a single event based on a filter. This is perfect for displaying a specific user profile, a referenced note, or any single Nostr event.

Key Features

  • 🎯 Single Event: Fetches one event matching your filter
  • FutureBuilder-based: Built on Flutter's FutureBuilder for simple state management
  • 🔄 Loading States: Built-in handling for loading, error, and success states
  • 🎨 Flexible Builder: Full control over how to render the event

Example: Display a User Profile

OneTimeEventBuilder(
  filter: NostrFilter(
    kinds: [0], // metadata/profile kind
    authors: ['npub1...'], // specific user's pubkey
    limit: 1,
  ),
  builder: (context, isLoading, error, subscriptionId, event) {
    if (isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    if (error != null) {
      return Center(
        child: Text('Error loading profile: $error'),
      );
    }

    if (event == null) {
      return Center(child: Text('Profile not found'));
    }

    final metadata = Metadata.fromEvent(event);
    
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            CircleAvatar(
              radius: 40,
              backgroundImage: metadata.picture != null
                ? NetworkImage(metadata.picture!)
                : null,
            ),
            SizedBox(height: 16),
            Text(
              metadata.name ?? 'Unknown',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            Text(
              metadata.about ?? 'No bio',
              style: TextStyle(color: Colors.grey[600]),
            ),
          ],
        ),
      ),
    );
  },
)

OneTimeEventsBuilder

Use OneTimeEventsBuilder when you need to fetch multiple events at once. This is ideal for displaying a small collection of events, such as the latest posts from a user or a list of referenced events.

Key Features

  • 📚 Multiple Events: Fetches multiple events matching your filters
  • FutureBuilder-based: Built on Flutter's FutureBuilder for simple state management
  • 🔄 Loading States: Built-in handling for loading, error, and success states
  • 🎨 Flexible Builder: Full control over how to render the events list

Example: Display Latest Posts from a User

OneTimeEventsBuilder(
  filters: [
    NostrFilter(
      kinds: [1], // text note kind
      authors: ['npub1...'], // specific user's pubkey
      limit: 10,
    ),
  ],
  builder: (context, isLoading, error, subscriptionId, events) {
    if (isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    if (error != null) {
      return Center(
        child: Text('Error loading posts: $error'),
      );
    }

    if (events == null || events.isEmpty) {
      return Center(child: Text('No posts found'));
    }

    return ListView.builder(
      itemCount: events.length,
      itemBuilder: (context, index) {
        final event = events[index];
        final timestamp = DateTime.fromMillisecondsSinceEpoch(
          event.createdAt * 1000,
        );

        return ListTile(
          title: Text(event.content ?? 'No content'),
          subtitle: Text(
            'Posted: ${timestamp.toString().substring(0, 16)}',
          ),
          leading: Icon(Icons.note),
        );
      },
    );
  },
)

Example: Fetch Multiple Specific Events by ID

OneTimeEventsBuilder(
  filters: [
    NostrFilter(
      ids: [
        'event_id_1',
        'event_id_2',
        'event_id_3',
      ],
    ),
  ],
  builder: (context, isLoading, error, subscriptionId, events) {
    if (isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    if (error != null) {
      return Center(child: Text('Error: $error'));
    }

    final eventMap = {
      for (var event in events ?? []) event.id: event
    };

    return Column(
      children: [
        _buildEventCard(eventMap['event_id_1']),
        _buildEventCard(eventMap['event_id_2']),
        _buildEventCard(eventMap['event_id_3']),
      ],
    );
  },
)

🎮 Example App

The package includes a comprehensive example app you can run and explore visually different use cases and implementations, simply run:

cd example
flutter pub get
flutter run

🛠️ Advanced Topics

Error Handling

FlutterNostrFeed(
  builder: (context, data, options) {
    if (options.hasError) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error, size: 64, color: Colors.red),
            SizedBox(height: 16),
            Text('Failed to load feed'),
            SizedBox(height: 8),
            ElevatedButton(
              onPressed: options.refresh,
              child: Text('Retry'),
            ),
          ],
        ),
      );
    }

    return FlutterNostrFeedList(/* ... */);
  },
)

🤝 Contributing

We welcome contributions! Here's how you can help:

🐛 Bug Reports

  • Use the issue template
  • Include steps to reproduce
  • Provide Flutter/Dart version info

💡 Feature Requests

  • Describe the use case
  • Explain why it would be valuable
  • Consider contributing a PR

🔧 Pull Requests

  • Fork the repository
  • Create a feature branch
  • Add tests for new functionality
  • Update documentation
  • Submit a PR with a clear description

📋 Roadmap

🎯 Version 0.2

  • Chat primitives (NIP-44)
  • Identity helpers and key management
  • Enhanced error handling
  • Performance improvements

🚀 Future Versions

  • Payment integration (Lightning)
  • NostrConnect support
  • Relay moderation tools
  • Advanced caching strategies
  • WebSocket connection pooling

📄 License

This project is licensed under the MIT License - see the LICENSE file for details.


🙏 Acknowledgments

  • Built on top of dart_nostr package
  • Inspired by the Nostr protocol's simplicity and power
  • Community feedback and contributions

Made with ❤️ for the Nostr community

⭐ Star this repo🐛 Report issues💬 Join discussions

About

Build scalable/complex Nostr apps effortlessly — even as a beginner!

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

 

Languages