SODA Engineering Blog
🍣

【Flutter】Riverpod開発チートシート2024保存版

2024/12/13に公開

\スニダンを開発している SODA inc.の Advent Calendar 2024 13日目の記事です!!!/

ごあいさつ

こんにちは、Flutterアプリエンジニアの一木です。

FlutterのRiverpodで開発したときに使えそうなコードなどをメインでまとめました。
個人開発や業務でも使えるかもしれないコード早見表として参考になればと思います。
Flutterの実行環境は整っているものとして、自分が個人でも使っている開発を便利にしてくれるプラグインなども紹介していきます!

00実行環境編

IDEはVSCode
Flutterのバージョン管理ツールはasdf
riverpod generator を使う
とします。

プロジェクトのDart/Flutterのバージョン指定

pubspec.yaml
environment:
  sdk: ">=3.5.4 <4.0.0"
  flutter: ">=3.24.5"

Dart 3.5.4以上を指定する。
Flutter 3.24.5以上を指定する。

asdf install用のファイル

.tool-versions
flutter 3.24.5-stable

asdf install で3.24.5のFlutterSDKをインストールする。
asdfの詳細は他記事を検索してください。

VSCodeの実行用環境変数

.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "dev-debug",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "flutterMode": "debug",
            "args": [
                "--dart-define=FLAVOR=dev",
                "--dart-define-from-file=env/dart-define.dev.json",
            ],
        },
        {
            "name": "prd-release",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "flutterMode": "release",
            "args": [
                "--dart-define=FLAVOR=prd",
                "--dart-define-from-file=env/dart-define.prd.json",
            ],
        }
    ]
}

env/dart-define.dev.json
{
    "AAA_KEY":"12345",
    "BBB_KEY":"ABCDE",
    "CCC_KEY":"FGHIJ67890"
}

vscode用の実行環境の設定ファイル
dart-define は環境変数を指定するために使用する。

VSCodeの設定周り(プロジェクト共通)

.vscode/settings.json
{
    "git.enableSmartCommit": true,
    "dart.flutterSdkPath": "/Users/myUserName/.asdf/shims/flutter",
    "window.zoomLevel": 0,
    "dart.debugExternalLibraries": false,
    "dart.debugSdkLibraries": false,
    "editor.cursorBlinking": "smooth", // カーソルの点滅
    "editor.cursorSmoothCaretAnimation": "explicit", // カーソルのアニメーション
    "editor.cursorStyle": "block", // カーソルの形状
    "editor.bracketPairColorization.enabled": true, // 括弧の対応を色付ける
    "[dart]": { // Dartファイルの設定
        "editor.formatOnSave": true, // 保存時に自動フォーマット
        "editor.codeActionsOnSave": {
            "source.fixAll": "explicit",
            "source.organizeImports": "always",
        }, // 保存時にimportを自動整形
        "editor.dragAndDrop": false, // テキストのドラッグ&ドロップを無効
        "editor.formatOnType": true, // タイプ時に自動フォーマット
        "editor.guides.bracketPairs": true, // 括弧の対応を表示
        "editor.rulers": [80], // 80文字でルーラーを表示
        "editor.selectionHighlight": false, // 選択範囲のハイライト
        "editor.suggest.snippetsPreventQuickSuggestions": false, // スニペットの補完を有効
        "editor.suggestSelection": "first", // 補完候補の最初を選択
        "editor.tabCompletion": "onlySnippets", // タブでスニペットを補完
        "editor.wordBasedSuggestions": "off" // 単語ベースの補完をオフ
    },
    "editor.renderLineHighlight": "all", // 選択行のハイライト
    "editor.wordSegmenterLocales": "ja", // 日本語の単語分割
    "explorer.fileNesting.patterns": {"*.dart": "${capture}.g.dart, ${capture}.freezed.dart"}, // ファイルネスティングのパターン
    "explorer.fileNesting.enabled": true, // ファイルネスティングを有効
    "explorer.confirmDelete": false, // ファイル削除時の確認を無効
    "explorer.confirmDragAndDrop": false, // ドラッグ&ドロップ時の確認を無効
    "files.autoSave": "afterDelay", // 自動保存
    "files.insertFinalNewline": true, // ファイルの最後に改行を挿入
    "files.trimTrailingWhitespace": true, // 行末の空白を削除
    "search.showLineNumbers": true, // 検索結果に行番号を表示
    "workbench.editor.wrapTabs": true, // タブを折り返す
    "github.copilot.editor.enableAutoCompletions": true // GitHub Copilotの自動補完を有効
}

コーディングカスタマイズ用の設定ファイル
欲しいものだけ抜粋して使ってみてください!
プロジェクト共通で使いたくない場合は、個人設定のsettings.jsonに指定してください。

~/Library/Application Support/Code/User/settings.json

状態管理 riverpod + riverpod_generator を導入する

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
+ hooks_riverpod: ^2.6.1 # riverpod 本体
+ flutter_hooks: # hook を使うために使用
+ riverpod_annotation: # riverpod の自動生成に使用

dev_dependencies:
  build_runner:
+ custom_lint: # コーディングルールを指定する
+ riverpod_generator: # riverpod の自動生成に使用
+ riverpod_lint: # lint を riverpod 用に拡張したもの

flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs 手動用
flutter pub run build_runner watch --delete-conflicting-outputs 自動用

01riverpod の基礎を学ぶ

GitHub にも同じものを用意してますので、わからなくなったらこちらを参考にしてみてください。ファイルパスも各章で検索できます。
https://github.com/hitotsu01ki/riverpod-project/tree/master

Providerを使って値を取得する

lib/example/example01controller.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'example01controller.g.dart';

/// Providerを使って値を取得する

String example0101Controller(Ref ref) {
  return 'value0101';
}
lib/example/example01screen.dart
lib/example/example01screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sample_202412/example/example01controller.dart';

/// riverpodの基礎を学ぶ
class Example01Screen extends ConsumerWidget {
  const Example01Screen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Riverpodの基礎'),
      ),
      body: ListView(
        children: <Widget>[
          ListTile(
            title: const Text('Providerを使って値を取得する'),
            subtitle: const Text('Example0101'),
            trailing: Text(ref.watch(example0101ControllerProvider)),
            onTap: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(
                      'Example0101: ${ref.watch(example0101ControllerProvider)}'),
                ),
              );
            },
          ),
          // 以降ここに追記していく
        ],
      ),
    );
  }
}

ロードが完了するまで待つ

lib/example/example01controller.dart

class Example0102Controller extends _$Example0102Controller {
  static String title = 'ロードが完了するまで待つ';
  static String subTitle = 'Example0102';

  
  FutureOr<String> build() async {
    // ロードしてることを認識するため1秒待つ
    await Future.delayed(const Duration(seconds: 1));
    return 'value0102';
  }
}
lib/example/example01screen.dart
lib/example/example01screen.dart
          ref.watch(example0102ControllerProvider).maybeWhen(
                data: (value) => ListTile(
                  title: Text(Example0102Controller.title),
                  subtitle: Text(Example0102Controller.subTitle),
                  trailing: Text(value),
                  onTap: () => ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      content: Text('Example0102: $value'),
                    ),
                  ),
                ),
                orElse: () => ListTile(
                  title: Text(Example0102Controller.title),
                  subtitle: Text(Example0102Controller.subTitle),
                  trailing: const CircularProgressIndicator(),
                ),
              ),

タップすると1度 [loading] に入り値が増える

lib/example/example01controller.dart

class Example0103Controller extends _$Example0103Controller {
  static String title = 'タップすると1度 [loading] に入り値が増える';
  static String subTitle = 'Example0103';

  
  FutureOr<int> build() async {
    await Future.delayed(const Duration(seconds: 1));
    return 0;
  }

  Future<void> increment() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      await Future.delayed(const Duration(seconds: 1));
      return state.value! + 1;
    });
  }
}
lib/example/example01screen.dart
lib/example/example01screen.dart
          ref.watch(example0103ControllerProvider).maybeWhen(
                data: (value) => ListTile(
                  title: Text(Example0103Controller.title),
                  subtitle: Text(Example0103Controller.subTitle),
                  trailing: Text('$value'),
                  onTap: () => ref
                      .read(example0103ControllerProvider.notifier)
                      .increment(),
                ),
                orElse: () => ListTile(
                  title: Text(Example0103Controller.title),
                  subtitle: Text(Example0103Controller.subTitle),
                  trailing: const CircularProgressIndicator(),
                ),
              ),

タップすると [loading] に入らず値が増える

lib/example/example01controller.dart

class Example0104Controller extends _$Example0104Controller {
  static String title = 'タップすると [loading] に入らず値が増える';
  static String subTitle = 'Example0104';

  
  FutureOr<int> build() async {
    await Future.delayed(const Duration(seconds: 1));
    return 0;
  }

  Future<void> increment() async {
    await update((value) async {
      await Future.delayed(const Duration(seconds: 1));
      return value + 1;
    });
  }
}
lib/example/example01screen.dart
lib/example/example01screen.dart
          ref.watch(example0104ControllerProvider).maybeWhen(
                data: (value) => ListTile(
                  title: Text(Example0104Controller.title),
                  subtitle: Text(Example0104Controller.subTitle),
                  trailing: Text('$value'),
                  onTap: () => ref
                      .read(example0104ControllerProvider.notifier)
                      .increment(),
                ),
                orElse: () => ListTile(
                  title: Text(Example0104Controller.title),
                  subtitle: Text(Example0104Controller.subTitle),
                  trailing: const CircularProgressIndicator(),
                ),
              ),

タップするとthrowされ [error] に入る

lib/example/example01controller.dart

class Example0105Controller extends _$Example0105Controller {
  static String title = 'タップするとthrowされ [error] に入る';
  static String subTitle = 'Example0105';

  
  FutureOr<int> build() async {
    await Future.delayed(const Duration(seconds: 1));
    return 0;
  }

  Future<void> increment() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () async {
        await Future.delayed(const Duration(seconds: 1));
        throw Exception('sample error');
      },
    );
  }
}
lib/example/example01screen.dart
lib/example/example01screen.dart
          ref.watch(example0105ControllerProvider).when(
                data: (value) => ListTile(
                  title: Text(Example0105Controller.title),
                  subtitle: Text(Example0105Controller.subTitle),
                  trailing: Text('$value'),
                  onTap: () => ref
                      .read(example0105ControllerProvider.notifier)
                      .increment(),
                ),
                error: (error, _) => ListTile(
                  title: Text(Example0105Controller.title),
                  subtitle: Text(Example0105Controller.subTitle),
                  trailing:
                      Text('$error', style: const TextStyle(color: Colors.red)),
                ),
                loading: () => ListTile(
                  title: Text(Example0105Controller.title),
                  subtitle: Text(Example0105Controller.subTitle),
                  trailing: const CircularProgressIndicator(),
                ),
              ),

タップするとthrowされ [error] に入り、2回目タップするとproviderが破棄され、再度buildされる

lib/example/example01controller.dart

class Example0106Controller extends _$Example0106Controller {
  static String title =
      'タップするとthrowされ [error] に入り、2回目タップするとproviderが破棄され、再度buildされる';
  static String subTitle = 'Example0106';

  
  FutureOr<int> build() async {
    await Future.delayed(const Duration(seconds: 1));
    return 0;
  }

  Future<void> increment() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () async {
        await Future.delayed(const Duration(seconds: 1));
        throw Exception('sample error');
      },
    );
  }

  void reset() {
    state = const AsyncLoading();
    ref.invalidateSelf();
  }
}

lib/example/example01screen.dart
lib/example/example01screen.dart
          ref.watch(example0106ControllerProvider).when(
                data: (value) => ListTile(
                  title: Text(Example0106Controller.title),
                  subtitle: Text(Example0106Controller.subTitle),
                  trailing: Text('$value'),
                  onTap: () => ref
                      .read(example0106ControllerProvider.notifier)
                      .increment(),
                ),
                error: (error, _) => ListTile(
                  title: Text(Example0106Controller.title),
                  subtitle: Text(Example0106Controller.subTitle),
                  trailing:
                      Text('$error', style: const TextStyle(color: Colors.red)),
                  onTap: () =>
                      ref.read(example0106ControllerProvider.notifier).reset(),
                ),
                loading: () => ListTile(
                  title: Text(Example0106Controller.title),
                  subtitle: Text(Example0106Controller.subTitle),
                  trailing: const CircularProgressIndicator(),
                ),
              ),

02Hook の基礎を学ぶ

画面遷移時に1度だけ実行する

lib/example/example0201screen.dart
    useEffect(
      () {
        Future.microtask(
          () {
            if (context.mounted) {
              return showAdaptiveDialog(
                context: context,
                builder: (context) {
                  return AlertDialog(
                    title: const Text(title),
                    content: const Text(subTitle),
                    actions: <Widget>[
                      TextButton(
                        onPressed: () => Navigator.of(context).pop(),
                        child: const Text('とじる'),
                      ),
                    ],
                  );
                },
              );
            }
          },
        );

        return null;
      },
      const [],
    );
コード全体 lib/example/example02/example0201screen.dart
lib/example/example02/example0201screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class Example0201Screen extends HookConsumerWidget {
  const Example0201Screen({super.key});

  static const title = '画面遷移時に1度だけ実行する';
  static const subTitle = 'Example0201 useEffect';
  
  Widget build(BuildContext context, WidgetRef ref) {
    useEffect(
      () {
        Future.microtask(
          () {
            if (context.mounted) {
              return showAdaptiveDialog(
                context: context,
                builder: (context) {
                  return AlertDialog(
                    title: const Text(title),
                    content: const Text(subTitle),
                    actions: <Widget>[
                      TextButton(
                        onPressed: () => Navigator.of(context).pop(),
                        child: const Text('とじる'),
                      ),
                    ],
                  );
                },
              );
            }
          },
        );

        return null;
      },
      const [],
    );

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(title),
      ),
      body: const Center(
        child: Column(
          children: [
            Text(title),
            Text(subTitle),
          ],
        ),
      ),
    );
  }
}

画面内で値を保持する

lib/example/example0202screen.dart
    final count = useState<int>(0);

コード全体 lib/example/example0202screen.dart
lib/example/example0202screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class Example0202Screen extends HookConsumerWidget {
  const Example0202Screen({super.key});

  static const title = '画面内で値を保持する';
  static const subTitle = 'Example0202 useState';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final count = useState<int>(0);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(title),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          count.value++;
        },
        child: const Icon(Icons.add),
      ),
      body: Center(
        child: Column(
          children: [
            const Text(title),
            const Text(subTitle),
            Text('count = ${count.value}'),
          ],
        ),
      ),
    );
  }
}

画面遷移時に実行し、特定の値が更新した場合も実行する

lib/example/example0203screen.dart
    final count = useState<int>(0);

    useEffect(
      () {
        Future.microtask(
          () {
            if (context.mounted) {
              return showAdaptiveDialog(
                context: context,
                builder: (context) {
                  return AlertDialog(
                    title: const Text(title),
                    content: Text('count = ${count.value}'),
                    actions: <Widget>[
                      TextButton(
                        onPressed: () => Navigator.of(context).pop(),
                        child: const Text('とじる'),
                      ),
                    ],
                  );
                },
              );
            }
          },
        );

        return null;
      },
      // [count.value] が更新された場合に再実行する
      [count.value],
    );

コード全体 lib/example/example0203screen.dart
lib/example/example0203screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class Example0203Screen extends HookConsumerWidget {
  const Example0203Screen({super.key});

  static const title = '画面遷移時に実行し、特定の値が更新した場合も実行する';
  static const subTitle = 'Example0203 useEffect + useState';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final count = useState<int>(0);

    useEffect(
      () {
        Future.microtask(
          () {
            if (context.mounted) {
              return showAdaptiveDialog(
                context: context,
                builder: (context) {
                  return AlertDialog(
                    title: const Text(title),
                    content: Text('count = ${count.value}'),
                    actions: <Widget>[
                      TextButton(
                        onPressed: () => Navigator.of(context).pop(),
                        child: const Text('とじる'),
                      ),
                    ],
                  );
                },
              );
            }
          },
        );

        return null;
      },
      // [count.value] が更新された場合に再実行する
      [count.value],
    );

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(title),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          count.value++;
        },
        child: const Icon(Icons.add),
      ),
      body: Center(
        child: Column(
          children: [
            const Text(title),
            const Text(subTitle),
            Text('count = ${count.value}'),
          ],
        ),
      ),
    );
  }
}

画面内で関数を保持する

lib/example/example0204screen.dart
    final count = useState<int>(0);
    final memo1 = useMemoized(() => count.value * 2);
    final memo2 = useMemoized(() => count.value * 2, [count.value]);

コード全体 lib/example/example0204screen.dart
lib/example/example0204screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class Example0204Screen extends HookConsumerWidget {
  const Example0204Screen({super.key});

  static const title = '画面内で関数を保持する';
  static const subTitle = 'Example0204 useMemoized + useState';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final count = useState<int>(0);
    final memo1 = useMemoized(() => count.value * 2);
    final memo2 = useMemoized(() => count.value * 2, [count.value]);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(title),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          count.value++;
        },
        child: const Icon(Icons.add),
      ),
      body: Center(
        child: Column(
          children: [
            const Text(title),
            const Text(subTitle),
            Text('count = ${count.value}'),
            Text('memo1 = $memo1 (計算は1度だけ 0 * 2 = 0)'),
            Text('memo2 = $memo2 (count.value が更新されると再計算)'),
          ],
        ),
      ),
    );
  }
}

画面内でコールバックを保持する

lib/example/example0205screen.dart
    final count = useState<int>(0);
    final plus5 = useCallback(() => count.value = count.value + 5);
    final popup = useCallback(
      () {
        if (context.mounted) {
          return showAdaptiveDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                title: const Text(title),
                content: Text('count = ${count.value}'),
                actions: <Widget>[
                  TextButton(
                    onPressed: () => Navigator.of(context).pop(),
                    child: const Text('とじる'),
                  ),
                ],
              );
            },
          );
        }
      },
    );

コード全体 lib/example/example0205screen.dart
lib/example/example0205screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class Example0205Screen extends HookConsumerWidget {
  const Example0205Screen({super.key});

  static const title = '画面内でコールバックを保持する';
  static const subTitle = 'Example0205 useCallback';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final count = useState<int>(0);
    final plus5 = useCallback(() => count.value = count.value + 5);
    final popup = useCallback(
      () {
        if (context.mounted) {
          return showAdaptiveDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                title: const Text(title),
                content: Text('count = ${count.value}'),
                actions: <Widget>[
                  TextButton(
                    onPressed: () => Navigator.of(context).pop(),
                    child: const Text('とじる'),
                  ),
                ],
              );
            },
          );
        }
      },
    );

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(title),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: popup,
        child: const Icon(Icons.add),
      ),
      body: Center(
        child: Column(
          children: [
            const Text(title),
            const Text(subTitle),
            Text('count = ${count.value}'),
            ElevatedButton(
              onPressed: plus5,
              child: const Text('plus5'),
            ),
          ],
        ),
      ),
    );
  }
}

03Ref の基礎を学ぶ

1秒毎に増加する count が 3 で割り切れるとスナックバーで通知

lib/example/example0301controller.dart
    ref.listen(
      example0301ControllerProvider,
      (previous, next) {
        if (next.value! % 3 == 0) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('count を 3 で割れました (${next.value})'),
              duration: const Duration(seconds: 1),
            ),
          );
        }
      },
    );


lib/example/example0301screen.dart
lib/example/example0301screen.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sample_202412/example/example03controller.dart';

class Example0301Screen extends HookConsumerWidget {
  const Example0301Screen({super.key});

  static const title = '1秒毎に増加する count が 3 で割り切れるとスナックバーで通知';
  static const subTitle = 'Example0301 ref.listen';
  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(
      example0301ControllerProvider,
      (previous, next) {
        if (next.value! % 3 == 0) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('count を 3 で割れました (${next.value})'),
              duration: const Duration(seconds: 1),
            ),
          );
        }
      },
    );

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(title),
      ),
      body: Center(
        child: Column(
          children: [
            const Text(title),
            const Text(subTitle),
            ref.watch(example0301ControllerProvider).maybeWhen(
                  data: (count) => Text('count = $count'),
                  orElse: () => const CircularProgressIndicator(),
                ),
          ],
        ),
      ),
    );
  }
}

showModalBottomSheet 内でref.watchを使う

lib/example/example0302screen.dart
    final textController = useTextEditingController();
    final modal = useCallback(
      () {
        return showModalBottomSheet(
          context: context,
          isScrollControlled: true,
          builder: (sheetContext) {
            return FractionallySizedBox(
              heightFactor: 0.7,
              child: Column(
                children: [
                  const ListTile(title: Text('TextEditingController')),
                  TextField(controller: textController),
                  ListTile(
                    title: const Text('TextEditingController から値を取得'),
                    subtitle: const Text('画面を開き直さないと値を表示できない'),
                    trailing: Text(textController.text),
                  ),
                  ListTile(
                    title: const Text('Example0302Controller から値を取得'),
                    subtitle: const Text('保存ボタンを押すと値が更新される'),
                    trailing: Consumer(
                      builder: (consumerContext, consumerRef, _) {
                        return consumerRef
                            .watch(example0302ControllerProvider)
                            .maybeWhen(
                              data: (value) => Text(value),
                              orElse: () => const SizedBox(),
                            );
                      },
                    ),
                  ),
                  Row(
                    children: [
                      ElevatedButton(
                        onPressed: () => Navigator.pop(sheetContext),
                        child: const Text('閉じる'),
                      ),
                      ElevatedButton(
                        onPressed: () {
                          ref
                              .read(example0302ControllerProvider.notifier)
                              .save(text: textController.text);
                        },
                        child: const Text('保存する'),
                      ),
                    ],
                  ),
                ],
              ),
            );
          },
        );
      },
    );

lib/example/example0302screen.dart
lib/example/example0302screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sample_202412/example/example03controller.dart';

class Example0302Screen extends HookConsumerWidget {
  const Example0302Screen({super.key});

  static const title = 'showModalBottomSheet 内でref.watchを使う';
  static const subTitle = 'Example0302 Consumer';
  
  Widget build(BuildContext context, WidgetRef ref) {
    final textController = useTextEditingController();
    final modal = useCallback(
      () {
        return showModalBottomSheet(
          context: context,
          isScrollControlled: true,
          builder: (sheetContext) {
            return FractionallySizedBox(
              heightFactor: 0.7,
              child: Column(
                children: [
                  const ListTile(title: Text('TextEditingController')),
                  TextField(controller: textController),
                  ListTile(
                    title: const Text('TextEditingController から値を取得'),
                    subtitle: const Text('画面を開き直さないと値を表示できない'),
                    trailing: Text(textController.text),
                  ),
                  ListTile(
                    title: const Text('Example0302Controller から値を取得'),
                    subtitle: const Text('保存ボタンを押すと値が更新される'),
                    trailing: Consumer(
                      builder: (consumerContext, consumerRef, _) {
                        return consumerRef
                            .watch(example0302ControllerProvider)
                            .maybeWhen(
                              data: (value) => Text(value),
                              orElse: () => const SizedBox(),
                            );
                      },
                    ),
                  ),
                  Row(
                    children: [
                      ElevatedButton(
                        onPressed: () => Navigator.pop(sheetContext),
                        child: const Text('閉じる'),
                      ),
                      ElevatedButton(
                        onPressed: () {
                          ref
                              .read(example0302ControllerProvider.notifier)
                              .save(text: textController.text);
                        },
                        child: const Text('保存する'),
                      ),
                    ],
                  ),
                ],
              ),
            );
          },
        );
      },
    );

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text(title),
      ),
      body: Center(
        child: Column(
          children: [
            const ListTile(
              title: Text(title),
              subtitle: Text(subTitle),
            ),
            ListTile(
              title: const Text('ボトムシートを表示'),
              trailing:
                  Text('${ref.watch(example0302ControllerProvider).value}'),
              onTap: modal,
            ),
          ],
        ),
      ),
    );
  }
}

04コード分割とリファクタリング の基礎を学ぶ

特定のWidgetをプライベートファイルに分割する(part of)

lib/example/example04/example0401screen.dart
import 'package:flutter/material.dart';

part 'example0401widget.dart';

/// コード分割とリファクタリング の基礎を学ぶ
class Example0401Screen extends StatelessWidget {
  const Example0401Screen({super.key});

  static const title = '特定のWidgetを別ファイルに分割する';
  static const subTitle = 'Example0401 part of';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('part of で分割した画面'),
      ),
      body: const _Example0401Widget(),
    );
  }
}
lib/example/example04/example0401widget.dart
part of 'example0401screen.dart';

/// コード分割とリファクタリング の基礎を学ぶ
class _Example0401Widget extends StatelessWidget {
  const _Example0401Widget();

  
  Widget build(BuildContext context) {
    return const Center(
      child: Column(
        children: [
          Text('part of で分割した画面'),
          Text('Example0401Widget'),
          Text('part of のファイルはプライベートにできる'),
          Text('ほかのファイルから参照できない'),
        ],
      ),
    );
  }
}

part 'example0401widget.dart'; で別ファイルに分割できる
class _Example0401Widget extends StatelessWidget { のようにプライベートクラスをpartで呼び出すことができる。

特定のWidgetを用途別に分割する(named constractor)

lib/example/example04/example0402screen.dart
import 'package:flutter/material.dart';

/// コード分割とリファクタリング の基礎を学ぶ
class Example0402Screen extends StatelessWidget {
  const Example0402Screen({super.key});

  static const title = '特定のWidgetを用途別に分割する';
  static const subTitle = 'Example0402 named constractor';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('名前付きコンストラクタ で分割した画面'),
      ),
      body: ListView(
        children: const <Widget>[
          _Example0402Widget.fixed(),
          _Example0402Widget(
            title: '引数を指定してタイトルを表示する',
            subTitle: 'サブタイトル',
          ),
          _Example0402Widget.titleOnly('_Example0402Widget.titleOnly'),
        ],
      ),
    );
  }
}

class _Example0402Widget extends StatelessWidget {
  const _Example0402Widget({
    required this.title,
    required this.subTitle,
  });

  const _Example0402Widget.fixed()
      : this(
          title: '特定のWidgetを用途別に分割する',
          subTitle: 'Example0402 named constractor',
        );

  const _Example0402Widget.titleOnly(this.title) : subTitle = '';

  final String title;
  final String subTitle;

  
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(title),
      subtitle: Text(subTitle),
    );
  }
}

_Example0402Widget.fixed() でWidgetの使い手側別に役割を与える。

コンストラクタに制限を加える(factory, ._())

lib/example/example04/example0403screen.dart
import 'package:flutter/material.dart';

/// コード分割とリファクタリング の基礎を学ぶ
class Example0403Screen extends StatelessWidget {
  const Example0403Screen({super.key});

  static const title = 'コンストラクタに制限を加える';
  static const subTitle = 'Example0403 factory';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Factory で名前付きコンストラクタに制限をつけて分割した画面'),
      ),
      body: ListView(
        children: <Widget>[
          _Example0403Widget.fixed(),
          // 指定不可のためビルドエラー
          // _Example0403Widget(
          //   title: 'コンストラクタに制限を加える',
          //   subTitle: 'サブタイトル',
          // ),
          _Example0403Widget.titleOnly('_Example0403Widget.titleOnly'),
          // 空文字不可のため画面遷移時にエラー
          // _Example0403Widget.titleOnly(''),

          // 補足として同じ値を指定するとインスタンスは同じになる
          _Example0403Widget.titleOnly('_Example0403Widget.titleOnly'),
        ],
      ),
    );
  }
}

/// 補足として、factory に同じ値を指定すると
/// インスタンスは同じになります。
class _Example0403Widget extends StatelessWidget {
  /// [._] でコンストラクタに制限を加える
  const _Example0403Widget._({
    required this.title,
    required this.subTitle,
  });

  /// [title] と [subTitle] に固定値を設定する
  factory _Example0403Widget.fixed() {
    return const _Example0403Widget._(
      title: '特定のWidgetを用途別に分割する',
      subTitle: 'Example0403 factory',
    );
  }

  /// [title] が空文字の場合はエラー
  factory _Example0403Widget.titleOnly(String title) {
    if (title.isEmpty) {
      throw ArgumentError.notNull('title');
    }
    return _Example0403Widget._(
      title: title,
      subTitle: '',
    );
  }

  final String title;
  final String subTitle;

  
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(title),
      subtitle: Text(subTitle),
    );
  }
}

_Example0403Widget._() で指定不可にする
factory _Example0403Widget.titleOnly(String title) { 内でtitleに空文字を指定するとエラーとなる

05環境変数 の基礎を学ぶ

dart-define (Flavorなど)

lib/example/example05controller.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'example05controller.g.dart';


String flavorController(Ref ref) {
  return const String.fromEnvironment('FLAVOR');
}
lib/example/example05creen.dart
lib/example/example05creen.dart
          ListTile(
            title: const Text('Flavor: '),
            trailing: Text(ref.watch(flavorControllerProvider)),
            onTap: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content:
                      Text('Flavor: ${ref.watch(flavorControllerProvider)}'),
                ),
              );
            },
          ),

VSCodeの実行用環境変数 で指定した "--dart-define=FLAVOR=dev" を読み取る

dart-define-from-file (API_KEYなど)

lib/example/example05controller.dart

String aaaKeyController(Ref ref) {
  return const String.fromEnvironment('AAA_KEY');
}


DartEnv dartEnvController(Ref ref) {
  return DartEnv(
    aaaKey: const String.fromEnvironment('AAA_KEY'),
    bbbKey: const String.fromEnvironment('BBB_KEY'),
    cccKey: const String.fromEnvironment('CCC_KEY'),
  );
}

class DartEnv {
  final String aaaKey;
  final String bbbKey;
  final String cccKey;
  DartEnv({
    required this.aaaKey,
    required this.bbbKey,
    required this.cccKey,
  });
}

lib/example/example05creen.dart
lib/example/example05creen.dart
          ListTile(
            title: const Text('Dart Environment: '),
            subtitle: const Text('AAA_KEY'),
            trailing: Text(ref.watch(aaaKeyControllerProvider)),
            onTap: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content:
                      Text('AAA_KEY: ${ref.watch(aaaKeyControllerProvider)}'),
                ),
              );
            },
          ),
          ListTile(
            title: const Text('Dart Environment: '),
            subtitle: const Text('BBB_KEY'),
            trailing: Text(ref.watch(dartEnvControllerProvider).bbbKey),
            onTap: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(
                      'Dart Environment: ${ref.watch(dartEnvControllerProvider).bbbKey}'),
                ),
              );
            },
          ),

"--dart-define-from-file=env/dart-define.dev.json" のファイル内を読み込む

06テストの書き方 の基礎を学ぶ

controller のテストを書く

テスト対象 lib/example/example06controller.dart
lib/example/example06controller.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'example06controller.g.dart';


class Example0601Repository extends _$Example0601Repository {
  
  FutureOr<int> build() {
    return Future.value(601);
  }

  FutureOr<int> fetch() {
    return Future.value(9999);
  }
}


class Example0601Controller extends _$Example0601Controller {
  static String title = 'タップすると1度 [loading] に入り値が増える';
  static String subTitle = 'Example0103 と同じ処理';

  
  Future<int> build() async {
    return ref.read(example0601RepositoryProvider.future);
  }

  Future<void> fetch() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      return ref.read(example0601RepositoryProvider.notifier).fetch();
    });
  }
}
test/example06controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sample_202412/example/example06controller.dart';

void main() {
  group('Example0601Controller Tests', () {
    test('Initial state is AsyncLoading', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);

      final notifier = container.read(example0601ControllerProvider.notifier);
      expect(notifier.state, const AsyncLoading<int>());
    });

    test('Build function works correctly', () async {
      final container = ProviderContainer();

      addTearDown(container.dispose);

      final future = await container.read(example0601ControllerProvider.future);

      expect(future, 601);
    });

    test('Fetch function works correctly', () async {
      final container = ProviderContainer();
      addTearDown(container.dispose);

      final notifier = container.read(example0601ControllerProvider.notifier);

      await notifier.fetch();

      expect(notifier.state, const AsyncData<int>(9999));
    });
  });
}

flutter test でテストを実行する。

最後に

次回はriverpod3がリリースされましたらお会いしましょう〜!
ありがとうございました〜!

SODA Engineering Blog
SODA Engineering Blog

Discussion