📱

FlutterでBlueskyのタイムラインを表示するアプリを作る

2023/04/29に公開

はじめに

FlutterでBlueskyのタイムライン最新20件を表示するアプリを作成します。
この記事は、入門編的な位置づけなので画像表示・リツイート・引用リツイートのなどの機能は省きます。

その辺りの細かい実装が知りたい人は、弊OSSの「Skyclad」のソースコードをご確認ください!!!

https://github.com/igz0/skyclad

作るもの

以下のようなアプリです。
FlutterアプリによるBlyeskyのタイムライン表示

必要なもの

  • Blueskyのアカウント
  • Flutterの開発環境

手順

1. flutter doctorの実行

flutter doctorを実行し、Flutterが正常動作することを確認してください。

❯ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.7.12, on macOS 13.3.1 22E261 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0-rc1)
[✓] Xcode - develop for iOS and macOS (Xcode 14.3)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2022.1)
[✓] VS Code (version 1.77.3)
[✓] Connected device (3 available)
[✓] HTTP Host Availability

• No issues found!

2. flutter createによるプロジェクトの作成

flutter createコマンドでbsky_cliというFlutterプロジェクトを作成します。

❯ flutter create bsky_cli
Signing iOS app for device deployment using developer identity: "Apple Development: foobar@example.com"
Creating project bsky_cli...
Running "flutter pub get" in bsky_cli...
Resolving dependencies in bsky_cli... (1.0s)
+ async 2.10.0 (2.11.0 available)
+ boolean_selector 2.1.1
+ characters 1.2.1 (1.3.0 available)
+ clock 1.1.1
+ collection 1.17.0 (1.17.1 available)
+ cupertino_icons 1.0.5
+ fake_async 1.3.1
+ flutter 0.0.0 from sdk flutter
+ flutter_lints 2.0.1
+ flutter_test 0.0.0 from sdk flutter
+ js 0.6.5 (0.6.7 available)
+ lints 2.0.1 (2.1.0 available)
+ matcher 0.12.13 (0.12.15 available)
+ material_color_utilities 0.2.0 (0.3.0 available)
+ meta 1.8.0 (1.9.1 available)
+ path 1.8.2 (1.8.3 available)
+ sky_engine 0.0.99 from sdk flutter
+ source_span 1.9.1 (1.10.0 available)
+ stack_trace 1.11.0
+ stream_channel 2.1.1
+ string_scanner 1.2.0
+ term_glyph 1.2.1
+ test_api 0.4.16 (0.5.2 available)
+ vector_math 2.1.4
Changed 24 dependencies in bsky_cli!
Wrote 127 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your application, type:

  $ cd bsky_cli
  $ flutter run

Your application code is in bsky_cli/lib/main.dart.

3. デモアプリの実行

flutter runコマンドでFlutterのデモアプリが実行できることを確認してください。

❯ cd bsky_cli
❯ flutter run
Using hardware rendering with device sdk gphone64 arm64. If you notice graphics artifacts, consider enabling software rendering with
"--enable-software-rendering".
Launching lib/main.dart on sdk gphone64 arm64 in debug mode...
Running Gradle task 'assembleDebug'...                             16.4s
✓  Built build/app/outputs/flutter-apk/app-debug.apk.
Installing build/app/outputs/flutter-apk/app-debug.apk...          546ms
Syncing files to device sdk gphone64 arm64...                      100ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

💪 Running with sound null safety 💪

An Observatory debugger and profiler on sdk gphone64 arm64 is available at: http://127.0.0.1:62403/O3dZpUz56-c=/
The Flutter DevTools debugger and profiler on sdk gphone64 arm64 is available at: http://127.0.0.1:9103?uri=http://127.0.0.1:62403/O3dZpUz56-c=/
E/SurfaceSyncer(10912): Failed to find sync for id=0
W/Parcel  (10912): Expecting binder but got null

エミュレーター等でFlutterのデモアプリが起動します。
アプリの起動を確認したら、ターミナルでCtrl+Cを使いアプリを停止させてください。

Flutterデモアプリ

4. パッケージの追加

タイムラインの表示用にパッケージを2つ追加します。

❯ flutter pub add bluesky
Resolving dependencies...
  async 2.10.0 (2.11.0 available)
+ at_identifier 0.0.3
+ at_uri 0.0.3
+ atproto 0.2.7
+ atproto_core 0.2.8
+ bluesky 0.4.0
  characters 1.2.1 (1.3.0 available)
  collection 1.17.0 (1.17.1 available)
+ freezed_annotation 2.2.0
+ http 0.13.5
+ http_parser 4.0.2
  js 0.6.5 (0.6.7 available)
+ json_annotation 4.8.0
  lints 2.0.1 (2.1.0 available)
  matcher 0.12.13 (0.12.15 available)
  material_color_utilities 0.2.0 (0.3.0 available)
  meta 1.8.0 (1.9.1 available)
+ mime 1.0.4
+ nsid 0.0.3
  path 1.8.2 (1.8.3 available)
  source_span 1.9.1 (1.10.0 available)
  test_api 0.4.16 (0.5.2 available)
+ typed_data 1.3.1
+ universal_io 2.2.0
+ xrpc 0.0.13
Changed 14 dependencies!

❯ flutter pub add timeago
Resolving dependencies...
  async 2.10.0 (2.11.0 available)
  characters 1.2.1 (1.3.0 available)
  collection 1.17.0 (1.17.1 available)
+ intl 0.18.1
  js 0.6.5 (0.6.7 available)
  lints 2.0.1 (2.1.0 available)
  matcher 0.12.13 (0.12.15 available)
  material_color_utilities 0.2.0 (0.3.0 available)
  meta 1.8.0 (1.9.1 available)
  path 1.8.2 (1.8.3 available)
  source_span 1.9.1 (1.10.0 available)
  test_api 0.4.16 (0.5.2 available)
+ timeago 3.4.0
Changed 2 dependencies!

それぞれ、Flutter用のBluskyパッケージと、時刻を絶対時間を相対時間に変換するためのパッケージになります。
特に、この記事はBlueskyのパッケージがなければ成立しませんでした。

https://pub.dev/packages/bluesky

https://pub.dev/packages/timeago

作成してくれた加藤 真也さんに感謝!!!

5. main.dartファイルの書き換え

Flutterプロジェクトのlib/main.dartを次のように書き換えてください。
コードの中のYOUR_HANDLE_OR_EMAILYOUR_PASSWORDは、それぞれ適切なものに置き換えてください。

ハンドルネームは「igz0.bsky.social」のような文字列、パスワードはアプリパスワードで十分です!!!

lib/main.dart
import 'package:flutter/material.dart';
import 'package:bluesky/bluesky.dart' as bsky;
import 'package:timeago/timeago.dart' as timeago;

void main() {
  timeago.setLocaleMessages("ja", timeago.JaMessages());
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bluesky Sample',
      home: Scaffold(
        appBar: AppBar(
          centerTitle: true,
          title: const Text('Timeline'),
          backgroundColor: Colors.blue[600],
        ),
        body: BlueskyTimeline(),
      ),
    );
  }
}

class BlueskyTimeline extends StatefulWidget {
  
  _BlueskyTimelineState createState() => _BlueskyTimelineState();
}

class _BlueskyTimelineState extends State<BlueskyTimeline> {
  late Future<List<dynamic>> _timelineFuture;

  
  void initState() {
    super.initState();
    _timelineFuture = _fetchTimeline();
  }

  Future<List<dynamic>> _fetchTimeline() async {
    final session = await bsky.createSession(
      identifier: 'YOUR_HANDLE_OR_EMAIL',
      password: 'YOUR_PASSWORD',
    );
    final bluesky = bsky.Bluesky.fromSession(session.data);
    final feeds = await bluesky.feeds.findTimeline(limit: 20);

    // Convert the feeds to JSON
    final jsonFeeds = feeds.data.toJson()['feed'];

    return jsonFeeds;
  }

  
  Widget build(BuildContext context) {
    return FutureBuilder<List<dynamic>>(
      future: _timelineFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasData) {
            return ListView.builder(
              itemCount: snapshot.data!.length,
              itemBuilder: (context, index) {
                final feed = snapshot.data![index];
                final post = feed['post'];
                final author = post['author'];
                final createdAt = DateTime.parse(post['indexedAt']).toLocal();

                return Column(children: [
                  InkWell(
                    onTap: () => print('Tapped!'),
                    child: Container(
                      padding: const EdgeInsets.all(8.0),
                      child: Row(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          CircleAvatar(
                            backgroundImage: NetworkImage(author['avatar']),
                            radius: 24,
                          ),
                          const SizedBox(width: 8.0),
                          Flexible(
                            child: Container(
                                child: Column(
                                    crossAxisAlignment:
                                        CrossAxisAlignment.start,
                                    children: [
                                  Row(
                                    mainAxisAlignment:
                                        MainAxisAlignment.spaceBetween,
                                    children: [
                                      Flexible(
                                        child: Text(
                                          author['displayName'],
                                          overflow: TextOverflow.ellipsis,
                                          style:
                                              const TextStyle(fontSize: 15.0),
                                        ),
                                      ),
                                      Flexible(
                                        child: Text(
                                          '@${author['handle']}',
                                          overflow: TextOverflow.ellipsis,
                                        ),
                                      ),
                                      Text(
                                        timeago.format(createdAt, locale: "ja"),
                                        style: const TextStyle(fontSize: 12.0),
                                        overflow: TextOverflow.clip,
                                      ),
                                    ],
                                  ),
                                  const SizedBox(height: 10.0),
                                  Text(post['record']['text'],
                                      style: const TextStyle(fontSize: 15.0)),
                                ])),
                          ),
                        ],
                      ),
                    ),
                  ),
                  const Divider(height: 1, thickness: 1, color: Colors.white12)
                ]);
              },
            );
          } else {
            return const Center(child: Text('タイムラインの取得に失敗しました'));
          }
        } else {
          return const Center(child: CircularProgressIndicator());
        }
      },
    );
  }
}

6. アプリの実行

flutter runコマンドで再度アプリを実行します。

flutter run

FlutterアプリによるBlyeskyのタイムライン表示

冒頭のアプリが表示されました!!
お疲れさまです!!!

最後に

Blueskyのパッケージはタイムラインの取得以外も様々な機能に対応しているので、ここから機能を拡張したい人は公式ドキュメントを参照してください。

※ この記事はFlutter初心者が書いているので内容に突っ込みどころなどあったら、遠慮なくご指摘ください。

Discussion