STORY
Nostr Tools Flutter Tutorial: Implementing Key Generation and Event Publishing [Part 2]
AUTHOR
Joined 2022.06.19
DATE
VOTES
sats
COMMENTS
TAGS

Nostr Tools Flutter Tutorial: Implementing Key Generation and Event Publishing [Part 2]

Welcome to the Nostr Tools Flutter Tutorial!

In Part 1 of this tutorial series, you learned how to implement a simple feed in Nostr using the Nostr Tools Flutter package.

In Part 2 of the series, we will cover the following topics:

  1. Generating keys

  2. Storing keys securely using flutter_secure_storage

  3. Encoding and decoding keys as described in the Nip-19 proposal

  4. Obtaining private keys as input from the user and validating them

  5. Implementing the pull-to-refresh feature for feeds

  6. Publishing events to the relays

Noost App - Demo

But before we dive into the details, it's important to note that this tutorial is part two of a three-part series. If you haven't gone through Part 1 yet, I encourage you to take a look at it before continuing with Part 2. Also, make sure to familiarize yourself with the theory of Nip-19, as we'll be using it to encode and decode the keys.

Now, let's get started!

Setting up the Project

To begin with Part 2 development, we need to clone the starter project specifically created for this tutorial. This project includes pre-built UI widgets that we'll use to build our app.

Note: It's recommended to start with the starter project for Part 2, as it contains some changes specific to this article.

Here's how you can clone the starter project for Part 2. Open your terminal and run the following commands:

$ git clone https://github.com/aniketambore/ntf-materials.git
$ cd ntf-materials/02-noost-client/projects/starter
$ code .

This will open the Part 2 starter project in VS Code. Alternatively, you can use your preferred IDE to open the starter project. Once it's open, run flutter pub get if necessary, and then run the app.

When you run the app, you'll see a simple Nostr feed screen, which is what we implemented in Part 1 of this tutorial series.

Take a moment to review what has changed in the starter project since Part 1 of the tutorial.

Project Files

Component Library

We have added several new reusable component widgets in the Component Library, including:

  • NoostCurveButton

  • NoostOkButton

  • NoostSnackBar

  • NoostTextButton

  • NoostTextFormField

We will see how to use these components across different screens later in the tutorial.

Screens Folder

If you take a look at the screens folder, you will notice that we don't have the noost_feed_screen.dart file directly inside it, as we did in Part 1 of the tutorial. Instead, we have a feed_screen folder inside the screens folder, and the noost_feed_screen.dart file is located inside that folder. We also have a widget folder inside the feed_screen folder, which contains widgets that are specific to the feed screen.

Noost Feed Screen

If you open the noost_feed_screen.dart file, you will see that in the _NoostFeedScreenState class, there are several TODOs. These are the functionalities that we will be implementing in this tutorial. Take a look at the _connectToRelay and _initStream methods as well. In Part 1 of the tutorial, we implemented the relayStream method, but here we are breaking that logic into two separate methods to easily implement the resubscribing functionality in our app, which we will see further in this tutorial.

Secure Storage

The flutter_secure_storage package is a recommended way to store sensitive information such as private and public keys, as it leverages the most secure storage options provided by each platform, such as Apple's Keychain on iOS and Google's Keystore on Android.

To use flutter_secure_storage in your project:

Open the pubspec.yaml file and add the following package under dependencies, beneath nostr_tools:

flutter_secure_storage: ^8.0.0

Save the file and run flutter pub get to install the package.

Modify the minSdkVersion in the android/app/build.gradle file. The flutter_secure_storage plugin requires Android 4.3 (API level 18) or higher. So update the minSdkVersion inside the defaultConfig block as follows:

minSdkVersion 18

Now you are ready to work with secure storage in your project!

Initializing Defaults

In order to securely store and manage the private and public keys, as well as handle user input and form validation, we need to initialize some default state variables in the _NoostFeedScreenState class.

First, let's initialize an instance of FlutterSecureStorage to securely store our keys. We can do this by replacing // TODO: Initialize an instance of FlutterSecureStorage with the following code:

final _secureStorage = const FlutterSecureStorage();

Next, we need to initialize two empty String variables _privateKey and _publicKey to hold the hex values of the keys, and a boolean variable _keysExist to track whether the keys exist or not. Replace // TODO: Initialize two empty String variables to hold the private and public keys and // TODO: Initialize a boolean variable to track whether keys exist or not with the following code:

String _privateKey = '';
String _publicKey = '';
bool _keysExist = false;

We also need to declare and initialize instances of TextEditingController, GlobalKey, KeyApi, and Nip19 to handle user input, form validation, and key generation. Replace // TODO: Declare and initialize instances of TextEditingController, GlobalKey, KeyApi, and Nip19 with the following code:

final _keyController = TextEditingController();
final _formKey = GlobalKey<FormFieldState>();
final _keyGenerator = KeyApi();
final _nip19 = Nip19();

KeyApi() and Nip19() has been defined in nostr_tools package.

Lastly, we need to initialize a boolean variable _isNotePublishing by replacing // TODO: Initialize a boolean variable to track if note publishing is in progress with the following code:

bool _isNotePublishing = false;

These initial properties are crucial for managing the state of the note publishing process and securely storing and managing the keys. In the next section, we'll explore how we can generate new keys and store them securely using the _secureStorage instance.

Generating New Keys

To implement the generation of new keys, we first need to implement the _addKeysToStorage() method and then we'll implement generateNewKeys() method.

First, let's implement the _addKeysToStorage() method by replacing // TODO: Implement the _addKeysToStorage() method to add keys to secure storage with the following code:

  Future<bool> _addKeysToStorage(
    String privateKeyHex,
    String publicKeyHex,
  ) async {
    // 1
    Future.wait([
      _secureStorage.write(key: 'privateKey', value: privateKeyHex),
      _secureStorage.write(key: 'publicKey', value: publicKeyHex),
    ]);

    // 2
    setState(() {
      _privateKey = privateKeyHex;
      _publicKey = publicKeyHex;
      _keysExist = true;
    });

    return _keysExist;
  }

The _addKeysToStorage() method writes the private and public keys to a secure storage, using the FlutterSecureStorage plugin.

  1. It uses Future.wait() to wait for both write operations to complete.

  2. It then updates the state variables and triggering a rebuild of the widget.

Now, let's introduce the generateNewKeys() method by replacing // TODO: Implement the generateNewKeys() method to generate new keys and add them to secure storage with the following code:

  Future<bool> generateNewKeys() async {
    final newPrivateKey = _keyGenerator.generatePrivateKey();
    final newPublicKey = _keyGenerator.getPublicKey(newPrivateKey);

    return await _addKeysToStorage(newPrivateKey, newPublicKey);
  }

The generateNewKeys() method generates new private and public keys using an instance of the _keyGenerator class, and then adds these keys to the secure storage using the _addKeysToStorage() method that we implemented above. Finally, it returns a boolean value indicating whether the keys were successfully added to the storage or not.

To call the generateNewKeys() method, we need to update the modalBottomSheet() method by replacing /// TODO: Implement logic to generate new keys with the following code:

          generateNewKeyPressed: () {
            final currentContext = context;
            generateNewKeys().then((keysGenerated) {
              if (keysGenerated) {
                ScaffoldMessenger.of(currentContext).showSnackBar(
                    NoostSnackBar(label: 'Congratulations! Keys Generated!'));
              }
            });
            Navigator.pop(context);
          },

The updated modalBottomSheet() method generates new private and public keys when the "Generate New Keys" button is pressed. If the keys are generated successfully, it displays a snackbar with a congratulatory message using ScaffoldMessenger, and then closes the modal bottom sheet.

Finally, let's update the NoostAppBar widget with appropriate parameters for keysDialog by replacing /// TODO: Update the NoostAppBar widget with appropriate keysDialog, and deleteKeysDialog parameters. as:

      appBar: NoostAppBar(
        title: 'Noost',
        isConnected: _isConnected,
        keysDialog: IconButton(
            icon: const Icon(Icons.key),
            onPressed: () {
              // 1
              _keysExist
                  ? 
                  // 2
                  keysExistDialog(
                      _nip19.npubEncode(_publicKey),
                      _nip19.nsecEncode(_privateKey),
                    )
                  : 
                  // 3
                  modalBottomSheet();
            }),
        // TODO: Implement deleteKeysDialog parameter
      ),
  1. The updated NoostAppBar widget checks if _keysExist is true, indicating that keys already exist.

  2. If so, it calls the keysExistDialog() function with the appropriate arguments.

  3. Otherwise, it calls the modalBottomSheet() function to generate new keys if keys do not exist.

Time to put the app to the test! Since you've added a new dependency, go ahead and stop the current instance of the app and run it again. (Note: You may not always need to restart when adding dependencies).

Once the app is up and running, click on the "keys" icon in the appbar, and you'll see a modal bottom sheet like this:

Noost App - Modal Bottom Sheet

Next, click on the "Generate New Key" button within the modal bottom sheet, and watch as new keys are generated and securely stored in the app's storage.

Now, when you click on the "Key" icon again, instead of the bottom sheet, you'll see the keysExistDialog, indicating that the keys now exist in the app's storage:

Noost App - keysExistDialog

Within the keysExistDialog, you'll notice an icon in the bottom left that resembles an autorenew button. Clicking on this icon will allow you to switch between viewing the keys in hex format or bech32 encoded format, as described in Nip-19. To enable this functionality, simply locate the // TODO: Replace hexPriv and hexPub values in the keysExistDialog method, and replace it with the following code snippet:

return KeysExistDialog(
          npubEncoded: npubEncode,
          nsecEncoded: nsecEncode,
          hexPriv: _privateKey,
          hexPub: _publicKey,
        );

Here, we are passing the _privateKey and _publicKey values, which hold the hex formats of the keys.

After making these changes, simply hot reload the app, and you'll see the updated look and feel:

Noost App

Wait, there's more! To fully test the functionality, let's hot restart the app and click on the "keys" icon button again. You'll notice that this time, the modal bottom sheet appears instead of the keysExistDialog. This is because we haven't yet implemented the functionality to retrieve the keys from the storage, which we will be covering in the next section.

Retrieving Keys from Secure Storage

To retrieve the keys from the secure storage, we need to implement the _getKeysFromStorage() method. Replace the existing // TODO: Implement the _getKeysFromStorage() method to retrieve keys from secure storage with the following code:

  Future<void> _getKeysFromStorage() async {
    // 1
    final storedPrivateKey = await _secureStorage.read(key: 'privateKey');
    final storedPublicKey = await _secureStorage.read(key: 'publicKey');
    
    // 2
    if (storedPrivateKey != null && storedPublicKey != null) {
      setState(() {
        _privateKey = storedPrivateKey;
        _publicKey = storedPublicKey;
        _keysExist = true;
      });
    }
  }

Here's what this code does:

  1. We use the _secureStorage instance of FlutterSecureStorage to read the values associated with the keys 'privateKey' and 'publicKey' from the secure storage.

  2. Here we're checking if both storedPrivateKey and storedPublicKey are not null, which indicates that both private and public keys are stored in the secure storage and then we're updating the state variables.

Next, we need to call _getKeysFromStorage() inside the initState() method to retrieve the keys when the widget is initialized. Replace the // TODO: Call _getKeysFromStorage() inside of the initState() method with the following code:

  @override
  void initState() {
    _getKeysFromStorage();
    _initStream();
    super.initState();
  }

In the initState() method, we're now calling _getKeysFromStorage() to retrieve the keys from the secure storage. Now, when you hot restart the app and click on the "keys" icon, you will see the keysExistDialog appropriately displaying the retrieved keys.

In the next section, we will implement the functionality to delete keys from the secure storage. Stay tuned!

Deleting Keys From The Storage

To delete keys from secure storage, we need to implement the _deleteKeysFromStorage() method. Replace the // TODO: Implement the _deleteKeysFromStorage() method to delete keys from secure storage with the following code:

  Future<void> _deleteKeysFromStorage() async {
    // 1
    Future.wait([
      _secureStorage.delete(key: 'privateKey'),
      _secureStorage.delete(key: 'publicKey'),
    ]);
    
    // 2
    setState(() {
      _privateKey = '';
      _publicKey = '';
      _keysExist = false;
    });
  }

Here's what this code does:

  1. Here we're making calls to secure storage to delete the keys from the storage.

  2. We're updating the state variables _privateKey, _publicKey and _keysExist to reset the values after deleting the keys from the storage.

Now, let's implement the logic to call _deleteKeysFromStorage() in the deleteKeysDialog() method. Replace // TODO: Implement the logic to delete keys from storage with the following code:

          onYesPressed: () {
            final currentContext = context;
            _deleteKeysFromStorage().then((_) {
              if (!_keysExist) {
                ScaffoldMessenger.of(currentContext).showSnackBar(
                  NoostSnackBar(
                    label: 'Keys successfully deleted!',
                    isWarning: true,
                  ),
                );
              }
            });
            Navigator.pop(context);
          },

In this code, we're calling _deleteKeysFromStorage() method when the "Yes" button is pressed in the deleteKeysDialog. After deleting the keys, if _keysExist is false, we're displaying a snackbar with a success message, and then closing the dialog using Navigator.pop(context).

Finally, let's update the deleteKeysDialog parameter for the appbar. Replace // TODO: Implement deleteKeysDialog parameter with the following code:

        deleteKeysDialog: _keysExist
            ? IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () => deleteKeysDialog(),
              )
            : Container(),

In this code, we're checking if _keysExist is true, then only we're showing the deleteKeysDialog() as an IconButton with a delete icon in the top right of the appbar. Otherwise, we're displaying an empty container.

After making these changes, hot restart the app, and you will see the delete icon in the appbar.

Noost App

Clicking on the delete icon will open the deleteKeysDialog() as shown in the screenshot below.

Noost App

Clicking "Yes" on the dialog will successfully delete the keys from the device.

Now, up to this point in the tutorial, you've learned how to generate new keys. But what if the user is coming from a different client and wants to enter their private key to start using Noost from then on? Let's implement that next!

Handling Input of Private Key

Locate // TODO: Showing PastePrivateKeyDialog to handle the input which is inside of the pastePrivateKeyDialog() method and replace it as:

  void pastePrivateKeyDialog() {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return PastePrivateKeyDialog(
          keyController: _keyController,
          formKey: _formKey,
          // 1
          keyValidator: (value) {
            // TODO: Validating privateKey entered by the user
            return null;
          },
          // 2
          onOKPressed: () {
            if (_formKey.currentState!.validate()) {
              // TODO: Adding user entered private key to the storage.
              Navigator.pop(context);
            } else {
              _formKey.currentState?.setState(() {});
            }
          },
          onCancelPressed: () {
            Navigator.pop(context);
          },
        );
      },
    );
  }

The pastePrivateKeyDialog() method is responsible for displaying a dialog box with a custom widget called PastePrivateKeyDialog when called. Here's how it works:

  1. The keyValidator function is used for validating the private key entered by the user. We'll implement the validation logic further.

  2. If the form represented by _formKey is validated, the onOKPressed callback function is called.

Next, Locate // TODO: Validating privateKey entered by the user and replace it as:

          keyValidator: (value) {
            // 1
            if (value == null || value.isEmpty) {
              return 'Please enter your private key.';
            }
            
            try {
              // 2
              bool isValidHexKey = _keyGenerator.isValidPrivateKey(value);
              // 3
              bool isValidNsec = value.trim().startsWith('nsec') &&
                  _keyGenerator.isValidPrivateKey(_nip19.decode(value)['data']);
              
              // 4
              if (!(isValidHexKey || isValidNsec)) {
                return 'Your private key is not valid.';
              }

              // 5
            } on ChecksumVerificationException catch (e) {
              return e.message;

              // 6
            } catch (e) {
              return 'Error: $e';
            }

            // 7
            return null;
          }

Here's how the validation check of the user-entered private key works:

  1. It first checks if the value is null or empty, and if so, it returns the appropriate error message.

  2. We use the isValidPrivateKey(value) method to validate the user-entered private key. This method is defined in the nostr_tools Dart package and only accepts a hexadecimal value of the private key.

  3. If the user entered a bech32 encoded private key in the "nsec" format as described in Nip-19, we first decode it and then pass the decoded value (which is the hexadecimal value of the private key) to the isValidPrivateKey(...) method.

  4. If the value is not a valid private key in either hexadecimal or Nip-19 format, it returns the appropriate error message.

  5. If the value fails the checksum verification using a ChecksumVerificationException, it returns the error message from the exception.

  6. If any other exception occurs during the validation process, it returns an error message with the exception's error message.

  7. If the value passes all the validation checks, it returns null, indicating that the value is valid.

That's it for the validation check of the private key. Next locate // TODO: Adding user entered private key to the storage, and replace it as:

          onOKPressed: () {
            if (_formKey.currentState!.validate()) {
              // 1
              String privateKeyHex = _keyController.text.trim();
              String publicKeyHex;
            
              // 2
              if (privateKeyHex.startsWith('nsec')) {
                final decoded = _nip19.decode(privateKeyHex);
                privateKeyHex = decoded['data'];
                publicKeyHex = _keyGenerator.getPublicKey(privateKeyHex);
              } 
              // 3
              else {
                publicKeyHex = _keyGenerator.getPublicKey(privateKeyHex);
              }
              
              // 4
              _addKeysToStorage(privateKeyHex, publicKeyHex).then((keysAdded) {
                if (keysAdded) {
                  _keyController.clear();
                  ScaffoldMessenger.of(context).showSnackBar(
                    NoostSnackBar(label: 'Congratulations! Keys Stored!'),
                  );
                }
              });

              Navigator.pop(context);
            } else {
              _formKey.currentState?.setState(() {});
            }
          },

Let's break down what the code does:

  1. It obtains the value of the private key from the _keyController text field and assigns it to the privateKeyHex variable.

  2. It checks if the privateKeyHex starts with the string "nsec", indicating that it might be in NIP-19 format. If so, it decodes the privateKeyHex using _nip19.decode(privateKeyHex) method to obtain the 'data' field, which represents the actual private key in hexadecimal format.

  3. If the privateKeyHex does not start with "nsec", indicating that it is a regular hexadecimal private key.

  4. It then calls the _addKeysToStorage method to add the private key and public key to the storage. It attaches a then() callback to this method to handle the case when the keys are successfully added to the storage.

To see this in action, locate /// TODO: Implement logic to handle input of private key inside the modalBottomSheet() method and replace it with the following:

          inputPrivateKeyPressed: () {
            Navigator.pop(context);
            _keyController.clear();
            pastePrivateKeyDialog();
          },

Now, perform a hot restart of the app. Click on the "keys" icon and then click on the "Input your private key" option in the modal bottom sheet. You will see the pastePrivateKeyDialog with a text form field to input the private key, like this:

Noost App

Try clicking on the "OK" button without entering anything into the TextFormField, and you will see the appropriate error message displayed, like this:

Noost App

If you enter some irrelevant information, it will invalidate it and show you the appropriate error message, like this:

Noost App

Even if your private key is corrupted with some data, our code will catch and display that as well, like this:

Noost App

And if your private key is valid, it will store your key into the storage and display a snackbar with a congratulatory message.

By now, you've learned a lot about generating and managing keys in this tutorial. But there's more to explore in key management in nostr! In the next sections, we'll focus on implementing the pull-to-refresh functionality in our app and finally how to publish events.

Implementing Pull To Refresh

To enable the pull-to-refresh feature, we need to implement a method that will help us resubscribe to the stream. Locate the comment // TODO: Implement the _resubscribeStream method to initialize a stream., which is located just above the build method, and replace it with the following code:

  Future<void> _resubscribeStream() async {
    await Future.delayed(const Duration(seconds: 1), () {
      setState(() {
        _events.clear();
        _metaDatas.clear();
      });
      _initStream(); // Reconnect and resubscribe to the filter
    });
  }

In the above _resubscribeStream() method, after a delay of 1 second, we clear the _events and _metaDatas collections. Then, we call _initStream() method, which is responsible for initializing and subscribing to a stream, to reconnect and resubscribe to the filter.

Next, locate the comment /// TODO: Implement the RefreshIndicator widget and its onRefresh callback to handle refreshing of the page., and replace the entire body by wrapping the StreamBuilder widget in a RefreshIndicator widget as follows:

      // 1
      body: RefreshIndicator(
        // 2
        onRefresh: () async {
          await _resubscribeStream();
        },
        child: StreamBuilder(
            ....
        ),
      ),

Here's what it does:

  1. The RefreshIndicator widget provides pull-to-refresh behavior to its child widget, allowing the user to trigger a refresh action by pulling down on the screen.

  2. The onRefresh property of RefreshIndicator is set to an asynchronous callback function that gets executed when the user triggers the refresh action. In this case, the callback function is _resubscribeStream(), which is a Future-based method that performs asynchronous operations, clearing and resubscribing to a stream.

Now, hot restart your app and test the pull-to-refresh feature to see if it's working appropriately.

Noost App - Pull To Refresh

Next, let's move on to implementing the functionality to publish events in the next section.

Publishing Events

Let's make it easy for users to publish their notes by adding a button that displays a text field for entering the note. First, locate the /// TODO: Implement the CreatePostFAB widget to enable users to create new Noosts. comment and replace it with the following code:

      // 1
      floatingActionButton: _keysExist
          ? 
          // 2
          CreatePostFAB(
              publishNote: (note) {
                // TODO: Publish Note functionality
                Navigator.pop(context);
              },
              isNotePublishing: _isNotePublishing,
            )
          : 
          // 3
          Container(),

Here's what the code does:

  1. If _keysExist is true, the floatingActionButton is built using the CreatePostFAB widget. This custom widget represents a FAB with specific decoration and widgets for publishing a note.

  2. The CreatePostFAB widget is configured with two properties: publishNote and isNotePublishing. We will implement the publish note functionality soon. The isNotePublishing property indicates whether the note is currently being published, and it is likely used to display a progress indicator on the FAB during the publishing process.

  3. If _keysExist is false, an empty Container() widget is displayed, meaning that the FAB will not be visible.

Hot reload the app and you will see the FAB as shown in the screenshot below:

Noost App - FAB

When you click on the floating action button, a dialog will appear, prompting you to enter the note, as shown in the screenshot below:

Noost App - Create Note Dialog

With these modifications, your users will be able to easily publish their notes by simply clicking on the FAB and entering their note in the provided text field.

Now let's implement the publishing note functionality by replacing // TODO: Publish Note functionality with the following code:

              // 1
              publishNote: (note) {
                setState(() => _isNotePublishing = true);

                // 2
                final eventApi = EventApi();
                // 3
                final event = eventApi.finishEvent(
                  // 4
                  Event(
                    kind: 1,
                    tags: [
                      ['t', 'nostr']
                    ],
                    content: note!,
                    created_at: DateTime.now().millisecondsSinceEpoch ~/ 1000,
                  ),
                  _privateKey,
                );
                
                // 5
                if (eventApi.verifySignature(event)) {
                  try {
                    // 6
                    _relay.publish(event);
                    // 7
                    _resubscribeStream();
                    // 8
                    ScaffoldMessenger.of(context).showSnackBar(
                      NoostSnackBar(label: 'Congratulations! Noost Published!'),
                    );
                  } catch (_) {
                    // 9
                    ScaffoldMessenger.of(context).showSnackBar(NoostSnackBar(
                      label: 'Oops! Something went wrong!',
                      isWarning: true,
                    ));
                  }
                }
                setState(() => _isNotePublishing = false);
                Navigator.pop(context);
              },

There's a lot happening in here. Let's break it down:

  1. The publishNote function is called when the user triggers the "Noost!" button on the dialog.

  2. An instance of the EventApi class is created, which is defined in the nostr_tools package.

  3. The finishEvent method of the EventApi class is called with the Event object and the _privateKey variable.

    1. finishEvent will set the id of the event with the event hash and will sign the event with the given _privateKey.
  4. Here, we're creating a new instance of the Event class with specific properties such as:

    1. kind: 1: Which means a simple text note.

    2. tags: [['t', 'nostr']]: Its data type is List<List<String>>, which means if we want to add another tag, we need to update its value as tags: [['t', 'nostr'], ['t', 'bitcoin']]. But for our event, we're simply using the "nostr" tag because we're filtering the feed in our app with "nostr". So when our event is published, it will be super easy to reflect that in our app, only if we publish the event with a tag of "nostr".

    3. content: note!: It's set to the plaintext content of a note (anything the user wants to say).

    4. created_at: It's set to the current Unix timestamp in seconds.

  5. The verifySignature method of the EventApi class is called with the returned Event object to verify the signature of the event.

  6. If the signature is verified, the publish method is called on the _relay object to publish the event.

  7. After publishing the event, the _resubscribeStream method is called, likely to refresh the stream or subscription to reflect the newly published event.

  8. A SnackBar is shown to display a message indicating that the note was successfully published.

  9. If any error occurs during the publishing process (e.g., an exception is caught), a warning SnackBar is shown instead.

Now hot restart your app and let's publish a new event for the first time from Noost!

Noost App - Publishing Event

Phew! That was a lot of work, but you did it. Pretty amazing!

Congratulations, you've made it to the end of part 2 of this tutorial! Give yourself a pat on the back, because that was a lot of work, but you did it. You're now well on your way to becoming a Nostr pro dev!

By now, you should have a fully-functional Nostr app that can connect seamlessly to the Nostr relay, generate new keys with ease, and even allow you to input your own private key. You've learned how to store your keys securely, and you've implemented a convenient pull-to-refresh feature. Plus, you're now ready to publish your own Noosts and share your thoughts with the world.

But that's not all! In part 3 of this tutorial, we'll take things to the next level. We'll explore how to interact with multiple relays, opening up new possibilities for your app. And we'll even delve into publishing kind 0 events, unlocking exciting capabilities for your Noost app.

If you have any questions or run into any issues, don't hesitate to ask. I'm here to help! You can leave your questions in the comments section below or send me a direct message on Nostr. I'm always happy to assist you on your Nostr app development journey.

And if you're eager to dive into the complete code, head over to the final directory on GitHub. I hope you've found this tutorial helpful, and I'm grateful that you've been following along. Stay tuned for part 3, where we'll take your Nostr app to new heights. Keep up the fantastic work!