Flutterで簡単なMisskeyクライアントを作ろう
はじめに
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
で参照することができます。
Misskey本体のソースコードについて
基本的には先ほど紹介したAPIドキュメントでなんとかなることが多いのですが、
Streaming API周りはドキュメントが整備されておらず、
自分でコードを読んで解析したり、ブラウザのデベロッパーツールを見て調査をする必要があります。
またドキュメントも完璧ではないため、思った通りの動作をしないときはコードを読みにいくのが手っ取り早いです。
バックエンド周りだとこの辺を読むと参考になります。
使用する技術
- Flutter
- Dio
- Retrofit
- freezed
- Riverpod
- go_router
プロジェクト作成
私は普段Android Studioを使用しているので、
Android Studioを用いて解説しますが、お好みのエディター or IDEを使ってください。
flutterバージョン: 3.3.9
プロジェクト名:misskey_client_example
mainファイルをスッキリさせる
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"),
);
}
}
必要なライブラリを導入する
下記ライブラリをドキュメントに従い導入してください。
認可機能を作成する
Id & Password認証にして、そこから得られるTokenを使ってしまってもよかったのですが、
非公式サービスからこの方法を使ってTokenを取得するのは非推奨なのと、
一般的にはMiAuthが使われること前提になっているのでMiAuthを使います。
リクエストの形式
以下のようにして認証のための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ファイルを作成して、
下記のような画面を作成してください。
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
ファイルを作成します。
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の抽象クラスを作成します。
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関数を下記のように変更してください。
/// ユーザーが認可するのを待ち受ける
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の取得と保存を行うようにしました。
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を保存するようにします。
/// ユーザーが認可するのを待ち受ける
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の実装を行う
リクエスト用のオブジェクトを作成する
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
などのプロフィール画面で表示時に取得される情報と、
タイムラインなどのノートの含まれる情報量に違いがあります。
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のデータに対応するオブジェクトです。
今回は省略しましたが、実際には添付したファイルのデータの配列や、投票のデータなども持っています。
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にタイムライン取得用のエンドポイントを追加定義する
()
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を使ってタイムラインの取得結果を保持するような実装にしました。
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で表示していますが、気に入らなければ他のスタイルを使ってください。
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 ?? '',
),
],
))
],
),
));
}
}
タイムライン画面を実装する
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というライブラリを使って画面遷移の実装を行います。
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に実装済みなので、画面周り以外で変更することはありません。
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を含めるようにして送信します。
リクエスト用のオブジェクトを作成する
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);
}
レスポンス用のオブジェクトを作成する
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にノート作成用のメソッドを定義する
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を呼び出すようにしてください。
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にノート作成画面のナビゲーションを定義します。
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),
);
}
}
最後にタイムライン一覧画面にノート一覧画面に遷移するためのボタンを追加します。
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リクエストすれば大体なんとかなる
制作物のソースコード
Discussion