🔖

Riverpod + FlutterHooksにおける一覧・投稿機能

2021/12/15に公開

はじめに

この記事は、Flutter Advent Calendar Flutter 2021 3枚目の15日目の記事です。
先日、「tsumugu(ツムグ)」をリリースしましたが、うんともすんともいわず...サービス開発は難しいですね...。
リリースすれば、見える景色が変わるかと思いましたが、
まだ何も見えてきません...😢

そんなわけでリリース後は作業が停滞していましたが、少しずつ改善をしていこうと思います。
本記事では、tsumuguで扱った投稿機能について見ていきたいと思います。
https://zenn.dev/kosawa/articles/3909b6eb65eaa0

環境

Dart Flutter hooks_riverpod freezed
2.14.4 2.5.3 ^0.14.0 (0.14.0+4) ^0.14.2

一覧画面と投稿画面

まず簡易的な筋トレアプリの仕様を考えていきます。

仕様

各筋トレメニューに紐づく部位があります。
(例えば、筋トレメニューがベンチプレスだったら、部位は胸)

部位一覧ページの登録ボタンをタップすると、部位投稿ページに遷移します。
部位投稿ページに追加したい部位を入力して、保存ボタンをタップすると部位一覧ページに表示されます。

| 部位一覧ページ | 部位投稿ページ |
| ------------- | ------------- | ------------- |
||

全体のアーキテクチャ(部位一覧〜投稿)

部位一覧ページ

↓ 部位一覧取得の説明は割愛しますが、一連の流れがあった方がわかりやすいと思いますのでよかったら参考にしてみてください。

parts_state.dart
import 'package:flutter_app/data/model/parts.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'parts_state.freezed.dart';


class PartsState with _$PartsState {
  const PartsState._();

  factory PartsState({
    (false) bool isLoading,
    ([]) List<Parts> parts,
  }) = _PartsState;
}
parts_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_app/ui/parts/add_parts_page.dart';
import 'package:flutter_app/ui/theme/app_colors.dart';
import 'package:flutter_app/ui/theme/app_font_size.dart';
import 'package:flutter_app/view_model/parts_view_model.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class PartsPage extends HookWidget {
  
  Widget build(BuildContext context) {
    final viewModel = useProvider(partsViewModelProvider.notifier);
    final state = useProvider(partsViewModelProvider);
    useEffect(() {
      Future(() {
        viewModel.fetchParts();
        ;
      });
    }, const []);

    viewModel.showSnackBarMessage = (message) {
      final snackBar = SnackBar(content: Text(message));
      ScaffoldMessenger.of(context).showSnackBar(snackBar);
    };

    return Scaffold(
      backgroundColor: AppColors.background,
      appBar: AppBar(
        title: Text(
          '部位一覧',
          style: TextStyle(fontWeight: FontWeight.bold),
        ),
        backgroundColor: AppColors.background,
        elevation: 0,
        actions: [
          IconButton(
            icon: const Icon(Icons.add_rounded),
            onPressed: () {
              Navigator.push<bool>(
                context,
                MaterialPageRoute(
                  builder: (_) => AddPartsPage(),
                  fullscreenDialog: true,
                ),
              ).then((isCompleted) async {
                if (isCompleted == null || isCompleted == false) return;
                viewModel.fetchParts();
              });
            },
          ),
        ],
      ),
      body: SafeArea(
        child: RefreshIndicator(
          color: AppColors.background,
          onRefresh: () async {
            await viewModel.fetchParts();
          },
          child: ListView.separated(
            itemCount: state.parts.length,
            itemBuilder: (context, int index) {
              return ListTile(
                title: Text(
                  state.parts[index].name,
                  style: TextStyle(color: Colors.white, fontSize: AppFontSize.pt16),
                ),
                trailing: Icon(
                  Icons.navigate_next_rounded,
                  color: Colors.white60,
                  size: AppFontSize.pt20,
                ),
                onTap: () {
                  showDialog(
                    context: context,
                    builder: (context) {
                      return AlertDialog(
                        content: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Text('編集・削除'),
                          ],
                        ),
                      );
                    },
                  );
                },
              );
            },
            separatorBuilder: (BuildContext context, int index) => const Divider(color: Colors.white30),
          ),
        ),
      ),
    );
  }
}

部位投稿画面を呼び出しています。
Navigatorの返り値?に応じて、一覧画面を更新するか否かを判断しています。

IconButton(
  icon: const Icon(Icons.add_rounded),
    onPressed: () {
      Navigator.push<bool>(
	context,
	MaterialPageRoute(
	  builder: (_) => AddPartsPage(),
	  fullscreenDialog: true,
	),
      ).then(
	(isCompleted) async {
	  if (isCompleted == null || isCompleted == false) return;
	  viewModel.fetchParts();
	},
      );
    },
  ),

viewModel側でコールバックを定義しておいて、Widget側で利用しています。

viewModel.showSnackBarMessage = (message) {
  final snackBar = SnackBar(content: Text(message));
  ScaffoldMessenger.of(context).showSnackBar(snackBar);
};

viewModelで状態を更新して、Widget側ではRepositoryからProvideされたstateを利用しています。

parts_view_model.dart
import 'package:flutter/material.dart';
import 'package:flutter_app/data/model/parts.dart';
import 'package:flutter_app/data/model/state/parts_state.dart';
import 'package:flutter_app/data/repository/parts_repository.dart';
import 'package:flutter_app/util/callback_typedefs.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final partsViewModelProvider = StateNotifierProvider.autoDispose<PartsViewModel, PartsState>((ref) => PartsViewModel(ref.read));

class PartsViewModel extends StateNotifier<PartsState> {
  PartsViewModel(this._reader) : super(PartsState());

  final Reader _reader;
  ShowSnackBarMessageCallback? showSnackBarMessage;
  List<Parts> _parts = [];

  Future<void> fetchParts({isInit: false}) async {
    state = state.copyWith(isLoading: true);
    final showSnackBarMessage = this.showSnackBarMessage;

    try {
      _parts = await _reader(partsRepositoryProvider).getParts();
      state = state.copyWith(isLoading: false, parts: _parts);
    } catch (e) {
      debugPrint(e.toString());
      if (showSnackBarMessage != null) {
        showSnackBarMessage('部位一覧の取得に失敗しました');
      }
      state = state.copyWith(isLoading: false, parts: _parts);
    }
  }
}
parts_repository.dart
import 'auth_repository.dart';

abstract class PartsRepository {
  Future<List<Parts>> getParts();
}

final partsRepositoryProvider = Provider((ref) => PartsRepositoryImpl(ref.read));

class PartsRepositoryImpl implements PartsRepository {
  PartsRepositoryImpl(this._reader);

  final Reader _reader;

  
  Future<List<Parts>> getParts() async {
    final storeProvider = _reader(firebaseStoreProvider);
    final String? userId = _reader(authRepositoryProvider).currentUserId;
    if (userId == null) {
      throw Exception('uidの取得に失敗しました');
    }

    try {
      List<Parts> parts = [];
      final collectionRef = await storeProvider.collection('users').doc(userId).collection('parts');
      final snapshots = await collectionRef.get();

      if (snapshots.docs.isEmpty) {
        throw Exception('部位一覧の取得に失敗しました');
      }

      await Future.forEach(snapshots.docs, (QueryDocumentSnapshot<Map<String, dynamic>> e) async {
        final name = e.data()['name'].toString();
        final createdAt = (e.data()['createdAt'] as Timestamp).toDate();
        final part = Parts(e.id, createdAt, name: name);

        parts.add(part);
      });

      return parts;
    } catch (e) {
      throw Exception('部位一覧の取得に失敗しました');
    }
  }

部位投稿ページ

まずは、状態クラスを定義します。
isRegistrationEnabledは登録可否を判定する際に利用します。

add_parts_state.dart
import 'package:flutter_app/data/model/app_error.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'add_parts_state.freezed.dart';


class AddPartsState with _$AddPartsState {
  const AddPartsState._();

  factory AddPartsState({
    (false) bool isLoading,
    AppError? error,
    ('') String partsName,
  }) = _AddPartsState;

  bool get isRegistrationEnabled => partsName != '';
}

部位投稿ページの全体です。

add_parts_page.dart
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter_app/extension/widget_extension.dart';
import 'package:flutter_app/ui/component/button/text_button.dart';
import 'package:flutter_app/ui/component/form/custom_text_field.dart';
import 'package:flutter_app/ui/component/loading/loading_indicator_dialog_builder.dart';
import 'package:flutter_app/ui/theme/app_colors.dart';
import 'package:flutter_app/view_model/add_parts_view_model.dart';
import 'package:flutter_app/view_model/parts_view_model.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class AddPartsPage extends HookWidget {
  AddPartsPage();

  
  Widget build(BuildContext context) {
    final state = useProvider(addPartsViewModelProvider);
    final viewModel = useProvider(addPartsViewModelProvider.notifier);
    final partsNameController = useTextEditingController(text: state.partsName);
    final isRegistrationEnabled = state.isRegistrationEnabled;

    final partsState = useProvider(partsViewModelProvider);
    final partsNames = partsState.parts.map((e) => e.name).toList();

    partsNameController.addListener(() {
      viewModel.setPartsName(partsNameController.text);
    });

    return GestureDetector(
      onTap: () {
        FocusScopeNode currentFocus = FocusScope.of(context);
        if (!currentFocus.hasPrimaryFocus) {
          currentFocus.unfocus();
        }
      },
      child: Scaffold(
        backgroundColor: AppColors.background,
        appBar: AppBar(
          backgroundColor: AppColors.background,
          elevation: 0,
          title: Text(
            '部位の編集',
            style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
          ),
          actions: [
            CustomTextButton(
              text: '保存',
              isRegistrationEnabled: isRegistrationEnabled,
              onPressed: isRegistrationEnabled
                  ? () async {
                      final dialog = LoadingIndicatorDialogBuilder(context);
                      await dialog.show();
                      final result = await viewModel.addParts(partsNames: partsNames);
                      dialog.hide();

                      result.when(
                        success: (_) {
                          Navigator.pop(context, result.isSuccess);
                        },
                        failure: (e) {
                          showAlertDialog(
                            context: context,
                            message: e.message,
                          );
                        },
                      );
                    }
                  : null,
            ),
          ],
        ),
        body: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(12),
              child: CustomTextField.textField(
                '部位',
                hintText: '部位名を入力',
                textController: partsNameController,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

viewModel(StateNotifier派生クラス)とstateをuserProviderで取得しています。
stateには画面表示に関わるデータを持っています。

final state = useProvider(addPartsViewModelProvider);
final viewModel = useProvider(addPartsViewModelProvider.notifier);

FlutterのtextFieldにはcontollerプロパティがあり、TextEditingControllerをセットすることで、controllerはリスナーに通知します。ユーザーが入力した内容や選択がどのように更新されたかを検知することができます。

hooks_riverpodを利用しない場合は、おおよそ、viewModel側でコントローラーをセットして、Widget側で利用するといった実装方針になるかと思います。
適切に不要になったらdispose(廃棄)する必要があります。最初はuseTextEditingControllerを利用しないで実装したため、リソースの解放忘れなどでハマった記憶があります。

parts_view_model.dart
class PartsViewModel extends StateNotifier<PartsState> {
  PartsViewModel(this._reader) : super(PartsState());

  
  void dispose() {
    contentController.dispose();
    super.dispose();
  }

  final TextEditingController contentController = TextEditingController();

useTextEditingControllerを利用すると、Widgetのみで完結します。textFieldの変更を検知すると、stateクラスの定義している状態を変更させています。

今は、provider自体をautodisposeしているので、当然破棄されますが、autodisposeをはずしても破棄されているように見えるので、おそらくFlutterのライフサイクルに合わせて、破棄されているのではないかと理解しています。
この辺はまたハマったタイミングで深ぼってみようと思います。

final partsViewModelProvider = StateNotifierProvider.autoDispose<PartsViewModel, PartsState>((ref) => PartsViewModel(ref.read));
 final partsNameController = useTextEditingController(text: state.partsName);
 // 明示的に初期値を指定したい場合は、下記のような書き方もできます
 // final controller = useTextEditingController.fromValue(TextEditingValue.empty);

 
 partsNameController.addListener(() {
   viewModel.setPartsName(partsNameController.text);
 });

何もないところをタップするとキーボードを閉じるようにしています。
フォーカスを管理するuseFocusNodeとスクロールを管理するuseScrollControllerを利用すればいい感じにできそうな気がするので、そのうち試してみたいと思います。

GestureDetector(
  onTap: () {
    FocusScopeNode currentFocus = FocusScope.of(context);
    if (!currentFocus.hasPrimaryFocus) {
      currentFocus.unfocus();
    }
  },

下記は投稿ページで利用しているviewModelとRepositoryです。ほとんどstateの更新とDBへの保存しかやっていないので、説明は割愛しますが、もしよかったら参考にしてみてください。

add_parts_view_model.dart
import 'package:flutter/foundation.dart';
import 'package:flutter_app/data/model/app_error.dart';
import 'package:flutter_app/data/model/result.dart';
import 'package:flutter_app/data/model/state/add_parts_state.dart';
import 'package:flutter_app/data/repository/parts_repository.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final addPartsViewModelProvider = StateNotifierProvider.autoDispose<AddPartsViewModel, AddPartsState>((ref) => AddPartsViewModel(ref.read));

class AddPartsViewModel extends StateNotifier<AddPartsState> {
  AddPartsViewModel(this._reader) : super(AddPartsState());

  final Reader _reader;

  setPartsName(String partsName) {
    state = state.copyWith(partsName: partsName);
  }

  Future<Result> addParts({required List<String> partsNames}) async {
    if (partsNames.contains(state.partsName)) {
      return Result.failure(error: AppError(null, message: 'すでに登録されています'));
    }

    try {
      return _reader(partsRepositoryProvider).addParts(
        partsName: state.partsName,
      );
    } on Exception catch (e) {
      debugPrint(e.toString());
      return Result.failure(error: AppError(e, message: '部位の保存に失敗しました'));
    } catch (e) {
      debugPrint(e.toString());
      return Result.failure(error: AppError(null, message: '部位の保存に失敗しました'));
    }
  }
}
parts_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_app/data/model/app_error.dart';
import 'package:flutter_app/data/model/parts.dart';
import 'package:flutter_app/data/model/result.dart';
import 'package:flutter_app/provider/top_level_providers.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'auth_repository.dart';

abstract class PartsRepository {
  Future<void> addParts({required String partsName});
}

final partsRepositoryProvider = Provider((ref) => PartsRepositoryImpl(ref.read));

class PartsRepositoryImpl implements PartsRepository {
  PartsRepositoryImpl(this._reader);

  final Reader _reader;

  
  Future<Result<String>> addParts({required String partsName}) async {
    final storeProvider = _reader(firebaseStoreProvider);

    final String? userId = _reader(authRepositoryProvider).currentUserId;
    if (userId == null) {
      throw Exception('uidの取得に失敗しました');
    }

    final String prefixPath = 'users/${userId}/parts';
    final partsId = storeProvider.collection(prefixPath).doc().id;

    Map<String, Object?> data = {
      'name': partsName,
      'createdAt': Timestamp.now(),
    };

    try {
      await storeProvider.collection(prefixPath).doc(partsId).set(data);

      return Result.success(data: partsId);
    } catch (e) {
      return Result.failure(error: AppError(e as Exception?, message: '部位の保存に失敗しました'));
    }
  }
}

最後に

ざっと書いてみましたが、こちらを応用すれば一通りのCRUDは実現できるのではないでしょうか。
ただちょっとぼやっとした記事になってしまいました。

Riverpodを利用してアプリをリリースしてみましたが、とりあえず動くところまではできたになりがちなので、もうちょっとFlutterのライフサイクルや内部実装も深掘りしてみたいと思います。

Discussion