Flutter Riverpod Generator 導入時に読みたかったまとめ

最近、 Flutter でアプリを作る機会があり、 Riverpod （Generator）, Async Value , go_router を組み合わせてどのようにアプリを書いていったらいいのか、試行錯誤することがありました。
その際の総括として、ざっくりとした使い方をサンプルコードのような形で、まとめておきます。
パッケージの導入方法や import 文などは省略しており、適宜行間を読み解いていただく必要があります。

# サンプル実装

以下、Riverpod generator などを利用したときにどのように書き進めていったらいいのか参考のための実装例となります。
機能は、 DIO による REST リクエスト、 fake レスポンスとの切り替え、 エラーハンドリング、 Bearer トークンの設定、 Widget上 でのエラーハンドリングなどです。


### dio Provider

dio インスタンスを provider として取得できるようにします。

```dart
/// dioインスタンス
@riverpod
Dio dio(DioRef ref) {
  final dio = Dio();
  if (kDebugMode) {
    dio.interceptors.add(PrettyDioLogger());
  }
  return dio;
}

@riverpod
Dio apiDio(ApiDioRef ref) {
  var d = ref.watch(dioProvider);
  d.options = BaseOptions(
    baseUrl: 'https://my.apiserver.com',
    contentType: 'application/json; charset=UTF-8',
    responseType: ResponseType.json,
    headers: {'Authorization': 'Bearer xxx'},
  );
  return d;
}
```


### API Service

dio インスタンスを利用し、ネットワークリクエストを実装します。

`UserResponse` などエンティティを freezed で定義しておきます。

```dart
@freezed
class UserResponse with _$UserResponse {
  const factory UserResponse({
    required String name,
  }) = _DeviceItem;

  factory UserResponse.fromJson(Map<String, Object?> json) =>
      _$UserResponseFromJson(json);
}
```


`IApiRepository`で一旦抽象化しておき、ユニットテスト時にモックレスポンスを入れやすくしておきます。

```dart
abstract class IApiRepository {
  /// ユーザデータ取得
  Future<UserResponse> fetchMe({required String userId,　CancelToken? cancelToken});

	/// ユーザ名の変更
  Future<void> putName(
      {required String userId,
      required String newName,
      CancelToken? cancelToken});
}

/// リアルオブジェクト
class ApiRepository implements IApiRepository {
  ApiRepository({required this.client});

  final Dio client;

  @override
  Future<UserResponse> fetchMe({required String userId, CancelToken? cancelToken}) async {
    final response = await client.get('/me/$userId', cancelToken: cancelToken);
    return UserResponse.fromJson(response.data);
  }

	@override
  Future<void> putName(
      {required String userId,
      required String newName,
      CancelToken? cancelToken}) async {
    await client.put('/me/$userId',
        data: {'name': newName}, cancelToken: cancelToken);
  }
} 

/// Fakeオブジェクト。開発用。
class FakeRepository implements IApiRepository {
  FakeRepository();

  @override
  Future<UserResponse> fetchMe({required String userId, CancelToken? cancelToken}) async {
		await Future.delayed(const Duration(milliseconds: 500));
    return UserResponse(id: userId, name: "fake user"); // Fake Response
  }

	@override
  Future<void> putName(
      {required String userId,
      required String newName,
      CancelToken? cancelToken}) async {
    await Future.delayed(const Duration(milliseconds: 500));
    return;
  }
} 

/// demoModeProviderの状態に応じ、
/// リアルあるいはFakeオブジェクトを提供する
@riverpod
IApiRepository apiRepository(ApiRepositoryRef ref) {
  final demo = ref.watch(demoModeProvider); // true or false をもつだけのNotifier Provider
  if (demo == true) {
    return FakeApiRepository();
  } else {
    return ApiRepository(client: ref.watch(apiDioProvider));
  }
}
```

### Data

API Repository Provider を利用し、アプリから利用するための具体的なプロバイダにします。
ここでは、　Widget 生成とともに実行できるリクエストは Future プロバイダ、
Widget 生成後、ユーザ操作でのリクエストは Notifier プロバイダ、という使い分けです。

 - User Data 

```dart
@riverpod
Future<UserResponse> fetchUser(FetchUserRef ref) async {
	final repo = ref.read(apiRepositoryProvider);
  final cancelToken = CancelToken();
  ref.onDispose(() {cancelToken.cancel();});
  return repo.fetchUser(userId: 'my-id', cancelToken: cancelToken);
}
```

 - User Data  - Change Name

```dart
@riverpod
class putUserNameNotifier extends _$putDeviceNameNotifier {
  @override
  FutureOr<String?> build(String deviceName) {
    return _fetch();
  }

  Future<String?> _fetch() async {
    final res =
        ref.watch(fetchUserProvider(userId: 'my-id')).value;
    return res?.name;
  }

  Future<void> put(String newName) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final r = ref.read(apiRepositoryProvider);
      final cancelToken = CancelToken();
      await r.putName(
          userId: 'my-id',
          newName: newName,
          cancelToken: cancelToken);

      // ref.invalidate(fetchUserProvider);
      return await _fetch();
    });
  }
}
```

### go router

go_routerインスタンスを提供するプロバイダを作成します。
今回はルートがひとつだけ。

```dart
final goRouterProvider = Provider<GoRouter>((ref) {
  return GoRouter(
    initialLocation: '/myscreen',
    routes: [
      GoRoute(
        path: '/',
        redirect: (_, __) => '/myscreen',
      ),
      GoRoute(
        path: '/myscreen',
        name: 'myscreen',
        pageBuilder: (context, state) => NoTransitionPage(
          child: const MyScreen(),
        ),
      ),
    ],
  );
});
```

### Widget

以上、いくつかのプロバイダを準備したことで、多くの処理を Stateless Widget である Consumer Widget に書けます。
サービスごとに小さな Consumer Widget に分割すると書きやすいかもしれません。

```dart
/// ユーザ情報表示Widget
class MyWidget extends ConsumerWidget {
  const MyWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ref.watch(fetchUserProvider).when(data: (d) {
      return Text(d.toString());
    }, error: (err, stack) {
      return Text(err.toString());
    }, loading: () {
      return CircularProgressIndicator();
    });
  }
}
```

```dart
/// ユーザ名変更Widget
class PutNameWidget extends ConsumerWidget {
  const PutNameWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentName = ref.watch(putDeviceNameNotifierProvider("my-id"));
    return Row(children: [
      Text("current name: $currentName"),
      ElevatedButton(onPressed: () async {
        await ref.read(putDeviceNameNotifierProvider('my-id').notifier).put("new name");
				 // 必要に応じて取得済みユーザ情報を破棄、再取得
				ref.invalidate(fetchUserProvider);
        logger.d("put name completed");
      }, child: Text("change name"))
    ],);
  }
}
```


### Main

ルートとなるウィジェット`MyApp`をProviderScopeでくくります。

```dart
Future<void> main() async {
  runApp(ProviderScope(
      overrides: [],
      child: const MyApp()));
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final goRouter = ref.watch(goRouterProvider);
    return MaterialApp.router(
      routerConfig: goRouter,
      title: 'MyApp',
      theme: ThemeData.dark(),
    );
  }
}

class MyScreen extends ConsumerWidget {
  const MyScreen({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    var isDemo = ref.read(demoModeProvider);
    _navigate(isDemo, GoRouter.of(context));
    return const Scaffold(
      body: Column(
        children: [
            MyWidget(),
            PutNameWidget(),
        ],
      ),
    );
  }

}
```




# 参考

以下の実装が参考になりました。

コミックリーダアプリ。専用のサーバアプリとともにオープンソースで開発されているアプリです。
具体的な実装がおおく大変参考になりました。
一方、ネットワーク周りはかなり抽象化されおり、慣れていないと読み解くのは難しいです。
[Suwayomi/Tachidesk-Sorayomi: A free and open source manga reader app to read manga from a Tachidesk-Server instance.](https://github.com/Suwayomi/Tachidesk-Sorayomi)

ストアアプリ実装例。カート状態管理など。
[Code with Andrea](https://codewithandrea.com/articles/)から提供されているアプリ一式のサンプル。
API部分は実際に通信する実装はないので、それは他から見たほうが良いかもです。
[bizz84/complete-flutter-course: Complete Flutter Course Bundle - Flutter eCommerce App](https://github.com/bizz84/complete-flutter-course)

比較的この3つのなかではシンプルなアプリ例。
最新の映画情報を取得する半パブリックなAPIから取得した情報を表示するアプリでした。
[bizz84/tmdb_movie_app_riverpod: Flutter Movies app with Riverpod (TMDB API)](https://github.com/bizz84/tmdb_movie_app_riverpod)