😎

Flutterで簡単なMisskeyクライアントを作ろう

2022/12/13に公開

はじめに

Misskeyアドベントカレンダー2022年の13日を担当するパン太です。
自己紹介としては、Misskeyの非公式AndroidクライアントであるMilkteaの開発をおこなっています。
今までの経験を用いてFlutter + Misskey APIを用いた簡単なクライアントアプリケーションの作り方を記事にします。
これを機にMisskey APIに慣れ親しんでもらいMisskeyを盛り上げる一員になってもらえれば幸いです。
また今回のサンプルプログラムはFlutterのコードとしてはいまいちな部分があり、
うまく動作させることができた方は、そういった点をリファクタリングしてもらったり、
追加で機能を実装してもらって楽しんでもらえると幸いです。

必要な知識

  • HTTPに対しての簡単な知識(POSTがなんであるか、JSONなんであるかなど)
  • Flutterに対しての簡単な知識
    • Todoアプリやメモ帳などを作れるレベル
  • Misskeyに対しての知識(タイムラインやリアクション、ノートがなんであるかを理解していること)

作るものの概要

Streaming API(リアルタイムでイベントを受信する仕組み)を用いてリアルタイムでタイムラインを更新したかったのですが、することが多くなりすぎてしまうため、今回はしません。
またCWや画像の表示、カスタム絵文字の表示はしないこととします。

  • MiAuthを使って認証をすることができる
  • 認証に成功したらタイムラインをロードして画面上に表示できる
    • 表示する内容
      • ユーザーのアバター
      • ユーザーの名称とId
      • 本文
  • ページネーションをすることができ、過去の投稿を表示することができる
  • 本文を投稿することができる
タイムライン画面 投稿画面 認証画面

Misskey APIの特徴やドキュメントなど

特徴&印象

Misskey APIは一般的に公開されているようなAPIとは少し違って、
リクエストは全てPOSTで受け取るような仕組みになっています。
またユーザーを認可するためのTokenもHeaderではなく全てリクエストBodyに含めるような仕組みになっています。

ドキュメントなど

Misskey APIのドキュメントは
インスタンスのホスト/api-docで参照することができます。
https://misskey.io/api-doc
https://misskey.m544.net/api-doc

Misskey本体のソースコードについて

基本的には先ほど紹介したAPIドキュメントでなんとかなることが多いのですが、
Streaming API周りはドキュメントが整備されておらず、
自分でコードを読んで解析したり、ブラウザのデベロッパーツールを見て調査をする必要があります。
またドキュメントも完璧ではないため、思った通りの動作をしないときはコードを読みにいくのが手っ取り早いです。
https://github.com/misskey-dev/misskey

バックエンド周りだとこの辺を読むと参考になります。
https://github.com/misskey-dev/misskey/tree/develop/packages/backend
https://github.com/misskey-dev/misskey/tree/develop/packages/backend/src/server/api/endpoints

使用する技術

  • Flutter
    • Dio
    • Retrofit
    • freezed
    • Riverpod
    • go_router

プロジェクト作成

私は普段Android Studioを使用しているので、
Android Studioを用いて解説しますが、お好みのエディター or IDEを使ってください。
flutterバージョン: 3.3.9
プロジェクト名:misskey_client_example

mainファイルをスッキリさせる

lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const Text("hoge"),
    );
  }
}

必要なライブラリを導入する

下記ライブラリをドキュメントに従い導入してください。
https://pub.dev/packages/flutter_riverpod
https://pub.dev/packages/freezed
https://pub.dev/packages/retrofit
https://pub.dev/packages/go_router
https://pub.dev/packages/uuid
https://pub.dev/packages/url_launcher
https://pub.dev/packages/shared_preferences

認可機能を作成する

Id & Password認証にして、そこから得られるTokenを使ってしまってもよかったのですが、
非公式サービスからこの方法を使ってTokenを取得するのは非推奨なのと、
一般的にはMiAuthが使われること前提になっているのでMiAuthを使います。
https://misskey-hub.net/docs/api/

リクエストの形式

以下のようにして認証のためのURLを生成します。
ユーザーに生成したらURLにアクセスしてもらい、許可を押してもらうことによって、
APIにアクセスする用のTokenを得ることができます。
乱数は必ずUUIDなどの予測不能かつ重複が発生する確率の低いものを用いて生成してください。
https://{ホスト名}/miauth/乱数 + アプリ名 + 必要なパーミッション + 認証成功時のコールバック先のURL

https://misskey.io/miauth/9f115943-72c4-db32-3d8c-8c73dd9282a8?name=FlutterMisskeyApp&permission=read:account,write:account&callback=http://localhost

Tokenの取得

https://{ホスト名}/api/miauth/{乱数}/checkにPOSTを送信すると、
ユーザーが許可ボタンを押した場合は下記のようなレスポンスが帰ってきます。
また以後APIのアクセスTokenにはtoken: "乱数"を使用します。

{
    "ok": true,
    "token": "乱数",
    "user": {
        "id": "7slno9u6re",
        "name": "パン太はだれが何を言おうとも食パンじゃないよBot",
        "username": "PantaDev",
        "host": null,
        "avatarUrl": "https://misskey.io/identicon/7slno9u6re",
        "avatarBlurhash": null,
        "avatarColor": null,
        "isAdmin": false,
        "isModerator": false,
        "isBot": true,
        "isCat": false,
        "emojis": [],
        "onlineStatus": "online",
        "driveCapacityOverrideMb": null,
        "url": null,
        "uri": null,
        "createdAt": "2019-05-10T13:03:27.438Z",
        "updatedAt": "2022-11-18T10:26:08.335Z",
        "lastFetchedAt": null,
        "bannerUrl": null,
        "bannerBlurhash": null,
        "bannerColor": null,
        "isLocked": false,
        "isSilenced": false,
        "isSuspended": false,
        "description": null,
        "location": null,
        "birthday": null,
        "lang": null,
        "fields": [],
        "followersCount": 4,
        "followingCount": 3,
        "notesCount": 46,
        "pinnedNoteIds": [],
        "pinnedNotes": [],
        "pinnedPageId": null,
        "pinnedPage": null,
        "publicReactions": false,
        "ffVisibility": "public",
        "twoFactorEnabled": false,
        "usePasswordLessLogin": false,
        "securityKeys": false
    }
}

まだ許可をしていない、あるいは許可をしなかった場合には以下のようなレスポンスが帰ってきます。

{
    "ok": false
}

実際に実装する

認証ページに遷移できるようにする

新たにauth_screen.dartファイルを作成して、
下記のような画面を作成してください。

lib/auth_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart';

class AuthScreen extends ConsumerStatefulWidget {
  const AuthScreen({super.key});

  
  ConsumerState<ConsumerStatefulWidget> createState() {
    return _AuthScreenState();
  }
}

class _AuthScreenState extends ConsumerState<AuthScreen> {
  /// 接続先をユーザーの任意で書き換えられるようにしたい場合は、
  /// TextFieldなどを使ってこの値を書き換えてください。
  String baseUrl = "https://misskey.pantasystem.com";
  AuthStateType authStateType = AuthStateType.fixed;

  
  void dispose() {
    authStateType = AuthStateType.fixed;
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("認証"),
      ),
      body: Center(
        child: TextButton(
          child: const Text("認証する"),
          onPressed: () {
            _startAuth();
          },
        ),
      ),
    );
  }

  /// 認証先のURLを生成してブラウザへ遷移する
  void _startAuth() {
    final session = _generateSession();

    final url = _getUrl(
        session: session,
        baseUrl: baseUrl,
        appName: 'FlutterMisskeyApp',
        permissions: ["read:account", "write:account", "write:notes"]);

    launch(url);
    setState(() {
      authStateType = AuthStateType.waiting4Approve;
    });
    _waitingAuth(baseUrl: baseUrl, sessionId: session);
  }

  /// ユーザーが認可するのを待ち受ける
  Future<void> _waitingAuth(
      {required String baseUrl, required String sessionId}) async {
    while (authStateType == AuthStateType.waiting4Approve) {
      // userが許可を押したのかをチェックする。
      await Future.delayed(const Duration(milliseconds: 3000));
    }
  }

  /// パラメーターを元に認証先のURLを生成する
  String _getUrl(
      {required String session,
      required String baseUrl,
      required String appName,
      required List<String> permissions}) {
    String permission = '';
    for (int i = 0; i < permissions.length; i++) {
      if (i == 0) {
        permission = permissions[i];
      } else {
        permission = '$permission,${permissions[i]}';
      }
    }

    return "$baseUrl/miauth/$session?name=$appName&permission=$permission";
  }

  /// セッションを生成する
  String _generateSession() {
    return const Uuid().v4();
  }
}

enum AuthStateType { fixed, waiting4Approve, success }

認証の状態をチェックする

定期的にエンドポイント/api/miauth/{セッションId}/checkを叩いて、ユーザーが許可を押したのかを確認しに行くようにします。
今回はAPIを叩くのにRetrofitというライブラリを使用します。
Retrofitは抽象クラスと必要なアノテーションを付与するだけで、いい感じのAPIを叩くための実装クラスを生成してくれるので便利で良いです。
最近だとOpenAPIでいい感じにできたりするので使わなくても良いケースも多々あるのですが

まずレスポンスのJSONをオブジェクトに変換する先を作りたいので、
lib/api/dto配下にcheck_auth_response.dartファイルを作成します。

lib/api/dto
import 'package:freezed_annotation/freezed_annotation.dart';

part 'check_auth_response.freezed.dart';
part 'check_auth_response.g.dart';


class CheckAuthResponse with _$CheckAuthResponse {
  factory CheckAuthResponse({required bool ok, String? token}) =
      _CheckAuthResponse;

  factory CheckAuthResponse.fromJson(Map<String, dynamic> json) =>
      _$CheckAuthResponseFromJson(json);
}

続いてAPIにアクセスするためのRetrofitの抽象クラスを作成します。

lib/api/misskey_api.dart
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:misskey_client_example/api/dto/check_auth_response.dart';
import 'package:retrofit/retrofit.dart';

part 'misskey_api.g.dart';

()
abstract class MisskeyApi {
  factory MisskeyApi(Dio dio, {String baseUrl}) = _MisskeyApi;
  ("/api/miauth/{session}/check")
  Future<CheckAuthResponse> checkAuth(() String session);
}

class MisskeyApiFactory {
  MisskeyApi create(String baseUrl) {
    return MisskeyApi(Dio(), baseUrl: baseUrl);
  }
}

final misskeyApiFactoryProvider = Provider((ref) {
  return MisskeyApiFactory();
});

下記のコードをターミナルで実行してください。
先ほど作成した2つのファイルに基づいて必要なコードが自動生成されます。

flutter pub run build_runner build

画面に繋ぎ込む

auth_screen.dartの_waitingAuth関数を下記のように変更してください。

auth_screen.dart
  /// ユーザーが認可するのを待ち受ける
  Future<void> _waitingAuth(
      {required String baseUrl, required String sessionId}) async {
    while (authStateType == AuthStateType.waiting4Approve) {
      final res = await ref
          .read(misskeyApiFactoryProvider)
          .create(baseUrl)
          .checkAuth(sessionId);
      if (res.ok) {
        setState(() {
          authStateType = AuthStateType.success;
        });
        return;
      }
      // userが許可を押したのかをチェックする。
      await Future.delayed(const Duration(milliseconds: 3000));
    }
  }

Tokenと接続先の情報を保存できるようにする

Tokenを保存するためのクラスを作成します。
Tokenの保存にはSharedPreferencesを使います。
Token保存用のTokenServiceというクラスを作成してそこでTokenの取得と保存を行うようにしました。

lib/auth_service.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

class AuthService {
  Future<void> saveToken({required String? token}) async {
    (await SharedPreferences.getInstance()).setString("token", token ?? '');
  }

  Future<String?> getToken() async {
    return (await SharedPreferences.getInstance()).getString("token");
  }
}

final authServiceProvider = Provider((ref) {
  return AuthService();
});

取得したTokenを保存する

AuthScreenの_watingAuthの中を書き換えて、取得したTokenを保存するようにします。

lib/auth_screen.dart
  /// ユーザーが認可するのを待ち受ける
  Future<void> _waitingAuth(
      {required String baseUrl, required String sessionId}) async {
    while (authStateType == AuthStateType.waiting4Approve) {
      final res = await ref
          .read(misskeyApiFactoryProvider)
          .create(baseUrl)
          .checkAuth(sessionId);
      if (res.ok) {
        // 取得したTokenを保存する
        await ref.read(authServiceProvider).saveToken(token: res.token);
        log('取得したToken: ${res.token}');
        setState(() {
          authStateType = AuthStateType.success;
        });
        return;
      }
      // userが許可を押したのかをチェックする。
      await Future.delayed(const Duration(milliseconds: 3000));
    }
  }

Tokenを検証する

Tokenが動作するか心配な場合はcurlやPostmanなどでテストしてみてください。
うまくいけば認証した時のアカウントの情報が返ってくるはずです。

curl --location --request POST 'https://{インスタンスのホスト}/api/i' \
--header 'Content-Type: application/json' \
--data-raw '{
    "i": "取得したToken "
}'

タイムラインを取得する

今回はソーシャルタイムラインを取得できるようにします。

APIの実装を行う

リクエスト用のオブジェクトを作成する

lib/api/dto/timeline_request.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'timeline_request.freezed.dart';

part 'timeline_request.g.dart';


class TimelineRequest with _$TimelineRequest {
  factory TimelineRequest({
    required String? i,
    (name: 'sinceId', disallowNullValue: true) String? sinceId,
    (name: 'untilId', disallowNullValue: true) String? untilId,
  }) = _TimelineRequest;

  factory TimelineRequest.fromJson(Map<String, dynamic> json) =>
      _$TimelineRequestFromJson(json);
}

レスポンス用のオブジェクトを作成する

下記のコードはユーザーのデータのJSONに対応したオブジェクトです。
ユーザーデータの注意点として、api/users/showなどのプロフィール画面で表示時に取得される情報と、
タイムラインなどのノートの含まれる情報量に違いがあります。

lib/api/dto/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

import 'note.dart';

part 'user.freezed.dart';
part 'user.g.dart';


class User with _$User {
  User._();

  factory User({
    required String id,
    required String username,
    required String? name,
    required String? url,
    required String? avatarUrl,
    required String? avatarBlurhash,
    required String? bannerUrl,
    required String? bannerBlurhash,
    required String? description,
    required DateTime? birthday,
    required DateTime? createdAt,
    required DateTime? updatedAt,
    required String? location,
    required int? followersCount,
    required int? followingCount,
    required int? notesCount,
    required bool? isCat,
    required bool? isBot,
    required bool? isAdmin,
    required bool? isModerator,
    required bool? isLocked,
    required bool? hasUnreadSpecifiedNotes,
    required bool? hasUnreadMentions,
    required List<String>? pinnedNoteIds,
    required List<Note>? pinnedNotes,
    required String? host,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

以下のコードはノートのJSONのデータに対応するオブジェクトです。
今回は省略しましたが、実際には添付したファイルのデータの配列や、投票のデータなども持っています。

lib/api/dto/note.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:misskey_client_example/api/dto/user.dart';

part 'note.freezed.dart';
part 'note.g.dart';


class Note with _$Note {
  Note._();

  factory Note({
    required String id,
    required String? text,
    required String? cw,
    required String userId,
    String? replyId,
    String? renoteId,
    required bool? viaMobile,
    required User user,
    required String visibility,
    required DateTime createdAt,
    Map<String, int>? reactions,
    bool? localOnly,
    int? renoteCount,
    int? replyCount,
    Note? reply,
    Note? renote,
    List<String>? fileIds,
    String? myReaction,
  }) = _Note;

  factory Note.fromJson(Map<String, dynamic> json) => _$NoteFromJson(json);
}

コードを生成する

ターミナルで下記のコマンドを実行してコードを生成してください。

flutter pub run build_runner build

Retrofitにタイムライン取得用のエンドポイントを追加定義する

lib/api/misskey_api.servce
()
abstract class MisskeyApi {
  factory MisskeyApi(Dio dio, {String baseUrl}) = _MisskeyApi;
  ("/api/miauth/{session}/check")
  Future<CheckAuthResponse> checkAuth(() String session);

  ("/api/notes/hybrid-timeline")
  Future<List<Note>> getHybridTimeline(() TimelineRequest request);
}

投稿を取得してその内容を保持するための仕組みを実装する

ChangeNotifierを使ってタイムラインの取得結果を保持するような実装にしました。

lib/timeline_notifier.dart
import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:misskey_client_example/api/constants.dart';
import 'package:misskey_client_example/api/dto/note.dart';
import 'package:misskey_client_example/api/dto/timeline_request.dart';
import 'package:misskey_client_example/api/misskey_api.dart';
import 'package:misskey_client_example/auth_service.dart';

class TimelineNotifier extends ChangeNotifier {
  TimelineNotifier({required this.apiFactory, required this.authService});

  final MisskeyApiFactory apiFactory;
  final AuthService authService;

  List<Note> _notes = [];

  List<Note> get notes => _notes;

  bool _isLoading = false;

  Future<void> fetchNext() async {
    // ローディング中であればキャンセルする
    if (_isLoading) {
      return;
    }
    // ローディング中にする
    _isLoading = true;
    try {
      // タイムラインを取得する
      // untilIdを指定すると指定したNoteのIdより古いタイムラインを取得することができる。
      // 既に投稿が取得済みの場合はuntilIdにNoteのidが入りページネーションができる。
      final res = await apiFactory.create(baseUrl).getHybridTimeline(
          TimelineRequest(
              i: await authService.getToken(), untilId: _notes.lastOrNull?.id));
      _notes = _notes + res;
    } finally {
      // ローディング状態を終わらせ更新状態を通知する。
      _isLoading = false;
      notifyListeners();
    }
  }

  void clear() {
    _isLoading = false;
    _notes = [];
    notifyListeners();
  }
}

final timelineNotifierProvider = ChangeNotifierProvider((ref) {
  return TimelineNotifier(
      apiFactory: ref.read(misskeyApiFactoryProvider),
      authService: ref.read(authServiceProvider));
});

画面を作成する

表示する項目はできるだけ単純にします。
今回この記事ではここまでにしますが、
CWや投票や添付したドライブのファイルなども一緒にJSONの中に入って返ってくるので、
余裕のある方は是非実装してみてください。
また投稿の内容がRenoteの場合は本文もPollも添付ファイルも空になるので一工夫しないと投稿の中身が空になってしまうので、そういった点も調節してみると面白いかもしれません。

  • アバター
  • ユーザー名
  • 本文

なんとなくCardで表示していますが、気に入らなければ他のスタイルを使ってください。

lib/note_card.dart
import 'package:flutter/material.dart';
import 'package:misskey_client_example/api/dto/note.dart';

class NoteCard extends StatelessWidget {
  const NoteCard(this.note, {super.key});

  final Note note;

  
  Widget build(BuildContext context) {
    return Card(
        child: Container(
      padding: const EdgeInsets.all(16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.start,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            width: 56.0,
            height: 56.0,
            decoration: BoxDecoration(
                shape: BoxShape.circle,
                image: DecorationImage(
                    fit: BoxFit.fill,
                    image: NetworkImage(note.user.avatarUrl ?? ''))),
          ),
          const SizedBox(width: 4),
          Expanded(
              child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  if (note.user.name != null)
                    Text(
                      note.user.name!,
                      style: const TextStyle(fontWeight: FontWeight.bold),
                    ),
                  Text(note.user.username)
                ],
              ),
              Text(
                note.text ?? '',
              ),
            ],
          ))
        ],
      ),
    ));
  }
}


タイムライン画面を実装する

lib/timeline_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:misskey_client_example/note_card.dart';
import 'package:misskey_client_example/timeline_notifier.dart';

final timelineInitialLoadProvider =
    FutureProvider((ref) => ref.read(timelineNotifierProvider).fetchNext());

class TimelineScreen extends ConsumerWidget {
  const TimelineScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(timelineInitialLoadProvider);
    final timelineNotifier = ref.watch(timelineNotifierProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('ソーシャルタイムライン'),
        actions: [
          IconButton(
              onPressed: () {
                GoRouter.of(context).push('/auth');
              },
              icon: const Icon(Icons.login))
        ],
      ),
      body: ListView.builder(
        itemCount: timelineNotifier.notes.length,
        itemBuilder: (BuildContext context, int index) {
          return NoteCard(timelineNotifier.notes[index]);
        },
      ),
    );
  }
}

画面遷移を実装する

go_routerというライブラリを使って画面遷移の実装を行います。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:misskey_client_example/auth_screen.dart';
import 'package:misskey_client_example/timeline_screen.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

final _router = Provider((ref) {
  return GoRouter(routes: [
    GoRoute(
      path: '/auth',
      builder: (BuildContext context, GoRouterState state) {
        return const AuthScreen();
      },
    ),
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return const TimelineScreen();
      },
    ),
  ]);
});

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp.router(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routerConfig: ref.read(_router),
    );
  }
}

本来はログイン状態を確認して認証画面 or タイムライン画面に遷移するのが正しい在り方だと思うのですが、
今回はそこは本質ではないので、タイムライン画面に認証ボタンを配置しました。

ページネーションをする

Flutterでのページネーション(無限スクロール)の方法はいくつかあるのですが、
今回はListViewの末端のWidgetが表示されたタイミングでリビルドが走る仕組みを応用して、
リビルドが走った瞬間、次のタイムラインを読み取るようにします。
ページネーションのロジックはTimelineNotifierに実装済みなので、画面周り以外で変更することはありません。

lib/timeline_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:misskey_client_example/note_card.dart';
import 'package:misskey_client_example/timeline_notifier.dart';

final timelineInitialLoadProvider =
    FutureProvider((ref) => ref.read(timelineNotifierProvider).fetchNext());

class TimelineScreen extends ConsumerWidget {
  const TimelineScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(timelineInitialLoadProvider);
    final timelineNotifier = ref.watch(timelineNotifierProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('ソーシャルタイムライン'),
        actions: [
          IconButton(
              onPressed: () {
                GoRouter.of(context).push('/auth');
              },
              icon: const Icon(Icons.login))
        ],
      ),
      body: ListView.builder(
        itemCount: timelineNotifier.notes.length,
        itemBuilder: (BuildContext context, int index) {
          if (index == timelineNotifier.notes.length - 1) {
            timelineNotifier.fetchNext();
          }
          return NoteCard(timelineNotifier.notes[index]);
        },
      ),
    );
  }
}

投稿できるようにする

今回は本文のみ投稿できるようにします。

APIに合わせRetrofitを定義する

ノートを作成するときは、
api/notes/createをPOSTリクエストでリクエストボディにJSONを含めるようにして送信します。

リクエスト用のオブジェクトを作成する

lib/api/dto/create_note_request.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'create_note_request.freezed.dart';
part 'create_note_request.g.dart';


class CreateNoteRequest with _$CreateNoteRequest {
  CreateNoteRequest._();
  factory CreateNoteRequest({
    required String? i,
    required String? text,
    required String visibility,
  }) = _CreateNoteRequest;
  factory CreateNoteRequest.fromJson(Map<String, dynamic> json) =>
      _$CreateNoteRequestFromJson(json);
}

レスポンス用のオブジェクトを作成する

lib/api/dto/create_note_response.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:misskey_client_example/api/dto/note.dart';

part 'create_note_response.freezed.dart';
part 'create_note_response.g.dart';


class CreateNoteResponse with _$CreateNoteResponse {
  CreateNoteResponse._();
  factory CreateNoteResponse({required Note createdNote}) = _CreateNoteResponse;

  factory CreateNoteResponse.fromJson(Map<String, dynamic> json) =>
      _$CreateNoteResponseFromJson(json);
}

Retrofitにノート作成用のメソッドを定義する

lib/api/misskey_api.dart
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:misskey_client_example/api/dto/check_auth_response.dart';
import 'package:misskey_client_example/api/dto/create_note_request.dart';
import 'package:misskey_client_example/api/dto/create_note_response.dart';
import 'package:misskey_client_example/api/dto/note.dart';
import 'package:misskey_client_example/api/dto/timeline_request.dart';
import 'package:retrofit/retrofit.dart';

part 'misskey_api.g.dart';

()
abstract class MisskeyApi {
  factory MisskeyApi(Dio dio, {String baseUrl}) = _MisskeyApi;
  ("/api/miauth/{session}/check")
  Future<CheckAuthResponse> checkAuth(() String session);

  ("/api/notes/hybrid-timeline")
  Future<List<Note>> getHybridTimeline(() TimelineRequest request);

  ("/api/notes/create")
  Future<CreateNoteResponse> createNote(() CreateNoteRequest request);
}

class MisskeyApiFactory {
  MisskeyApi create(String baseUrl) {
    return MisskeyApi(Dio(), baseUrl: baseUrl);
  }
}

final misskeyApiFactoryProvider = Provider((ref) {
  return MisskeyApiFactory();
});

コードを生成する

flutter pub run build_runner build

UIを作成する

TextFieldと投稿ボタンを配置して、
投稿ボタンを押した時にTextFieldの入力内容を取得して、
MisskeyAPIのcreateを呼び出すようにしてください。

lib/note_editor_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:misskey_client_example/note_card.dart';
import 'package:misskey_client_example/timeline_notifier.dart';

final timelineInitialLoadProvider =
    FutureProvider((ref) => ref.read(timelineNotifierProvider).fetchNext());

class TimelineScreen extends ConsumerWidget {
  const TimelineScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(timelineInitialLoadProvider);
    final timelineNotifier = ref.watch(timelineNotifierProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('ソーシャルタイムライン'),
        actions: [
          IconButton(
              onPressed: () {
                GoRouter.of(context).push('/auth');
              },
              icon: const Icon(Icons.login))
        ],
      ),
      body: ListView.builder(
        itemCount: timelineNotifier.notes.length,
        itemBuilder: (BuildContext context, int index) {
          if (index == timelineNotifier.notes.length - 1) {
            timelineNotifier.fetchNext();
          }
          return NoteCard(timelineNotifier.notes[index]);
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          GoRouter.of(context).push('/create-note');
        },
      ),
    );
  }
}

ノート編集画面に遷移するための実装を行う

GoRouterにノート作成画面のナビゲーションを定義します。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:misskey_client_example/api/note_editor_screen.dart';
import 'package:misskey_client_example/auth_screen.dart';
import 'package:misskey_client_example/timeline_screen.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

final _router = Provider((ref) {
  return GoRouter(routes: [
    GoRoute(
      path: '/auth',
      builder: (BuildContext context, GoRouterState state) {
        return const AuthScreen();
      },
    ),
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return const TimelineScreen();
      },
    ),
    GoRoute(
        path: '/create-note',
        builder: (BuildContext context, GoRouterState state) {
          return const NoteEditorScreen();
        })
  ]);
});

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp.router(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routerConfig: ref.read(_router),
    );
  }
}

最後にタイムライン一覧画面にノート一覧画面に遷移するためのボタンを追加します。

lib/timeline_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:misskey_client_example/note_card.dart';
import 'package:misskey_client_example/timeline_notifier.dart';

final timelineInitialLoadProvider =
    FutureProvider((ref) => ref.read(timelineNotifierProvider).fetchNext());

class TimelineScreen extends ConsumerWidget {
  const TimelineScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(timelineInitialLoadProvider);
    final timelineNotifier = ref.watch(timelineNotifierProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('ソーシャルタイムライン'),
        actions: [
          IconButton(
              onPressed: () {
                GoRouter.of(context).push('/auth');
              },
              icon: const Icon(Icons.login))
        ],
      ),
      body: ListView.builder(
        itemCount: timelineNotifier.notes.length,
        itemBuilder: (BuildContext context, int index) {
          if (index == timelineNotifier.notes.length - 1) {
            timelineNotifier.fetchNext();
          }
          return NoteCard(timelineNotifier.notes[index]);
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          GoRouter.of(context).push('/create-note');
        },
      ),
    );
  }
}

まとめ

  • サードパーティアプリケーションはmiauthを使ってTokenを取得する
  • Misskey APIはPOSTリクエストすれば大体なんとかなる

制作物のソースコード

https://github.com/pantasystem/misskey_client_example

Discussion