🐧

非エンジニアが Flutter × Riverpod × Hooks で診断アプリを作ってみた学習記録(仮)🐧

に公開

こんにちは、Flutter初心者の道上です!

今回は、Riverpod × Hooks × Freezed × GoRouter を使って、
“前世をジョークで診断する”アプリを作ってみたので、学習記録としてまとめました(※改善中)
GitHubリポジトリ→[https://github.com/yoichi4141/jokereincarnationdiagnosistool]

使用したもの
-Flutter(3.x)
-Riverpod(FutureProvider 使用)
-flutter_hooks / hooks_riverpod(UI状態管理)/ Freezed/ GoRouter
-Sourcetree(Git管理)

https://riverpod.dev/ja/
https://pub.dev/packages/flutter_hooks
https://pub.dev/packages/hooks_riverpod
https://pub.dev/packages/freezed
https://pub.dev/packages/go_router

アプリの内容

なぜこの構成にしたのか?

Flutterは自由度が高すぎて、最初どう構成するか分からなかったのですが、
いろいろな記事を見て「Riverpod + Hooks + Freezed + GoRouter」の構成を選びました。

今の自分なりに、こういう理解です:
モダンパッケージ

  • Riverpod → 非同期処理をまとめる場所
  • Hooks → setState不要にして、UIを軽く保てる
  • Freezed → モデルの使い回しや比較がしやすい
  • GoRouter → 複雑な遷移も1つのファイルに集約できる

早速コードです!↓※モダンパッケージ(上記)に関する解説/メモ/します!

main.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'router/app_router.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      debugShowCheckedModeBanner: false,
      routerConfig: appRouter,
    );
  }
}
model/diagnosis_result.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'diagnosis_result.freezed.dart';
part 'diagnosis_result.g.dart';


class DiagnosisResult with _$DiagnosisResult {
  const factory DiagnosisResult({
    required String name, // 診断した名前
    required String result, // 前世のジョーク診断結果
    required DateTime createdAt, // 診断日時
  }) = _DiagnosisResult;

  factory DiagnosisResult.fromJson(Map<String, dynamic> json) =>
      _$DiagnosisResultFromJson(json);
}
lib/features/diagnosis/model/diagnosis_result.freezed.dart
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark

part of 'diagnosis_result.dart';

// **************************************************************************
// FreezedGenerator
// **************************************************************************

T _$identity<T>(T value) => value;

final _privateConstructorUsedError = UnsupportedError(
  'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);

DiagnosisResult _$DiagnosisResultFromJson(Map<String, dynamic> json) {
  return _DiagnosisResult.fromJson(json);
}

/// @nodoc
mixin _$DiagnosisResult {
  String get name => throw _privateConstructorUsedError; // 診断した名前
  String get result => throw _privateConstructorUsedError; // 前世のジョーク診断結果
  DateTime get createdAt => throw _privateConstructorUsedError;

  /// Serializes this DiagnosisResult to a JSON map.
  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;

  /// Create a copy of DiagnosisResult
  /// with the given fields replaced by the non-null parameter values.
  (includeFromJson: false, includeToJson: false)
  $DiagnosisResultCopyWith<DiagnosisResult> get copyWith =>
      throw _privateConstructorUsedError;
}

/// @nodoc
abstract class $DiagnosisResultCopyWith<$Res> {
  factory $DiagnosisResultCopyWith(
    DiagnosisResult value,
    $Res Function(DiagnosisResult) then,
  ) = _$DiagnosisResultCopyWithImpl<$Res, DiagnosisResult>;
  
  $Res call({String name, String result, DateTime createdAt});
}

/// @nodoc
class _$DiagnosisResultCopyWithImpl<$Res, $Val extends DiagnosisResult>
    implements $DiagnosisResultCopyWith<$Res> {
  _$DiagnosisResultCopyWithImpl(this._value, this._then);

  // ignore: unused_field
  final $Val _value;
  // ignore: unused_field
  final $Res Function($Val) _then;

  /// Create a copy of DiagnosisResult
  /// with the given fields replaced by the non-null parameter values.
  ('vm:prefer-inline')
  
  $Res call({
    Object? name = null,
    Object? result = null,
    Object? createdAt = null,
  }) {
    return _then(
      _value.copyWith(
            name:
                null == name
                    ? _value.name
                    : name // ignore: cast_nullable_to_non_nullable
                        as String,
            result:
                null == result
                    ? _value.result
                    : result // ignore: cast_nullable_to_non_nullable
                        as String,
            createdAt:
                null == createdAt
                    ? _value.createdAt
                    : createdAt // ignore: cast_nullable_to_non_nullable
                        as DateTime,
          )
          as $Val,
    );
  }
}

/// @nodoc
abstract class _$$DiagnosisResultImplCopyWith<$Res>
    implements $DiagnosisResultCopyWith<$Res> {
  factory _$$DiagnosisResultImplCopyWith(
    _$DiagnosisResultImpl value,
    $Res Function(_$DiagnosisResultImpl) then,
  ) = __$$DiagnosisResultImplCopyWithImpl<$Res>;
  
  
  $Res call({String name, String result, DateTime createdAt});
}

/// @nodoc
class __$$DiagnosisResultImplCopyWithImpl<$Res>
    extends _$DiagnosisResultCopyWithImpl<$Res, _$DiagnosisResultImpl>
    implements _$$DiagnosisResultImplCopyWith<$Res> {
  __$$DiagnosisResultImplCopyWithImpl(
    _$DiagnosisResultImpl _value,
    $Res Function(_$DiagnosisResultImpl) _then,
  ) : super(_value, _then);

  /// Create a copy of DiagnosisResult
  /// with the given fields replaced by the non-null parameter values.
  ('vm:prefer-inline')
  
  $Res call({
    Object? name = null,
    Object? result = null,
    Object? createdAt = null,
  }) {
    return _then(
      _$DiagnosisResultImpl(
        name:
            null == name
                ? _value.name
                : name // ignore: cast_nullable_to_non_nullable
                    as String,
        result:
            null == result
                ? _value.result
                : result // ignore: cast_nullable_to_non_nullable
                    as String,
        createdAt:
            null == createdAt
                ? _value.createdAt
                : createdAt // ignore: cast_nullable_to_non_nullable
                    as DateTime,
      ),
    );
  }
}

/// @nodoc
()
class _$DiagnosisResultImpl implements _DiagnosisResult {
  const _$DiagnosisResultImpl({
    required this.name,
    required this.result,
    required this.createdAt,
  });

  factory _$DiagnosisResultImpl.fromJson(Map<String, dynamic> json) =>
      _$$DiagnosisResultImplFromJson(json);

  
  final String name;
  // 診断した名前
  
  final String result;
  // 前世のジョーク診断結果
  
  final DateTime createdAt;

  
  String toString() {
    return 'DiagnosisResult(name: $name, result: $result, createdAt: $createdAt)';
  }

  
  bool operator ==(Object other) {
    return identical(this, other) ||
        (other.runtimeType == runtimeType &&
            other is _$DiagnosisResultImpl &&
            (identical(other.name, name) || other.name == name) &&
            (identical(other.result, result) || other.result == result) &&
            (identical(other.createdAt, createdAt) ||
                other.createdAt == createdAt));
  }

  (includeFromJson: false, includeToJson: false)
  
  int get hashCode => Object.hash(runtimeType, name, result, createdAt);

  /// Create a copy of DiagnosisResult
  /// with the given fields replaced by the non-null parameter values.
  (includeFromJson: false, includeToJson: false)
  
  ('vm:prefer-inline')
  _$$DiagnosisResultImplCopyWith<_$DiagnosisResultImpl> get copyWith =>
      __$$DiagnosisResultImplCopyWithImpl<_$DiagnosisResultImpl>(
        this,
        _$identity,
      );

  
  Map<String, dynamic> toJson() {
    return _$$DiagnosisResultImplToJson(this);
  }
}

abstract class _DiagnosisResult implements DiagnosisResult {
  const factory _DiagnosisResult({
    required final String name,
    required final String result,
    required final DateTime createdAt,
  }) = _$DiagnosisResultImpl;

  factory _DiagnosisResult.fromJson(Map<String, dynamic> json) =
      _$DiagnosisResultImpl.fromJson;

  
  String get name; // 診断した名前
  
  String get result; // 前世のジョーク診断結果
  
  DateTime get createdAt;

  /// Create a copy of DiagnosisResult
  /// with the given fields replaced by the non-null parameter values.
  
  (includeFromJson: false, includeToJson: false)
  _$$DiagnosisResultImplCopyWith<_$DiagnosisResultImpl> get copyWith =>
      throw _privateConstructorUsedError;
}
lib/features/diagnosis/model/diagnosis_result.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'diagnosis_result.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

_$DiagnosisResultImpl _$$DiagnosisResultImplFromJson(
  Map<String, dynamic> json,
) => _$DiagnosisResultImpl(
  name: json['name'] as String,
  result: json['result'] as String,
  createdAt: DateTime.parse(json['createdAt'] as String),
);

Map<String, dynamic> _$$DiagnosisResultImplToJson(
  _$DiagnosisResultImpl instance,
) => <String, dynamic>{
  'name': instance.name,
  'result': instance.result,
  'createdAt': instance.createdAt.toIso8601String(),
};
lib/features/diagnosis/provider/diagnosis_provider.dart
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:joke_diagnosis_app/features/diagnosis/model/diagnosis_result.dart';
import '../model/diagnosis_result.dart';

final diagnosisProvider =
    AsyncNotifierProvider<DiagnosisNotifier, DiagnosisResult>(
      DiagnosisNotifier.new,
    );

class DiagnosisNotifier extends AsyncNotifier<DiagnosisResult> {
  final _jokes = [
    "あなたの前世はジャガイモです。",
    "江戸時代の落語家でした。",
    "ピラミッドの石を1個だけ運んだ人です。",
    "未来から来たネコ型AIでした。",
    "あなたはインド人の王でした名前はクマール",
  ];

  
  Future<DiagnosisResult> build() async {
    await Future.delayed(const Duration(seconds: 2)); // 擬似API待ち時間
    final name = ref.read(userNameProvider);
    final joke = _jokes[Random().nextInt(_jokes.length)];
    return DiagnosisResult(name: name, result: joke, createdAt: DateTime.now());
  }
}

// 名前を保持するProvider(StateNotifier)
final userNameProvider = StateProvider<String>((ref) => "");
[ユーザーが名前入力]
   ↓   ref.read(userNameProvider)
[診断スタートで diagnosisProvider を watch]
   ↓   AsyncNotifier の build() が呼ばれる
   ↓   2秒待つ → ランダムジョーク生成 → モデル返却
[UIは diagnosisProvider の .when() で表示]
AsyncNotifier	非同期ロジックを build() で管理できる
StateProvider	フォームや一時データに最適な軽量Provider
ref.read()	他のProviderから値を取得する方法
ref.watch()	UIで状態を監視して表示に反映する
lib/features/diagnosis/view/home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../provider/diagnosis_provider.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final nameController = useTextEditingController();
    final isValid = useState(false);

    useEffect(() {
      nameController.addListener(() {
        isValid.value = nameController.text.trim().isNotEmpty;
      });
      return null;
    }, [nameController]);

    return Scaffold(
      appBar: AppBar(title: const Text('ジョーク診断メーカー')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('あなたの名前を入力してください'),
            const SizedBox(height: 16),
            TextField(
              controller: nameController,
              decoration: const InputDecoration(
                border: OutlineInputBorder(),
                hintText: '例:たろう',
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed:
                  isValid.value
                      ? () {
                        ref.read(userNameProvider.notifier).state =
                            nameController.text.trim();
                        context.push('/loading');
                      }
                      : null,
              child: const Text('診断スタート'),
            ),
          ],
        ),
      ),
    );
  }
}
lib/features/diagnosis/view/loading_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../provider/diagnosis_provider.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(diagnosisProvider, (previous, next) {
      next.whenData((_) {
        context.go('/result');
      });
    });

    // 初回のみ診断開始
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(diagnosisProvider.notifier).build();
    });

    return Scaffold(
      body: Center(
        child:
            const Text('診断中…🧠')
                .animate()
                .fadeIn(duration: 500.ms)
                .scale(duration: 800.ms)
                .then(delay: 300.ms)
                .shake(),
      ),
    );
  }
}
lib/features/diagnosis/view/result_page.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../provider/diagnosis_provider.dart';
import 'package:intl/intl.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final result = ref.watch(diagnosisProvider).value;

    if (result == null) {
      return const Scaffold(body: Center(child: Text('診断結果がありません')));
    }

    final formattedDate = DateFormat(
      'yyyy年MM月dd日 HH:mm',
    ).format(result.createdAt);

    return Scaffold(
      appBar: AppBar(title: const Text('診断結果')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('名前:${result.name}', style: const TextStyle(fontSize: 20)),
            const SizedBox(height: 16),
            Text('前世:${result.result}', style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 16),
            Text('診断日:$formattedDate'),
            const SizedBox(height: 32),
            ElevatedButton(
              onPressed: () => context.go('/'),
              child: const Text('もう一度診断する'),
            ),
          ],
        ),
      ),
    );
  }
}

解消するべきバグ

二回目の診断ができない
ひらがな以外も認識させる

その他
ZennとGitの連携を試す

※編集中

Discussion