🐧
非エンジニアが 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管理)
アプリの内容
なぜこの構成にしたのか?
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