🦆

[入門] Flutter で SNSアプリを作ってみる(Nostrプロトコル)

2023/03/22に公開

概要

今回は flutter の入門として超簡単な SNS アプリを作成します。文字数は多いですがほとんどコードです。
機能要件としては

  • テキストでの投稿ができる
  • 他人の投稿を見ることができる
    の 2 つです。

サーバーを立てて作るのもいいですが、今回は flutter のみに集中したいので Nostr というプロトコルを使用して作成します。
Nostr は分散型SNSを実現するプロトコルの1つです。

実は、 Nostr を使用する場合はアカウントを作成する必要があるので、適当なNostrクライアントでアカウントを作成し、秘密鍵(private key) を保存しておいてください。

今回のリポジトリはこちら

00: この記事でできるようになること

  1. WebSocket でサーバに接続できる
  2. Flutter で簡単な UI を作成できる
    • ListView で可変調の List を扱える。
    • ListView ではみ出るのを解決できる。
    • URL指定の画像を扱える。
    • (おまけ①) 上にタブをつけることができる。

01: 準備

VScode を使用しているのでf1 -> Flutter: New Projectでプロジェクトを作成します。

今回は nostr, web_socket_channel というパッケージを使います。

flutter pub add nostr
flutter pub add web_socket_channel

これで準備 OK です。
もしくは pubspec.yamlの dependencies に nostr: ^1.3.3 を追加しても OK です。

// ...
dependencies:
  flutter:
    sdk: flutter
  nostr: ^1.3.3
  web_socket_channel: ^2.3.0
// ...

02: 簡単に UI を作成する

まずは UI を作ってみましょう。
必要な要件としては、

  1. 無数に増えるメッセージを表示できる。
  2. プロフィールが表示できる
    • 名前
    • プロフィール画像

と言ったところでしょうか。イメージとしては Twitter のタイムラインみたいな感じにしたいと思います。
ただし、今回はプロフィールの取得までは行わないため、名前とプロフィール画像は適当に決めちゃいましょうか。

02-01: メッセージを ListView で表示する。

無数に増えるメッセージを ListView で表示します。
ListView は複数のコンポーネントを縦に積んで表示できる view です。


  Widget build(BuildContext context) {
    return ListView(
      children: const [
        Text('a'),
        Text('b'),
        Text('c'),
      ],
    );
  }

こんな感じに書くと、

こんな感じにリスト表示されます。
縦に積む方法としては Column などいろいろありますが、ListView はオーバーした分についてスクロールできるようにしてくれます。

しかし、先ほどのように ListView の children を使用すると可変調の List に対応できません。
そこで今回は ListView.builder() を使用します。

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: NostrWidget(),
      ),
    );
  }
}

class NostrWidget extends StatefulWidget {
  const NostrWidget({super.key});

  
  State<NostrWidget> createState() => _NostrWidgetState();
}

class _NostrWidgetState extends State<NostrWidget> {
  _NostrWidgetState();
  final List<String> messages = ["これが最初のメッセージ", "2つ目のメッセージ", "これが3つ目だ!"];

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ListView.builder(
          itemCount: messages.length,
          itemBuilder: (context, index) {
            return Text(messages[index]);
          },
        ),
      ),
    );
  }
}

こんな感じで NostrWidget という widget を作成し、MainApp で呼んでください。(この時点では StatefulWidget で作成する必要はないのですが、後々 StatelessWidget だと難しくなるので...)
すると、

ちゃんとリスト内のメッセージが表示されています。

02-02: プロフィール画像を表示する。

次はプロフィール画像の表示です。
今回は適当な画像を表示する感じですね。
イラストやさんの

この画像を使用します。特に意味はないです。

画像をダウンロードして表示させてもいいんですが、実際のSNSアプリの場合はアプリ側に画像は保存しないことが多いです。
そこで今回は、URLを指定して毎回画像をとってくるようにします。
profileImage で画像を指定して、ListView の child を以下のように変更します。

final Image profileImage = const Image(
  image: NetworkImage(
    'https://1.bp.blogspot.com/-BnPjHnaxR8Q/YEGP_e4vImI/AAAAAAABdco/2i7s2jl14xUhqtxlR2P3JIsFz76EDZv3gCNcBGAsYHQ/s180-c/buranko_boy_smile.png'),
);
// ...略
ListView.builder(
  itemCount: messages.length,
  itemBuilder: (context, index) {
    return Row(
      children: [
        profileImage,
        Text(
          messages[index],
    ),
  ],
);

すると

こんな感じ。クソダサレイアウトは後々修正します。

02-03: いい感じのレイアウトにする。

それではいい感じのレイアウトにしましょう。
いい感じというかそれっぽい感じですね。

messageWidget を作成して各メッセージのデザインを作成します。

class NostrWidget extends StatefulWidget {
  const NostrWidget({super.key});

  
  State<NostrWidget> createState() => _NostrWidgetState();
}

class _NostrWidgetState extends State<NostrWidget> {
  _NostrWidgetState();
  final List<String> messages = ["これが最初のメッセージ", "2つ目のメッセージ", "これが3つ目だ!"];
  final Image profileImage = const Image(
    width: 50, // いい感じに大きさ調節しています。
    height: 50,
    image: NetworkImage(
        'https://1.bp.blogspot.com/-BnPjHnaxR8Q/YEGP_e4vImI/AAAAAAABdco/2i7s2jl14xUhqtxlR2P3JIsFz76EDZv3gCNcBGAsYHQ/s180-c/buranko_boy_smile.png'),
  );

  final channel = WebSocketChannel.connect(Uri.parse('wss://relay.damus.io'));
  // もし接続できない場合は他のリレーサーバのURLを指定してください。

  Widget messageWidget(int index) {
    return Container(
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: Colors.black12, width: 1),
        ),
      ),
      margin: const EdgeInsets.fromLTRB(10, 0, 10, 0), // 下線の左右に余白を作りたかった
      padding: const EdgeInsets.fromLTRB(0, 10, 0, 10), // いい感じに上下の余白を作ります。
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start, // 上詰めにする
        children: [
          ClipRRect(
            // プロフィール画像を丸くします。
            borderRadius: BorderRadius.circular(25),
            child: profileImage,
          ),
          Padding(
            padding: const EdgeInsets.only(left: 15),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text('吾輩は猫である', // 名前です。
                    style: TextStyle(fontWeight: FontWeight.bold)),
                Text(
                  messages[index],
                ),
              ],
            ),
          )
        ],
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('これからすごくなる SNS アプリ')),
      body: Center(
        child: ListView.builder(
          itemCount: messages.length,
          itemBuilder: (context, index) {
            return messageWidget(index);
          },
        ),
      ),
    );
  }
}

こんな感じで、

こんな感じです。

03: メッセージを受信する

次に Nostr のリレーサーバからデータを受信してみましょう。
手順としては

  1. WebSocket でリレーサーバに接続
  2. リクエストを送信
  3. 流れてきたデータを加工・表示

と言った感じです。

03-01: WebSocketでリレーサーバに接続

接続だけなら超簡単です。

final channel = WebSocketChannel.connect(Uri.parse('wss://relay.damus.io'));

この行を _NostrWidgetState 内に記述するだけで OK です。

03-02: リクエストを送信

ここから少しずつ nostr パッケージを使用していきます。
「Nostrなんか興味ねぇよ!!」って方はコピペで OK です。そうじゃない方も大体コピペで OK です。

_NostrWidgetState に以下のコードを追加してください。

  
  void initState() {
    Request requestWithFilter = Request(generate64RandomHexChars(), [
      Filter(
        kinds: [1],
        limit: 50,
      )
    ]); // ①
    channel.sink.add(requestWithFilter.serialize()); // ②
    channel.stream.listen((payload) {
      print(payload); // ③
    });
    super.initState();
  }

で実行すると、ターミナルにめっちゃメッセージが流れてきます。故障じゃないです安心してください。(Nostr リレーサーバ上のスパム等も受信するのですごく多いんですよね...)

やっていることとしては、

  • ① 取得するメッセージの条件を指定: 今回は通常メッセージの最新50件
  • ② 条件をリクエストとしてリレーサーバに送信
  • ③ データ取得時の振る舞いを設定: 今回はターミナルに選られたデータを出力

といった感じです。

03-03: 流れてきたデータを加工・表示

それでは、受け取ったデータを加工して 02 で作成した UI に表示しましょう。

03-03-01: データの加工

先ほどターミナルに表示されていたのを眺めてみると

[
  "EVENT",
  "29a72235f66bd47f12e3e8922482054b21097b4e0ebf3cd837a991b8e1adb232",
  {
    "content":"Nostr の Damus でノストラダムスなのか!!!!",
    "created_at":1675621235,
    "id":"19c67f554092186f9bd731f32715fe28917579f711ec75e25281c3f659a582d8",
    "kind":1,
    "pubkey":"ea67462d032231f0583fa7a20c4f96e1122c4dd1623c52840e2a40b11bcdf9d1",
    "sig":"67058cbd3e41c2705d3ae3b730a4a79e7856a74721577fb28f487886466f110cc07edcae97eb472811fa4b3794d95d0e5498c27b6692234b9249e4109959e1ee",
    "tags":[]
  }
]

こんな感じの文字列が流れてきているのがわかります。(これは私が投稿した実際のメッセージです。誰でもわかることをさも天才かのように言っているのは気にしないでください。)

Nostr の細かい部分は置いておきますが、

  • content にメッセージの内容が含まれていそう
  • created_at がメッセージの作成日時を示していそう

ここらへんがわかっていただけれは OK です。
では、流れてきたデータを加工してメッセージの内容を取り出します。

channel.stream.listen((payload) {
  final _msg = Message.deserialize(payload); // ①
  if (_msg.type == 'EVENT') { // ②
    print(_msg.message.content);
  }
});

こんな感じに content を取り出すことができます。

  • ① nostr パッケージに入っている Message クラスを使用して先ほどのデータを扱いやすく加工しています。
  • ② Nostr のデータにはいくつかタイプがあり、メッセージが含まれているのは、 EVENT タイプなので、今回は EVENT タイプに絞って扱います。

03-03-02: データの表示

02 で作成した messages にメッセージを追加することでデータを表示します。
データを追加する際には少し注意が必要で、messagesに追加する部分をsetStateで括りましょう。
これを怠ると、messagesを更新しても画面が更新されず、先ほどのListViewが変わりません。

実際には、以下のようにすると messages に追加されて画面も更新されます。

channel.stream.listen((payload) {
  try {
    final _msg = Message.deserialize(payload);
    if (_msg.type == 'EVENT') {
      setState(() {
        messages.add(_msg.message.content);
      });
    }
  } catch (err) {}
});

しかし、ここで問題があります。画面を見ると

こんな感じに悲惨なことになっています。

ここでの問題点は2つあり、

  1. メッセージが右側にはみ出ている。
  2. メッセージが下に追加されている。

です。

解決方法としては、結構簡単で

  1. Expanded を使用する。
  2. created_at を使用する。

で OK です。

① Expanded ではみ出るのを修正

そもそも ListView を使用した際にうまく横幅に合わせて改行できなくなることが原因です。
そこで、はみ出る部分を Expanded でくくってあげるとうまく改行してくれます。
今回の場合だと、名前とメッセージが入っている Column をくくってあげれば OK です。

messageWidget

  Widget messageWidget(int index) {
    return Container(
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: Colors.black12, width: 1),
        ),
      ),
      margin: const EdgeInsets.fromLTRB(10, 0, 10, 0), 
      padding: const EdgeInsets.fromLTRB(0, 10, 0, 10), 
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          ClipRRect(
            borderRadius: BorderRadius.circular(25),
            child: profileImage,
          ),
          Expanded( // ここに追加しています
            child: Padding(
              padding: const EdgeInsets.only(left: 15),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('吾輩は猫である',
                      style: TextStyle(fontWeight: FontWeight.bold)),
                  Text(
                    messages[index],
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

こんな感じに修正。

② created_at を使用してメッセージを投稿順にする

めっちゃ簡単な方法として

  1. created_atを取得
  2. メッセージを追加する際に毎回 created_at でソート

という方法で実装します。

まずは messages を以下のように変更します。

final List<Map<String, dynamic>> messages = [
  {'createdAt': 0, "content": "これが最初のメッセージ"},
  {'createdAt': 1, "content": "2つ目のメッセージ"},
  {'createdAt': 2, "content": "これが3つ目だ!"},
];

messagescreatedAt を持たせています。

次に、channel.stream.listen を以下のように変更します。

channel.stream.listen((payload) {
  try {
    final _msg = Message.deserialize(payload);
    if (_msg.type == 'EVENT') {
      setState(() {
        messages.add({
          "createdAt": _msg.message.createdAt,
          "content": _msg.message.content
        });
	messages.sort((a, b) {
          return b['createdAt'].compareTo(a['createdAt']);
        });
      });
    }
  } catch (err) {}
});

messages に追加する際に createdAt も一緒に追加し、 createdAtでソートしています。

最後に表示部分の変更ですね。
messages[index]messages[index]["content"] にすれば OK です。

ここまでのコードはこんな感じ

import 'package:flutter/material.dart';
import 'package:nostr/nostr.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: NostrWidget(),
      ),
    );
  }
}

class NostrWidget extends StatefulWidget {
  const NostrWidget({super.key});

  
  State<NostrWidget> createState() => _NostrWidgetState();
}

class _NostrWidgetState extends State<NostrWidget> {
  _NostrWidgetState();
  final List<Map<String, dynamic>> messages = [
    {'createdAt': 0, "content": "これが最初のメッセージ"},
    {'createdAt': 1, "content": "2つ目のメッセージ"},
    {'createdAt': 2, "content": "これが3つ目だ!"},
  ];
  final Image profileImage = const Image(
    width: 50, // いい感じに大きさ調節しています。
    height: 50,
    image: NetworkImage(
        'https://1.bp.blogspot.com/-BnPjHnaxR8Q/YEGP_e4vImI/AAAAAAABdco/2i7s2jl14xUhqtxlR2P3JIsFz76EDZv3gCNcBGAsYHQ/s180-c/buranko_boy_smile.png'),
  );

  final channel = WebSocketChannel.connect(Uri.parse('wss://relay.damus.io'));

  
  void initState() {
    Request requestWithFilter = Request(generate64RandomHexChars(), [
      Filter(
        kinds: [1],
        limit: 50,
      )
    ]);
    channel.sink.add(requestWithFilter.serialize());
    channel.stream.listen((payload) {
      try {
        final _msg = Message.deserialize(payload);
        if (_msg.type == 'EVENT') {
          setState(() {
            messages.add({
              "createdAt": _msg.message.createdAt,
              "content": _msg.message.content
            });
            messages.sort((a, b) {
              return b['createdAt'].compareTo(a['createdAt']);
            });
          });
        }
      } catch (err) {}
    });
    super.initState();
  }

  Widget messageWidget(int index) {
    return Container(
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: Colors.black12, width: 1),
        ),
      ),
      margin: const EdgeInsets.fromLTRB(10, 0, 10, 0), // 下線の左右に余白を作りたかった
      padding: const EdgeInsets.fromLTRB(0, 10, 0, 10), // いい感じに上下の余白を作ります。
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start, // 上詰めにする
        children: [
          ClipRRect(
            // プロフィール画像を丸くします。
            borderRadius: BorderRadius.circular(25),
            child: profileImage,
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.only(left: 15),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('吾輩は猫である', // 名前です。
                      style: TextStyle(fontWeight: FontWeight.bold)),
                  Text(
                    messages[index]["content"],
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('これからすごくなる SNS アプリ')),
      body: Center(
        child: ListView.builder(
          itemCount: messages.length,
          itemBuilder: (context, index) {
            return messageWidget(index);
          },
        ),
      ),
    );
  }
}

ここまでで、

こんな感じに投稿順にメッセージが表示されていると思います。(投稿の内容は意味不明ですが...)

04: メッセージを送信する

では最後にメッセージを送信できるようにしましょう。
今回は、Nostr リレーサーバにメッセージを送信するために以下の手順を踏みます。

  1. フローティングボタン押下でメッセージを送るページを表示
  2. 秘密鍵と送信するメッセージを記入
  3. 送信ボタンで送信

04-01: フローティングボタンの配置

そもそもフローティングボタンってなんなん?って思うかもしれませんが、Twitter の右下にいるこのボタンのことです。

flutter はこのフローティングボタンの実装がめっちゃ簡単です。
_NostrWidgetState内の build を以下のように変更します。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('これからすごくなる SNS アプリ')),
      body: Center(
        child: ListView.builder(
          itemCount: messages.length,
          itemBuilder: (context, index) {
            return messageWidget(index);
          },
        ),
      ),
      floatingActionButton: FloatingActionButton( // ここから
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => Scaffold( // この部分が送信用ページになる
                appBar: AppBar(),
                body: Text('送信用ページ'),
              ),
              fullscreenDialog: true,
            ),
          );
        },
        child: const Icon(Icons.add),
      ), // ここまでを追加
    );
  }

すると、アプリの右下にフローティングボタンが出てきて,

押すとこんな感じのモーダルが出てきます。

04-02: メッセージを送るページの作成

メッセージを送るページ MessageSendPage を作成します。
lib 内に messageSendPage.dart を作成し、中に以下のように記述します。

import 'package:flutter/material.dart';

class MessageSendPage extends StatefulWidget {
  const MessageSendPage({super.key});

  
  _MessageSendPageState createState() => _MessageSendPageState();
}

class _MessageSendPageState extends State<MessageSendPage> {
  _MessageSendPageState();

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('メッセージ送信'),
      ),
      body: const Text('メッセージ送信用のページ'),
    );
  }
}

ここからいろいろと追加してメッセージの送信ができるようにしていきます。
欲しい機能は

  1. 秘密鍵を登録できる
  2. メッセージの内容を書ける
  3. メッセージを送信できる

この3つです。Nostr でメッセージを送信するために有効な秘密鍵が必要なんです。

04-02-01: 秘密鍵を登録できる

秘密鍵を登録するために TextField を追加します。

class _MessageSendPageState extends State<MessageSendPage> {
  String _privKey = ''; // 秘密鍵

  _MessageSendPageState();

  // テキストフィールドと _privKey を合わせる
  void _handleKey(String e) {
    setState(() {
      _privKey = e;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('メッセージ送信'),
      ),
      body: Container(
        padding: const EdgeInsets.all(20),
        child: TextField(
          enabled: true,
          onChanged: _handleKey,
        ),
      ),
    );
  }
}

03 で messages にメッセージを追加した時と同様に setState でくくるのを忘れないようにしましょう。

これで _privKey に秘密鍵を登録できるようになりました。

04-02-02: メッセージを書ける

同じ要領でメッセージも書けるようにします。
2つ TextField が並ぶのでどっちがどっちかわかるように Text で書いておきましょう。

class _MessageSendPageState extends State<MessageSendPage> {
  String _text = ''; // メッセージ
  String _privKey = ''; // 秘密鍵

  _MessageSendPageState();

  // テキストフィールドと _text を合わせる
  void _handleText(String e) {
    setState(() {
      _text = e;
    });
  }

  // テキストフィールドと _privKey を合わせる
  void _handleKey(String e) {
    setState(() {
      _privKey = e;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('メッセージ送信'),
      ),
      body: Column(
        children: [
          Container(
            padding: const EdgeInsets.all(20),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text(
                  '秘密鍵',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                TextField(
                  enabled: true,
                  onChanged: _handleKey,
                ),
              ],
            ),
          ),
          Container(
            padding: const EdgeInsets.all(20),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text(
                  'メッセージ',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                TextField(
                  enabled: true,
                  onChanged: _handleText,
                  minLines: 3,
                  maxLines: 10,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

こんな感じ

これで、_text にメッセージが、_privKey に秘密鍵を書けるようになりました。

04-02-03: メッセージを送信できる

最後に入力した秘密鍵とメッセージでメッセージの送信ができるようにします。

Nostr でメッセージを送信するには EVENT を作成してリレーサーバに送信する必要があります。
ただ、 nostr パッケージのおかげでサクッとできてしまします。

  1. NostrWidget から channel を受け取る
  2. EVENT を作成
  3. channelEVENT を追加する

という手順で進めます。

まずは、NostrWidget から channel を受け取ります。
MessageSendPagechannel を追加して受け取りの準備をします。

import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; // <== 追加

class MessageSendPage extends StatefulWidget {
  WebSocketChannel channel; // <====== 追加
  MessageSendPage(this.channel, {super.key}); // <== const が取れています

  
  _MessageSendPageState createState() => _MessageSendPageState(channel);
}

class _MessageSendPageState extends State<MessageSendPage> {
  WebSocketChannel channel; // <===== 追加

  _MessageSendPageState(this.channel);
  // ...略

次に NostrWidget 側から MessageSendPagechannel を渡します。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('これからすごくなる SNS アプリ')),
      body: Center(
        child: ListView.builder(
          itemCount: messages.length,
          itemBuilder: (context, index) {
            return messageWidget(index);
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => MessageSendPage(channel), // channel を渡す
              fullscreenDialog: true,
            ),
          );
        },
        child: const Icon(Icons.add),
      ),
    );
  }

これで無事 NostrWidget から MessageSendPagechannel が渡せました。

次に、Event の作成から channel への追加を行います。
_MessageSendPageState 内に以下のような _sendMessage を実装します。

  // メッセージを送信する
  void _sendMessage() {
    if (_privKey == '' && _text == '') return; // 鍵かテキストが空欄なら送信しない
    Event event = Event.from(
      kind: 1,
      content: _text,
      privkey: _privKey,
      tags: [],
    ); // Event の作成
    channel.sink.add(event.serialize()); // イベントを追加する。
  }

あとはこの _sendMessage を呼び出せばメッセージが送信されます。
今回は画面右上に送信ボタンを配置しようと思います。
AppBaractions でとても簡単に実装できます。

  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('メッセージ送信'),
        actions: [
          // メッセージの送信ボタン
          Container(
            padding: const EdgeInsets.only(right: 10),
            child: IconButton(
              icon: const Icon(Icons.send),
              onPressed: () {
                _sendMessage();
              },
            ),
          ),
        ],
      ),
      // ...略

これでそれっぽくなりました(????)

秘密鍵と内容を入れて実際にメッセージが送信できるかみてみましょう。
(注! 何があっても送信ボタンを連打しないほうがいいです!)

...

...

送信できたんでしょうか...?
そうなんです。メッセージの流れが早すぎて確認できないんです。

秘密鍵が間違っていなければ送信はできているはずです。
他の Nostr クライアントで確認していただければわかるとはおもいます。

機能要件の

  • テキストでの投稿ができる
  • 他人の投稿を見ることができる

の 2 つが達成できたので本編はここまでです。
まとめの後におまけとして、自分の投稿だけを表示するページを作成します。

まとめ(?)

今回は Flutter x Nostr で sns アプリっぽいものを作成しました。
できるだけ初学者でもわかるように詳しく書いたつもりです。(そもそも自分も上級者ではない かつ Zenn 初心者なのでわかりづらい点等あったら教えていただける助かります!)

今回のアプリの問題点は結構あります。
簡単にあげるだけでも

  • メッセージを追加してから全体をソートするので時間が経つと重くなる
    • web で開くとすでにめっちゃ重いです。
  • 秘密鍵が丸見え
  • プロフィールが適当
  • 無限にメッセージを読み込みつづけてしまうので1つ1つのメッセージが読めない。
  • 機能が足らなすぎる
    • いいね
    • りぷらい
    • フォロー
    • etc...

とたくさんでてきます。特に機能が圧倒的に足りていないのが問題ですね。
せっかくなので、このまま開発を続けてみたいと思っています(現状は)
開発が進むにつれて Zenn の記事も増やせるといいなぁ。

ただ、 Nostr への理解も深めないとこのアプリに未来はないのかなと。
Nostr がどこまで生き残るかにもよるんですかね。

参考

https://pub.dev/packages/nostr

https://docs.flutter.dev/cookbook/networking/web-sockets

おまけ①: 自分の投稿のみをみるページを追加する

Twitter でいうプロフィールページのように自身の投稿のみを見れるページを追加します。
NostrWidget 内で 2 つの接続経路・メッセージの List を持つようにしましょう。

final myChannel = WebSocketChannel.connect(Uri.parse('wss://relay.damus.io'));
final List<Map<String, dynamic>> myMessages = [];

を追加します。

また、initState 内で myChannellisten も定義してあげましょう。
ほぼ 03 の時と同じですが、条件に authers が追加されています。

void initState() {
// 略...
    const privKey = "<your private key>"; // <=== ここが大きな変更点。自分の秘密鍵を指定してください。
    final keys = Keychain(privKey); // <=== ここが大きな変更点。
    Request myRequestWithFilter = Request(generate64RandomHexChars(), [
      Filter(
        kinds: [1],
        limit: 50,
        authors: [keys.public], // <=== ここが大きな変更点。
      )
    ]);
    myChannel.sink.add(myRequestWithFilter.serialize());
    myChannel.stream.listen((payload) {
      try {
        final _msg = Message.deserialize(payload);
        if (_msg.type == 'EVENT') {
          setState(() {
            myMessages.add({
              "createdAt": _msg.message.createdAt,
              "content": _msg.message.content
            });
            myMessages.sort((a, b) {
              return b['createdAt'].compareTo(a['createdAt']);
            });
          });
        }
      } catch (err) {}
    });
// 略...

これで、自分のメッセージを受信するところまで OK です。

ページの切り替えには TabView を使います。
そのために、 messageWidget が引数として messages もしくは myMessages を受け取れるように変更します。

Widget messageWidget(List<Map<String, dynamic>> messages, int index) {
// 略...

引数に List<Map<String, dynamic>> messages を追加しただけです。

それではいよいよ TabView の追加です。

  
  Widget build(BuildContext context) {
    return DefaultTabController( // <==== ①
      length: 2,
      child: Scaffold(
        appBar: AppBar(
            title: const Text('これからすごくなる SNS アプリ'),
            bottom: const TabBar( // <==== ②
              tabs: [
                Tab(icon: Icon(Icons.person)),
                Tab(icon: Icon(Icons.search)),
              ],
            )),
        body: TabBarView(children: [ // <===== ③
          Center(
            child: ListView.builder(
              itemCount: myMessages.length,
              itemBuilder: (context, index) {
                return messageWidget(myMessages, index); // <===== ④
              },
            ),
          ),
          Center(
            child: ListView.builder(
              itemCount: messages.length,
              itemBuilder: (context, index) {
                return messageWidget(messages, index);
              },
            ),
          ),
        ]),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => MessageSendPage(channel),
                fullscreenDialog: true,
              ),
            );
          },
          child: const Icon(Icons.add),
        ),
      ),
    );
  }

これで

こんな感じでタブが表示でき、タブを切り替えると自分の投稿と他人の投稿のリストが表示されると思います。

それぞれ何をしたかというと、

  • ①: TabView の親玉である DefaultTabController で全体を括ります。ここで、タブの個数を指定します。今回は、自分のリストと他人のリストの 2 つですね
  • ②: タブに表示する Widget の指定です。
  • ③: タブを切り替えた時に実際に表示される Widget です。②での指定と同じ順番の Widget が表示されます。
  • ④: 先ほど変更した messageWidget に渡すリストを変えることで表示が自分のメッセージなのか他人のメッセージなのかを制御しています。

これで自分のメッセージも見れるようになりました!

Discussion