fusetterみたいなものをFlutterで作った話
車輪の再発明って本当に楽しいですね。Blueskyにはまだfusetterみたいなものがないので、画像のALTにネタバレを書けるfusetterみたいなものを作ってみました。
Flutterを書くのが2年ぶりでずいぶん回り道もしたので記録として書いておきます。
全ソースコードはGitHubにあります。https://github.com/eyasuyuki/bluespoiler
fusetterの伏せ字アルゴリズムを勝手に推測する
fusetterは[と]で括った部分が伏せ字になりますが、どういうアルゴリズムで動いているんでしょうか? fusetterのJavaScriptソースを盗み見てパクっても面白くないので勝手に推測することにしました。
まず開き括弧と閉じ括弧の出現位置を保存しておくことにします。
// pair of brackets
class Pair {
// field
int start = 0;
int end = 0;
// constructor
Pair({this.start = 0, this.end=0});
}
開き括弧と閉じ括弧の対応は、開き括弧の出現位置だけをスタックに積むことで解析します。
void setInput(String input) {
_input = input;
alt = [];
List<int> stack = []; // list of '[' positions
List<Pair> brackets = []; // list of bracket pairs
for (int i = 0; i < _input.length; i++) {
if (input[i] == '[') {
stack.add(i);
} else if (_input[i] == ']' && stack.isNotEmpty) {
int start = stack.last;
if (brackets.isNotEmpty) {
Pair prev = brackets.last;
if (start < prev.start && i > prev.end) {
brackets.removeLast();
}
}
Pair p = Pair(start: start, end: i);
brackets.add(p);
stack.removeLast();
}
}
閉じ括弧が出現したら、スタックからpopしてPairを生成してbracketsに追加します。この際、一つ前のPairと比較して外側の括弧Pairならひとつ前を削除する制御を行っています。
最終的にできたPair(括弧位置)の配列であるbracketsを使って平文文字列と伏せ字文字列を生成して行きます。
TextEditingControllerにRiverpodは使うな
当初状態管理はRiverpodで書こうとしていたのですが、最も苦労したのがTextFormFieldに入力された文字をリアルタイムでTextに表示してプレビューする部分。StreamProviderを使うべきなのかとか散々調べた結果、Riverpodの作者のRemiさんがTextEditingControllerにRiverpodは非推奨なのでStatefulWidgetかflutter_hooksを使えと言っているのを発見。
これを発見しなかったら何週間無駄にしたかと思うとゾッとします。結局HookWidgetでサクッと書けました。
flutter_loginは便利だが、そもそも画面遷移する必要あるの?
fusetterはTwitter APIで最初にアプリ連携してから記事を書くようになっているけども、BlueskyにはそういうものがないのでユーザーIDとパスワードでセッションを生成する必要があります。
final session = await bsky.createSession(
identifier: emailController.text,
password: passwordController.text
);
ユーザーIDとパスワードの入力画面が必要なので、当初は記事入力画面でPostボタンを押したらflutter_loginで書いたログイン画面に遷移するようにしていました。でも一晩寝て起きてみるとそもそも画面遷移って必要なの? という疑問が浮かんだのです。
最終的には同じ画面でメールアドレスとパスワードを入力させるように変更しました。
Blueskyへの投稿はとても簡単
Bluesky APIを利用するためにbluesky
パッケージを使用します。
flutter pub add bluesky
Blueskyへの投稿はこれだけ。
final session = await bsky.createSession(
identifier: emailController.text,
password: passwordController.text
);
final bluesky = bsky.Bluesky.fromSession(session.data);
final uploaded = await bluesky.repo.uploadBlob(imageBytes.value!);
final post = bluesky.feed.post(
text: body,
embed: bsky.Embed.images(
data: bsky.EmbedImages(
images: [
bsky.Image(
alt: alt,
image: uploaded.data.blob,
),
],
)
),
);
画像ファイル選択と表示
前項で画像を保持していたimageBytes
ですがこれはどうやって定義したのでしょうか。
HookWidget
のbuild
メソッドの最初で以下の定義を行なっています。
final imageBytes = useState<Uint8List?>(null);
画像ファイルの選択はimage_picker_web
を使います。
flutter pub add image_picker_web
画面側ではImagePickerWeb.getImageAsByte
を呼び出します。
Future<void> pickImage() async {
const maxImageSize = 999997;
try {
Uint8List? uint8list = await ImagePickerWeb.getImageAsBytes();
if (uint8list!.lengthInBytes > maxImageSize) { // 画像サイズチェック
ScaffoldMessenger.of(context).showSnackBar( // サイズ超過は警告
SnackBar(
content: Text(AppLocalizations.of(context)!.image_size_text),
backgroundColor: Colors.redAccent,
),
);
} else {
imageBytes.value = uint8list; // 選択された画像でimageBytesの値を更新
}
} catch (e) {
print(e);
}
}
maxImageSize
はBlueskyの画像の最大バイト数です。これを超える画像は使用できません。
画像ファイルのサイズをチェックし、これを超える場合はSnackBar
で警告します。
画面側ではボタンのonPressed
でpickImage
を呼び出すようにします。
ElevatedButton(
onPressed: () async {
await pickImage();
},
child: Text(AppLocalizations.of(context)!.image_button_text)
),
選択された画像をImage
に表示します。
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Image.memory(
imageBytes.value!,
width: 200,
height: 200,
),
CloseButton(onPressed: () {
imageBytes.value = null;
})
])
画像の横にCloseButton
を表示し、押されたらimageBytes
の値をクリアするようにしています。
これらウィジェットを組み合わせて、imageBytes
の値がnull
のときは画像選択ボタンを表示し、画像が選択されていたらその画像をImage
に表示するようにします。
imageBytes.value != null
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Image.memory(
imageBytes.value!,
width: 200,
height: 200,
),
CloseButton(onPressed: () {
imageBytes.value = null;
})
])
: ElevatedButton(
onPressed: () async {
await pickImage();
},
child: Text(AppLocalizations.of(context)!.image_button_text)),
Blueskyの記事ページURLを推定する
Blueskyeへのpost
の戻り値としてuriとcidが返ってきます。
{"uri":"at://did:plc:dptps7rgxju4nrg6qskop2wz/app.bsky.feed.post/3kjmvo7ocl62e","cid":"bafyreihxcb3n6hxucw3hdeb3kylhzkoumitkx4hygoy24tyxt4hemajdde"}
このuriはatUriというもので、ATプロトコルにおいて記事をユニークに指し示しています。Blueskyの記事のURLは次のようになります。
https://bsky.app/profile/javaopen.org/post/3kjmvo7ocl62e
profile
の次のパスjavaopen.org
は投稿者のハンドル、最後の3kjmvo7ocl62e
はatUriの最後のパスと一致しています。
atUriの最後のパスを得るためUri.parse
で解析するとportが不正だというエラーになります。
final uri = Uri.parse('at://did:plc:dptps7rgxju4nrg6qskop2wz/app.bsky.feed.post/3kjmvo7ocl62e');
Unhandled exception:
FormatException: Invalid port (at character 10)
at://did:plc:dptps7rgxju4nrg6qskop2wz/app.bsky.feed.post/3kjmvo7ocl62e
^
たしかにportっぽいものが数字じゃないのでUri.parse
は使えません。
他にも方法はあるのかも知れませんが、String.split
で'/'
を使って分割することにします。
final articleId = post.data.uri.href.split('/').last;
投稿者のハンドルはsession
から得られます。
再掲
final session = await bsky.createSession(
identifier: emailController.text,
password: passwordController.text
);
投稿者のハンドルはsession.data.handle
から得られます。これで記事のURLが生成できます。
final url = Uri(
scheme: 'https',
host: 'bsky.app',
pathSegments: <String>[
'profile',
session.data.handle,
'post',
articleId,
],
);
テスト用のユーザーIDとパスワードを秘匿する
Bluesky APIとの接続部分のテストがなかったので書きます。mockito
を使う方が良いのでしょうが、とりあえず本物のサーバーに接続するテストを書くことにします。
今回は本物のユーザーIDとパスワードを秘匿するため、以下の手順でflutter_dotenv
を使用します。
-
.gitignore
に.env
を追加する - プロジェクトルートに
.env
ファイルを作り環境変数を記述する。今回はBLUESKY_IDENTIFIER
とBLUESKY_PASSWORD
- コマンドラインで
flutter pub add flutter_dotenv
を実行しライブラリを使用可能にする - テストの中で
dotenv
を使用して環境変数を読み込む。
.envファイル(架空の設定値です)
BLUESKY_IDENTIFIER=eyasuyuki
BLUESKY_PASSWORD=EotTromABdPCWHlR
テストコード
void main() async {
await dotenv.load(fileName: '.env');
final id = dotenv.get('BLUESKY_IDENTIFIER');
final password = dotenv.get('BLUESKY_PASSWORD');
環境変数の読み込みはString.fromEnvironment
やPlatform.environment
ではなくdotenv.get
を使用します。
Riverpodの単体テストを書く
Bluesky APIとやりとりする部分はボタンのonPressed
にベタ書きしていました。これを分離してテストも書きたいのでRiverpodで書き直すことにしました。
Future<bool> testLogin(TestLoginRef ref, {required String email, required String password}) async {
try {
final session = await bsky.createSession(
identifier: email,
password: password
);
switch (session.status.code) {
case 200:
return true;
case 204:
return true;
default:
return false;
}
} catch (e) {
return false;
}
}
テストではProviderContainer
を使って、自動生成されたtestLoginProvider
をread
しています。IDとパスワードは前述の方法で秘匿した本物のアカウントを使用して本当にBlueskyに接続してテストしています。
test('test testLogin', () async {
final container = ProviderContainer();
// test loading
expect(
container.read(testLoginProvider.call(email: id, password: password)),
const AsyncValue<bool>.loading()
);
// test success
expect(
await container.read(testLoginProvider.call(email: id, password: password).future),
true
);
// test fail
expect(
await container.read(testLoginProvider.call(email: 'email', password: 'password').future),
false
);
});
画面側ではref.read
で読み込んだtestLoginProvider
を利用しています。Riverpodを使うために、画面側の継承元クラスをHookWidget
からHookConsumerWidget
に変更しています。
TextButton(
onPressed: () async {
await ref.read(testLoginProvider.call(email: emailController.text, password: passwordController.text).future)
? ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.login_success_text)))
: ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(backgroundColor: Colors.redAccent, content: Text(AppLocalizations.of(context)!.login_failed_text)));
},
child: Text(AppLocalizations.of(context)!.verify_button_text),
),
(もうちょっとスマートに書けないかとは思っています)
go_routerで前の画面に戻ったときに入力値をクリアする
投稿に成功したら記事のリンクを表示する画面に遷移し、[別のネタバレを投稿する]ボタンを押した時に編集画面に戻るようにしました。
このとき単にcontext.pop
やcontext.go
で画面遷移すると入力値がそのまま残ってしまいます。
context.push
やcontext.go
には引数を渡せるので、呼び出し側では引数を付けて呼び出すようにします。
context.go('/', extra: true) // true: 入力値をクリアする
GoRouter
の定義では呼び出し側のextra:
に渡された値をstate.extra
で取得してコンストラクタ引数として渡しています。
final _router = GoRouter(
initialLocation: '/',
initialExtra: false,
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return SpoilerEditor(title: title, clearAll: state.extra as bool);
},
),
画面側ではその引数で画面をクリアするかどうかを判定することにします。コンストラクタのオプション引数としてclearAll
をとるように変更します。
const SpoilerEditor({super.key, required this.title, this.clearAll = false});
画面の初期化処理はuseEffect
を使います。
// use effect
useEffect(() {
if (clearAll) {
inputController.clear(); // 記事入力のクリア
imageBytes.value = null; // 画像データのクリア
emailController.clear(); // Email入力のクリア
passwordController.clear(); // パスワード入力のクリア
}
return null;
}, [clearAll]);
注意点としては[clearAll]
ではなく[]
だとclearAll
の値が変化していてもuseEffect
は実行されません。
Riverpodで入力値を管理するproviderを書く方法など様々あろうかとは思いますが、とりあえずflutter_hooks
のuseEffect
で実現できました。
Postにリンクを埋め込む
URLを含むTweetを行うとTwitterでは勝手にリンクしてくれますが、Blueskyでは単なる文字列として扱われます。
URLを本文中にリンクとして埋め込むにはRich TextとしてPostする必要があります。
DenoでBlueskyに𝑹𝒊𝒄𝒉 𝒕𝒆𝒙𝒕を投稿する という記事に書かれている通り、Postの際にfacets
というRich Textの情報を付加すれば良さそうです。
まず正規表現を使って本文中のURLを抽出することにします。
List<Pair> extractUrl(String input) {
final _regexp = RegExp(r'https?://[^\s]*');
final matched = _regexp.allMatches(input);
return matched.map((e) => Pair(start: e.start, end: e.end)).toList();
}
本文中のURLの開始位置と終了位置が得られたので、これを使ってfacetsを生成することにします。
List<bsky.Facet> createFacets(String input, List<Pair> urls) {
return urls.map((e) => bsky.Facet(
index: bsky.ByteSlice(
byteStart: e.start,
byteEnd: e.end,
),
features: [
bsky.FacetFeature.link(
data: bsky.FacetLink(
uri: input.substring(e.start, e.end),
),
),
],
)).toList();
}
Postしてみるとリンクの位置がずれています。
位置がずれた理由ですが、正規表現でマッチした開始位置と終了位置は文字単位なのに対して、Facet
のindex
はバイト単位の位置だからです。というわけでバイト単位の位置を求めるためにPair
にメンバーを追加して、もうPairではなくなったのでRegion
に名前を変えることにします。
class Region {
// field
int start = 0;
int end = 0;
int byteStart = 0;
int byteEnd = 0;
// constructor
Region({this.start = 0, this.end = 0, this.byteStart = 0, this.byteEnd = 0});
}
Dartのマルチバイト文字列はUTF-16なので、バイト単位の開始位置、終了位置を求めるにはutf8.encode(文字).length
を使います。
List<Region> toByteIndices(String input, List<Region> pairs) {
int byteIndex = 0;
int prevBytes = 0;
for (int i = 0; i < input.length; i++) {
byteIndex += prevBytes;
for (var p in pairs) {
if (i == p.start) {
p.byteStart = byteIndex;
} else if (i == p.end) {
p.byteEnd = byteIndex;
}
}
prevBytes = utf8.encode(input[i]).length;
}
return pairs;
}
あとはfacets
の生成でバイト位置を使うように変更します。
List<bsky.Facet> createFacets(String input, List<Region> urls) {
urls = toByteIndices(input, urls); // バイト位置を求める
return urls.map((e) => bsky.Facet(
index: bsky.ByteSlice(
byteStart: e.byteStart, // バイト単位の開始位置
byteEnd: e.byteEnd, // バイト単位の終了位置
),
features: [
bsky.FacetFeature.link(
data: bsky.FacetLink(
uri: input.substring(e.start, e.end), // ここは変更なし
),
),
],
)).toList();
}
位置がずれずにPostできました。
flutter-webアプリケーションとしてリリースした理由
理由としては以下が挙げられます。
- 実行、デバッグをChromeで行っていたから
-
image_picker_web
を使っているから - GitHubの公開リポジトリなのでGitHub Pagesにデプロイするのが最も簡単
プロジェクトソースにはios、android、web、macos、windows、linuxそれぞれに対応したディレクトリが存在し各環境向けにビルドできるようにはなっていますが、開発中はもっぱらChromeで実行やデバッグを行っていました。iosやandroidはシミュレータ/エミュレータの設定が必要で、何の設定もなしに起動できるのはChromeとネイティブアプリケーションだけだからです。モバイルアプリケーションへの対応は後からでもできると思っていました。
Bluespoilerでは画像を読み込んでいますが、flutter-webでは画像の読み込みにimage_picker
ではなくimage_picker_web
を使う必要があります。flutter-webではdart:io
がimportできないためです。このため、モバイル向けビルドではimage_picker
を使い、web向けビルドではimage_picker_web
を使うといった設定が本来は必要になります。今回は動くものを先に作りたかったためimage_picker_web
を使って開発し、モバイルアプリケーションへの対応は後回しにしました。
Bluespoilerでは全てのソースコードをGitHubのpublicリポジトリで公開しています。Flutter WebをGitHub Pagesにデプロイする方法 という記事を参考に、完成したアプリケーションをGitHub Pagesに公開しました。
いざ公開してみるとGitHub Pagesでflutter-webアプリケーションを公開するのは様々なメリットがあったのです。
- デベロッパー登録もアプリ審査も必要ない
- ホーム画面にブックマークを追加すればネイティブアプリケーションと同様に気軽に起動できる
- GitHubにpushするだけでデプロイ完了
- バックエンドはBluesky APIだけなので、独自のサイトを稼働させる必要がない
Native Apps Are Deadなんていう記事もあるようですが、GitHubの公開プロジェクトならGitHub Pagesにデプロイするのは大いにアリなんじゃないでしょうか。
バグレポートと要望はGitHubまで
バグレポートと要望は下記にお願いします。
Discussion