Flutter BoxPainterでpie_chartを描く(中心に文字が入れられる)

f:id:ta_watanabe:20211022060304p:plain

こんな感じのpie_chartを作りました。

chartの中心に文字が入れられる既存のパッケージが見つからなかったので、 flutter galleryのソースを見ながら作りました。

drawArcで大小二つのArcをかいて、内側のArcは白く塗りつぶしてます。

まずはチャートと中の文字を生成している部分

import 'dart:math' as math;

import 'package:flutter/material.dart';

// 参考ソース
// https://github.com/flutter/gallery/blob/master/lib/studies/rally/charts/pie_chart.dart

class MyChart extends StatelessWidget {
  const MyChart(
      {Key? key,
      required this.total,
      required this.rest,
      required this.outerRPerParentHeightHalf,
      this.percentageFontSize})
      : super(key: key);

  final int total;
  final int rest;
  final double outerRPerParentHeightHalf; // 外側の半径 / 親の高さの半分
  final double? percentageFontSize;

  int get percentage => (total == 0 ? 0 : (rest / total * 100).toInt());

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (context, constraints) {
      return ConstrainedBox(
        constraints: constraints,
        child: DecoratedBox(
          decoration: MyDecoration(
              percentage: percentage,
              outerRPerParentHeightHalf: outerRPerParentHeightHalf),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                '$percentage%',
                style: TextStyle(
                    fontWeight: FontWeight.bold,
                    fontSize: percentageFontSize,
                    color: Colors.blue),
              ),
              Padding(
                padding: const EdgeInsets.only(top: 8.0),
                child: Text(
                  '$rest/$total',
                  style: TextStyle(
                      fontWeight: FontWeight.bold, color: Colors.blue),
                ),
              ),
            ],
          ),
        ),
      );
    });
  }
}

class MyDecoration extends Decoration {
  const MyDecoration(
      {required this.percentage, required this.outerRPerParentHeightHalf});

  final int percentage;
  final double outerRPerParentHeightHalf;

  @override
  BoxPainter createBoxPainter([VoidCallback? onChanged]) {
    return MyBoxPainter(
        amount: percentage,
        outerRPerParentHeightHalf: outerRPerParentHeightHalf);
  }
}

class MyBoxPainter extends BoxPainter {
  MyBoxPainter({required this.amount, required this.outerRPerParentHeightHalf});
  final double outerRPerParentHeightHalf;
  late double outerR;

  final int amount;

  double get innerR => outerR * 0.75;
  double get centerR => (outerR - innerR) / 2 + innerR;
  double get circleR => (outerR - innerR) / 2;

  // 100%=360度 としたときの角度にまずは直す
  double get endAngle => amount * 360 / 100;
  double get endRadian => -endAngle * math.pi / 180;

  // configuration.sizeは謎のpaddingを引いた分
  @override
  void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
    outerR = configuration.size!.height / 2 * outerRPerParentHeightHalf;

    // まずはドーナッツのうす水色を作る
    drawRest(canvas, offset, configuration);
    // 有効なところは青く
    drawAnswer(canvas, offset, configuration);

    // 端を滑らかにするための円
    drawStartCircle(canvas, offset, configuration);
    drawEndCircle(canvas, offset, configuration);
  }

  drawRest(Canvas canvas, Offset offset, ImageConfiguration configuration) {
    final paint = Paint()..color = Colors.blue[50]!;
    canvas.drawArc(
        Rect.fromCircle(
            center: configuration.size!.center(offset), radius: outerR),
        0.0,
        360 * math.pi / 180,
        true,
        paint);

    final paint2 = Paint()..color = Colors.white;
    canvas.drawArc(
        Rect.fromCircle(
            center: configuration.size!.center(offset), radius: innerR),
        0.0,
        360 * math.pi / 180,
        true,
        paint2);
  }

  drawAnswer(Canvas canvas, Offset offset, ImageConfiguration configuration) {
    final paint = Paint()..color = Colors.blue;
    final startRad = -90 * math.pi / 180;

    canvas.drawArc(
        Rect.fromCircle(
            center: configuration.size!.center(offset), radius: outerR),
        startRad,
        endRadian,
        true,
        paint);

    final paint2 = Paint()..color = Colors.white;
    canvas.drawArc(
        Rect.fromCircle(
            center: configuration.size!.center(offset), radius: innerR),
        startRad,
        endRadian,
        true,
        paint2);
  }

  drawStartCircle(
      Canvas canvas, Offset offset, ImageConfiguration configuration) {
    final centerOffset = configuration.size!.center(offset);
    final centerOffsetDy = centerOffset.dy;
    final circleOffsetDy = centerOffsetDy - centerR;
    final paint = Paint()..color = Colors.blue;
    canvas.drawCircle(Offset(centerOffset.dx, circleOffsetDy), circleR, paint);
  }

  drawEndCircle(
      Canvas canvas, Offset offset, ImageConfiguration configuration) {
    // drawArcと座標系が違う
    final radian = (270 - endAngle) * math.pi / 180;

    final x = centerR * math.cos(radian);
    final y = centerR * math.sin(radian);

    final centerOffset = configuration.size!.center(offset);
    final paint = Paint()..color = Colors.blue;
    canvas.drawCircle(
        // xはプラスで右、yはマイナスで上
        Offset(centerOffset.dx + x, centerOffset.dy + y),
        circleR,
        paint);
  }
}

デバッグするためのコード

import 'package:flutter/material.dart';

import 'my_chart.dart';

void main() {
  runApp(MaterialApp(
    home: MySca(),
  ));
}

class MySca extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(appBar: AppBar(), body: MyGridView()),
    );
  }
}

class MyGridView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GridView.count(
      crossAxisCount: 2,
      children: [MyBox(), MyBox()],
    );
  }
}

class MyBox extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
          color: Colors.white,
          border: Border.all(
            color: Colors.blueAccent,
            width: 2,
          ),
          borderRadius: BorderRadius.circular(15)),
      child: MyChart(total: 300, rest: 240, outerRPerParentHeightHalf: 0.5),
    );
  }
}

https://github.com/na8esin/flutter2_practice/tree/main/lib/src/chart

flutter firebase auth Googleプロバイダを使う

Play Consoleアカウントをお金を払って登録していることが条件

アプリごとに SHA1 フィンガープリントを追加する

https://developers.google.com/android/guides/client-auth?authuser=0

そのためには何でもいいので適当にアプリを作ってflutter build appbundle

それを内部テストでもいいのでリリースを作ってアップロード

サンプル作って動かしたらエラーが発生

サンプル
https://firebase.flutter.dev/docs/auth/social#google

エラー内容
com.google.android.gms.common.api.ApiException: 10

Play Consoleの アップロード鍵の証明書のところにあるSHA-1 証明書のフィンガープリントをFirebaseのコンソールに登録しないとダメみたいなので、登録し直し。

気を取り直して、サンプルを動かすとデバッグでコンソールにuser情報が出力されることを確認

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:firebase_auth/firebase_auth.dart';

Future<UserCredential?> signInWithGoogle() async {
  // Trigger the authentication flow
  final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();
  if (googleUser == null) {
    return null;
  }

  // Obtain the auth details from the request
  final GoogleSignInAuthentication googleAuth = await googleUser.authentication;

  // Create a new credential
  final credential = GoogleAuthProvider.credential(
    accessToken: googleAuth.accessToken,
    idToken: googleAuth.idToken,
  );

  // Once signed in, return the UserCredential
  return await FirebaseAuth.instance.signInWithCredential(credential);
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MaterialApp(
    home: Scaffold(
      body: MyBody(),
    ),
  ));
}

class MyBody extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: ElevatedButton(
        child: Text('SIGN IN'),
        onPressed: () async {
          final user = await signInWithGoogle();
          print(user);
        },
      ),
    );
  }
}

この段階でAuthenticationにレコードができる

sign in, outをいい感じにする

単純にやると下記のようなコードになると思います。

// signInWithGoogleはさっきと変わりなし
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(ProviderScope(child: MyApp()));
}

final authStateChangesProvider = StreamProvider((ref) {
  return FirebaseAuth.instance.authStateChanges();
});

class MyApp extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, ref) {
    return MaterialApp(home: AuthWidget());
  }
}

class AuthWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ref.watch(authStateChangesProvider).when(
        data: (user) {
          if (user == null) return SignInScreen();
          return Scaffold(
            floatingActionButton: FloatingActionButton(
              onPressed: () async {
                await FirebaseAuth.instance.signOut();
              },
              child: Text('SIGN OUT'),
            ),
            body: WellcomeScreen(),
          );
        },
        error: (e, s, d) => Center(child: Text('$e')),
        loading: (d) => CircularProgressIndicator());
  }
}

class SignInScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: ElevatedButton(
        child: Text('SIGN IN'),
        onPressed: () async {
          // googleアカウントを選択するポップアップが起動して選択するとログインできる
          await signInWithGoogle();
        },
      ),
    );
  }
}

class WellcomeScreen extends StatelessWidget {
  const WellcomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Wellcome'),
          ElevatedButton(
              onPressed: () async {
                // この方法だと遷移先でsign outしてもsignInページには移動しない
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => AnotherScreen()),
                );
              },
              child: Text('to another screen'))
        ],
      ),
    );
  }
}

class AnotherScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Center(child: Text('AnotherScreen')),
        ElevatedButton(
            onPressed: () async {
              // signOutが実行されてもこのページは表示されたまま
              await FirebaseAuth.instance.signOut();

              // 例えば苦し紛れにこの辺でNavigator.pushなどすると、
              // SignInScreenからandroidのbackボタンで
              // このAnotherScreenに戻ってこれる。
              // その時、firestoreなどに接続するようになってると
              // 権限の問題でエラーが発生したりする。
            },
            child: Text('SIGN OUT'))
      ],
    );
  }
}

ソースのコメントに書いてありますが、うまくsign outしません

単純にNavigatorを使うと上手くいきます。

class AuthWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ref.watch(authStateChangesProvider).when(
        data: (user) {
          if (user == null) return SignInScreen();
          return Scaffold(
            floatingActionButton: FloatingActionButton(
              onPressed: () async {
                await FirebaseAuth.instance.signOut();
              },
              child: Text('SIGN OUT'),
            ),
            // ここにNavigatorを追加するだけで、AnotherScreenまで行った時に
            // fabでsign outすると、sign inページに遷移する
            body: Navigator(
              pages: const [
                MaterialPage(
                  key: ValueKey('WellcomeScreen'),
                  child: WellcomeScreen(),
                )
              ],
              onPopPage: (route, result) => route.didPop(result),
            ),
          );
        },
        error: (e, s, d) => Center(child: Text('$e')),
        loading: (d) => CircularProgressIndicator());
  }
}

https://github.com/na8esin/flutter2_open_project/tree/main/lib/src/auth

Flutter BLoCを軽く調べる

自分は最初からriverpodで始めたので必要ないかと思いましたが、たまに見かけるので。

BLoC

stands for Business Logic Components.

cubitが統合されてる

https://github.com/felangel/cubit

どんなコード?

https://bloclibrary.dev/#/coreconcepts?id=creating-a-cubit

class CounterCubit extends Cubit<int> {
  CounterCubit(int initialState) : super(initialState);
}

StateNotifierに似てる
https://pub.dev/packages/state_notifier

stateを変更する場合はemitを使う

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
}

stateを変更するStateNotifierとはちょっと違う

Just like with Cubit, a Bloc is a special type of Stream,
https://bloclibrary.dev/#/coreconcepts?id=stream-usage-1

enum CounterEvent { increment, decrement }

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0);

  @override
  Stream<int> mapEventToState(CounterEvent event) async* {
    switch (event) {
      case CounterEvent.decrement:
        yield state - 1;
        break;
      case CounterEvent.increment:
        yield state + 1;
        break;
    }
  }
}

caseでメソッドを判定したり、yieldを使ったり結構癖がある気がするが、 全てがstreamで表現されるのは統一性があるのかもしれない。

それと中でproviderを使ってる
https://github.com/felangel/bloc/blob/master/packages/flutter_bloc/pubspec.yaml#L16
providerがメンテされなくなったらどうなるのだろうか?

まずはdartレベルでページング処理を作成

f:id:ta_watanabe:20210928224321p:plain
ページング例

こんな感じのページネーションをflutter webで作ることになりました。

無限スクロールのパッケージはたくさんありますが、上記の様なオーソドックスなパターンのは見当たらなかったので、自作します。

ソース

基本はgoogleのページネーションを参考にしています。

import 'dart:math';

class Pagination {
  // 一度に画面に表示できる最大のページ数
  static const max = 10;

  static int maxHalf() => (max / 2).floor();
  static int centerLeftDiff() => maxHalf() - 1;

  static List<int> pagination(int selectedPage, int pageMax) {
    if (pageMax < selectedPage) {
      throw Exception('selectedPage must be less than or equal to pageMax');
    }

    // 右の最大ページが決まれば1に到達するか10個までカウントする
    List<int> pagination = [];
    for (var i = rightMaxNumber(selectedPage, pageMax); i > 0; i--) {
      if (pagination.length == max) {
        break;
      }
      pagination.add(i);
    }

    // for文の条件を反転させればreverseしなくてもいいと思うが
    // そうなると最小値を求めないといけなくなる
    return pagination.reversed.toList();
  }

  static int rightMaxNumber(int selectedPage, int pageMax) {
    int rightMaxNumber = 0;

    // 今回の例で言うと6ページを境に動作が変わる
    if (selectedPage <= maxHalf() + 1) {
      rightMaxNumber = min(pageMax, max);
    } else {
      rightMaxNumber = min(selectedPage + maxHalf() - 1, pageMax);
    }

    return rightMaxNumber;
  }
}

テストコード

import 'package:flutter_test/flutter_test.dart';

import 'package:flutter2_practice/src/pagination/pagination.dart';

void main() {
  group('Pagination', () {
    test('rightMaxNumber', () {
      expect(Pagination.rightMaxNumber(1, 1), 1);
      expect(Pagination.rightMaxNumber(1, 10), 10);
      expect(Pagination.rightMaxNumber(1, 11), 10);
      expect(Pagination.rightMaxNumber(6, 6), 6);
      expect(Pagination.rightMaxNumber(6, 7), 7);
      expect(Pagination.rightMaxNumber(6, 10), 10);
      expect(Pagination.rightMaxNumber(6, 11), 10);

      expect(Pagination.rightMaxNumber(7, 7), 7);
      expect(Pagination.rightMaxNumber(7, 8), 8);
      expect(Pagination.rightMaxNumber(7, 10), 10);
      expect(Pagination.rightMaxNumber(7, 11), 11);
      expect(Pagination.rightMaxNumber(7, 100), 11);
    });

    test('pagination', () {
      expect(Pagination.pagination(1, 1), [1]);
      expect(Pagination.pagination(1, 3), [1, 2, 3]);
      expect(Pagination.pagination(1, 10), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
      expect(Pagination.pagination(1, 11), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);

      expect(Pagination.pagination(6, 6), [1, 2, 3, 4, 5, 6]);
      expect(Pagination.pagination(6, 7), [1, 2, 3, 4, 5, 6, 7]);
      expect(Pagination.pagination(6, 10), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
      expect(Pagination.pagination(6, 11), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);

      expect(Pagination.pagination(7, 7), [1, 2, 3, 4, 5, 6, 7]);
      expect(Pagination.pagination(7, 8), [1, 2, 3, 4, 5, 6, 7, 8]);
      expect(Pagination.pagination(7, 10), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
      expect(Pagination.pagination(7, 11), [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
      expect(Pagination.pagination(7, 12), [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
      expect(Pagination.pagination(7, 100), [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);

      expect(Pagination.pagination(14, 20),
          [9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
    });
  });
}

上記のソースコードと、それをwidgetに組み込んだものも下記にあります。一番上の画像のようにはまだなってません。
https://github.com/na8esin/flutter2_practice/blob/main/lib/src/pagination

twitterのListsをFlutter webに埋め込んでみる

概要

https://platform.twitter.com/widgets.jsを埋め込んで実現しようとしてます。

なぜLists?

Overview | Docs | Twitter Developer Platform

Because of very low usage, we plan to retire the Likes, Collections, and Moments timelines. We recommended you use the Profile and Lists timelines, which we’re updating to become faster, easier to use, and more up-to-date with Twitter features and functionality.

と書かれていたので。

書いてみたソースコード

import 'dart:html';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

void main() {
  // ignore: undefined_prefixed_name
  ui.platformViewRegistry
    ..registerViewFactory(
        'hello-html',
        (int viewId) => AnchorElement(
            href:
                'https://twitter.com/na8esin/lists/1438080785391575041?ref_src=twsrc%5Etfw')
          ..className = 'twitter-timeline'
          ..text = 'hello'
          ..style.width = 'auto'
          ..style.height = 'auto')
    ..registerViewFactory(
        'hello-javascript',
        (int viewId) => ScriptElement()
          ..src = 'https://platform.twitter.com/widgets.js'
          ..style.width = '50%'
          ..style.height = '50%'
          ..async = true);

  runApp(MaterialApp(
    home: Scaffold(
      appBar: AppBar(
        title: const Text('hello'),
      ),
      body: ListView(
        children: const [
          SizedBox(
              width: 640,
              height: 640, // 大きすぎるとscriptタグが要素から消える
              child: HtmlElementView(viewType: 'hello-html')),
          SizedBox(
              width: 1.0,
              height: 1.0,
              child: HtmlElementView(viewType: 'hello-javascript')),
        ],
      ),
    ),
  ));
}

一応このコードでも埋め込み自体はできてます。ですがスクロールができない

DivElementとoverflowYを使ってスクロール

SingleChildScrollViewなどを使ってみましたが、うまくいかず。 ちょっと強引ですが、cssでスクロールさせてみました。

import 'dart:html';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

void main() {
  final anchor = AnchorElement(
      href:
          'https://twitter.com/na8esin/lists/1438080785391575041?ref_src=twsrc%5Etfw')
    ..className = 'twitter-timeline'
    ..text = 'hello hino city';
  // ignore: undefined_prefixed_name
  ui.platformViewRegistry
    ..registerViewFactory(
        'hello-html',
        (int viewId) => DivElement()
          ..style.overflowY = 'scroll'
          ..style.width = '300px'
          ..style.height = '500px'
          ..children = [anchor])
    ..registerViewFactory(
        'hello-javascript',
        (int viewId) => ScriptElement()
          ..src = 'https://platform.twitter.com/widgets.js'
          ..style.width = '50%'
          ..style.height = '50%'
          ..async = true);

  runApp(MaterialApp(
    home: Scaffold(
      appBar: AppBar(
        title: const Text('hello'),
      ),
      body: ListView(
        children: const [
          SizedBox(
              width: 640,
              height: 640, // 大きすぎるとscriptタグが要素から消える
              child: HtmlElementView(viewType: 'hello-html')),
          SizedBox(
              width: 1.0,
              height: 1.0,
              child: HtmlElementView(viewType: 'hello-javascript')),
        ],
      ),
    ),
  ));
}

youtu.be

これで大好きな日野市の情報は見逃さない。

GitHub - na8esin/flutter_embedded_tweet

Flutter in_app_purchase をPlay Billing Library 4 で書き換える

Flutterのplugin の in_app_purchaseのandroidの方の実装をPlay Billing Library 4で書き換えました。

GitHub - na8esin/migrate_flutter_in_app_purchase_to_play_billing4

※本文はまだ中途半端

まずはソースダウンロード

DownGitだと何故か落とせなかったので、 svn checkout https://github.com/flutter/plugins/trunk/packages/in_app_purchase

メインのプロジェクトのpubspec.yamlにpathで書く

https://dart.dev/tools/pub/dependencies#path-packages

pathで参照されてるpluginの javaのコードを変更した時は、flutter runを停止してもう一度起動すれば反映されます。

plugin側のpubspec.ymlのversionを変更してflutter pub upgradeなどをする必要はありません。

dartのコードであれば、plugin側のソースでもhot restartでいけます。

それと、pathでなくても、githubにおいても、packageとして公開してもいいと思います。

削除されたメソッドなどを書き換える

この辺りをよく読む
Google Play Billing Library リリースノート  |  Google Play の課金システム

background threads で動くようになったものはrunOnUiThread()で囲む

Android Billing 4.0.0 - No purchase result querySkuDetailsAsync() - Stack Overflow

自分が気づいたものは、上記のquerySkuDetailsAsync()とBillingClientStateListener#onBillingSetupFinished()です。 これはflutter側をデバックしてもわかりづらいので気付くのが結構難しいです。

上記の2つは下記で呼ばれるので、

plugins/MethodCallHandlerImpl.java at master · flutter/plugins · GitHub

activity.runOnUiThread()の中に入れることになります。

こんな感じです。

migrate_flutter_in_app_purchase_to_play_billing4/MethodCallHandlerImpl.java at 217badf8b3c59eb20ff098a2402c9046e62e124e · na8esin/migrate_flutter_in_app_purchase_to_play_billing4 · GitHub

LiveDataでメインスレッドに持ってきてみる

runOnUiThread()だと
プロセスとスレッドの概要  |  Android デベロッパー  |  Android Developers

操作が複雑になるにつれて、この種類のコードも複雑化してメンテナンスが難しくなります。

とのことなので、LiveDataを使ってみます。

バックグラウンド スレッドでの Android タスクの実行  |  Android デベロッパー  |  Android Developers

コードがバックグラウンド スレッドで実行されている場合は、MutableLiveData.postValue() を呼び出して UI レイヤと通信できます。

ということなので。

でもjava.lang.IllegalStateException: Reply already submittedが発生。 このエラーはObserverに複数回通知がいくからです。

対応方法としては、SingleLiveEventなコードにすればいいと思いますが、 そこまでするか?と思ったのでLiveDataは使わない方向で。

断念したところのコミット
GitHub - na8esin/migrate_flutter_in_app_purchase_to_play_billing4 at toLiveData

蛇足

observeの引数には、LifecycleOwnerが必要ですが、
https://developer.android.com/topic/libraries/architecture/lifecycle?hl=ja#implementing-lco

サポート ライブラリ 26.1.0 以降のフラグメントとアクティビティには、すでに LifecycleOwner インターフェースが実装されています。

ということですが、AndroidXを有効にしてるので問題なし。

蛇足2

https://developer.android.com/guide/components/processes-and-threads?hl=ja

↑を見るとAsyncTaskが最善と書いてますが、AsyncTaskのリファレンスを見ると

This class was deprecated in API level 30. Use the standard java.util.concurrent or Kotlin concurrency utilities instead.

となってる。

Executorsはjava.util.concurrentパッケージ。

蛇足3 ViewModelProviderを使うためのviewModelStoreの取得方法がわからなかった

普通のAndroidアプリだとAppCompatActivityから取れるんで問題ないですが、 ActivityAwareからは取れません。

時間に余裕がある時にまた調べたい。

蛇足4 onMethodCallはUiThreadでしか呼べない

https://github.com/flutter/engine/blob/master/shell/platform/android/io/flutter/plugin/common/MethodChannel.java#L152

BillingClient.queryPurchasesAsync()を使える様にする

queryPurchases() -> queryPurchasesAsync · na8esin/migrate_flutter_in_app_purchase_to_play_billing4@eece491 · GitHub

このコミットで自分のやりたいことはとりあえずできました。

queryPurchasesAsyncに変えると、アプリが起動したままで定期購入の有効期限が切れた場合に flutter側から呼び出すことで最新の情報が取得できました。

余談

registerWith()

registerWith()を使うのが古いみたいだけど
https://medium.com/flutter/modern-flutter-plugin-development-4c3ee015cf5a

最近はflutte createしてもregisterWith()は生成されない

Reply already submitted with simultaneous method calls

LiveDataを使ってみた時にこれが発生して下記のissueに辿り着きましたが、 今は同じ様にやっても再現できなかったです。

https://github.com/flutter/flutter/issues/29092

参考

ClassyTaxiのbilling libraryをv4に変えたとき
https://github.com/android/play-billing-samples/commit/27fbd2e113cf74c446a01e2f3b700d0380bd281d

firestore security ruleの知識を棚卸し

ドキュメント

サンプルコード

roleの設計

カスタムクレームをつかう

get()などで発生するコストがかからない

でもfunctions(admin sdk)じゃないと変更できない

チャットアプリのような場合だと、ルームのメンバーの権限をそこそこ変えることがありそうだが、 反映させる場合はidtokenを取得しなおさないといけないから若干手間
https://firebase.google.com/docs/auth/admin/custom-claims?hl=ja#propagate_custom_claims_to_the_client
※クライアントへカスタム クレームを伝播する

ただ下記の様な方法もあるから全てをカスタムクレームで済ませる必要はなさそう
https://firebase.google.com/docs/firestore/solutions/role-based-access?hl=ja

カスタムクレームを使わない

firestoreのどこかに保存する

この場合は、roleを確認するのにget()しないといけないのでコストがかかる

アクセス呼び出しと料金 https://firebase.google.com/docs/firestore/security/rules-conditions?hl=ja#access_calls_and_pricing

案1. usersのフィールドにrolesを持たせる

users/user_abc {
  fcmTokens:[]
  roles : [commenter, editor]
}
  • アプリ側でrolesを使わないような場合は、モデルがアプリと管理画面で別々になる
  • セキュリティールール
    • 'editor' in get(/.../users/uid).data.roles
    • アプリ側で間違ってsetした時エラーにしないといけない。そうしないとフィールドが消える
  • flutter側。例えば管理画面で更新ボタンを表示・非表示する場合。後で記述するパターンと比べるとnullチェックが多め
isEditor() async {
   final snap = await FirebaseFirestore.instance.doc('users/user_abc').get();
   final data = snap.data();
   if (data == null) return false; // ここがnullなことはほとんどないと思うけど念の為
   final List? roles = data['roles'];
   if (roles == null) return false;
   return roles.contains('editor');
}

上の様なコードを下記の様なボタンの出しわけで使う想定

if (user.isEditor())
  ElevatedButton(onPressed: () => db.update, child: Text('更新'));

案2. usersのサブコレクションにrolesを持たせる

collectionGroup()しないならメリットが思いつかない。 collectionGroupでwhere検索する場合はインデックスを作成しないといけない(沢山作ると料金が発生)。 ただ、そんなケースがあるのか?

users/user_abc/roles/role {
  editor: true,
  reader: true
}
  • セキュリティルールget(/.../users/uid/roles/role).data.editor
  • クライアント側のコード
isEditor() async {
  final snap =
      await FirebaseFirestore.instance.doc('users/users_abc/roles/role').get();
  return snap.data() != null && snap.data()!['editor'] == true;
}

案3. usersのサブコレクションにrolesを持たせ、ドキュメントIDはrole名

users/user_abc/roles/editor:{
  isAvailable: true
}
users/user_abc/roles/commenter:{
  isAvailable: true
}

いろいろやってみましたが、メリットなさそう。

案3. rolesコレクションを作って、ドキュメントIDはUID

roles/uid {
  editor: true,
  reader: true
}
  • セキュリティルール get(/.../roles/uid).data.editor
  • クライアント側
isEditor() async {
    final snap = await FirebaseFirestore.instance.doc('roles/user_abc').get();
    return snap.data() != null && snap.data()!['editor'] == true;
  }

なかなか悪くない。usersが巨大化したら分けるのもあり。

その他

最近powershellから起動しているせいかセキュリティールールを更新しても読み込んでくれない時がある。 その場合は、一旦停止してから、再度起動。

rules-unit-testingが2.0.0から大きく変わる

initializeAdminApp()がなくなる。結局内部はadmin sdkなので自分で作りだせはする。

v1系ではまだ存在する

https://github.com/firebase/firebase-js-sdk/blob/3664731934d28fad50d5c302b260a412170375f9/packages/rules-unit-testing/src/api/index.ts#L242