完全自分用の学習備忘録。
詳細な解説等はコード内でコメントアウトしております。
環境構築、認証画面とToDoリスト画面を作成していきます。
環境構築
適当なFlutterプロジェクトを作成する。(詳細な手順は省きます。他記事を参照ください🙇)
-
pubspec.yaml
dependencies: flutter: sdk: flutter flutter_hooks: flutter_riverpod: riverpod_annotation: go_router: ^14.6.2 path: ^1.8.3 http: ^1.2.2 flutter_secure_storage: ^9.2.2 json_annotation: ^4.8.1 dio: ^5.7.0 freezed: freezed_annotation: dependency_overrides: analyzer: ^6.7.0 # To fix Riverpod issue (https://github.com/rrousselGit/riverpod/issues/3794) dev_dependencies: flutter_test: sdk: flutter build_runner: riverpod_generator: json_serializable: ^6.7.0
エントリーポイント(main関数)
-
lib/main.dart
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify_app/app.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); runApp( // アプリ全体でriverpodを使用できるようProviderScopeでラップ const ProviderScope( child: RiverpodToDoApp(), ), ); }
-
lib/app.dart
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify_app/config/routes/routes.dart'; class RiverpodToDoApp extends ConsumerWidget { const RiverpodToDoApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // GoRouterを監視 final route = ref.watch(goRouterProvider); return MaterialApp.router( debugShowCheckedModeBanner: false, theme: ThemeData( primarySwatch: Colors.blue, ), routerConfig: route, // GoRouterの設定を適用 ); } }
GoRouterで画面管理
GoRouterにはRiverpodを併用する。
-
lib/config/routes/app_routes.dart
import 'package:go_router/go_router.dart'; import 'package:spotify_app/config/routes/routes_location.dart'; import 'package:spotify_app/config/routes/routes_provider.dart'; import '../../screens/auth_screen.dart'; import '../../screens/todo_list_screen.dart'; // アプリ内のルート設定を定義 // builder: に対応する画面ウィジェットを返す final appRoutes = [ // 認証画面 GoRoute( path: AppRoute.auth.path, parentNavigatorKey: navigationKey, builder: AuthScreen.builder, ), // Todoリスト画面 GoRoute( path: AppRoute.todoList.path, parentNavigatorKey: navigationKey, builder: TodoListScreen.builder, ), ];
-
lib/config/routes/routes_location.dart
enum AppRoute { auth, // 認証画面 todoList // Todoリスト画面 } // それぞれの画面のパスを定義 extension AppRouteExtention on AppRoute { String get path { switch (this) { case AppRoute.auth: return "/auth"; case AppRoute.todoList: return "/todoList"; default: return ""; } } }
-
lib/config/routes/routes_provider.dart
以下のコマンドを実行し、
provider.g.dart
ファイルを生成flutter pub run build_runner watch --delete-conflicting-outputs
import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify_app/config/routes/app_routes.dart'; import 'package:spotify_app/config/routes/routes_location.dart'; part 'routes_provider.g.dart'; final navigationKey = GlobalKey<NavigatorState>(); // StateとなるGoRouteを監視 @riverpod GoRouter goRouter(ref) { return GoRouter( initialLocation: AppRoute.auth.path, // アプリ起動時の初期画面のパス navigatorKey: navigationKey, routes: appRoutes // 定義済みのルートを設定 ); }
認証画面を作成
以下のUIを実装する。
-
ユーザー名入力欄
-
メールアドレス入力欄
-
パスワード入力欄
-
サインアップボタン
-
サインインボタン
-
lib/screens/auth_screen.dart
import 'dart:ffi'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify_app/config/routes/routes.dart'; import 'package:spotify_app/model/custom_exception.dart'; import 'package:spotify_app/providers/auth_view_model.dart'; import 'package:spotify_app/widgets/auth_button.dart'; import 'package:spotify_app/widgets/auth_text_field.dart'; import 'package:spotify_app/widgets/common_app_bar.dart'; import 'package:spotify_app/widgets/common_error_dialog.dart'; class AuthScreen extends ConsumerWidget { static AuthScreen builder(BuildContext context, GoRouterState state,) => const AuthScreen(); const AuthScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // 認証状態(true or false)、ローディング中状態、エラー状態を監視 final vm = ref.watch(authViewModelProvider); // AuthViewModelのインスタンス final vmNotifier = ref.read(authViewModelProvider.notifier); final usernameController = TextEditingController(); final emailController = TextEditingController(); final passwordController = TextEditingController(); return Scaffold( appBar: const CommonAppBar(title: "認証"), body: Stack( children: [ _authForm( usernameController, emailController, passwordController, vmNotifier, ), _buildLoadingOrErrorDialog(vm), ], ), ); } // 認証フォーム Widget _authForm( TextEditingController usernameController, TextEditingController emailController, TextEditingController passwordController, AuthViewModel vmNotifier ) { return Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 32), AuthTextField( hintText: "ユーザー名(サインインの場合は不要)", icon: Icons.person, controller: usernameController, ), AuthTextField( hintText: "メールアドレス", icon: Icons.email, controller: emailController, ), AuthTextField( hintText: "パスワード", icon: Icons.lock, obscureText: true, controller: passwordController, ), const SizedBox(height: 40), AuthButton( label: "サインアップ", onPressed: () { // サインアップ処理発火 vmNotifier.signUp( usernameController.text, emailController.text, passwordController.text, ); }, ), const SizedBox(height: 16), AuthButton( label: "サインイン", onPressed: () { // サインイン処理発火 vmNotifier.signIn( emailController.text, passwordController.text, ); }, ), ], ), ); } Widget _buildLoadingOrErrorDialog(AsyncValue<bool> vm) { return vm.when( // 状態(state)がローディング中の場合 loading: () => const Center( child: CircularProgressIndicator(strokeWidth: 6.0, color: Colors.grey,), ), // 状態(state)がエラーの場合 error: (error, _) { // レンダリング完了後にエラーダイアログを表示 WidgetsBinding.instance.addPostFrameCallback((_) { CommonErrorDialog.show(exception: error as CustomException); }); return Container(); }, // 状態(state)がtrue(認証成功)の場合 data: (didAuthenticate) { if (didAuthenticate) { // レンダリング完了後にToDoリスト画面へ遷移 WidgetsBinding.instance.addPostFrameCallback((_) { navigationKey.currentContext?.push(AppRoute.todoList.path); }); } return Container(); }, ); } }
Todoリスト画面を作成
以下のUIを実装する。
-
Todoリスト一覧表示
-
Todoリスト一覧再取得ボタン
-
Todoの削除
-
Todoの追加(ダイアログで表示)
-
Todoの編集(ダイアログで表示)
-
lib/screens/todo_list_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify_app/config/routes/routes.dart'; import 'package:spotify_app/model/to_do_model.dart'; import 'package:spotify_app/providers/to_do_view_model.dart'; import 'package:spotify_app/widgets/common_app_bar.dart'; import 'package:spotify_app/widgets/to_do_dialog.dart'; import '../model/custom_exception.dart'; import '../widgets/common_error_dialog.dart'; class TodoListScreen extends ConsumerWidget { static TodoListScreen builder( BuildContext context, GoRouterState state, ) => const TodoListScreen(); const TodoListScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // List<ToDoModel>の値、ローディング中状態、エラー状態を監視 final vm = ref.watch(toDoViewModelProvider); // ToDoViewModelのインスタンス final vmNotifier = ref.read(toDoViewModelProvider.notifier); return Scaffold( appBar: CommonAppBar( title: "Todoリスト", actions: [ TextButton( onPressed: () => { vmNotifier.fetchToDos() }, // ToDo全件取得処理を発火 child: const Center(child: Text('再取得', style: TextStyle(fontSize: 16, color: Colors.purple),),) ) ], ), body: Stack( children: [ _todoList( vm.value ?? [], vmNotifier ), _buildLoadingOrErrorDialog(vm), ], ), floatingActionButton: FloatingActionButton( onPressed: () { // Todo新規作成ダイアログを表示 showAddDialog(vmNotifier); // ToDoViewModelを渡す }, child: Icon(Icons.add), ), ); } Widget _todoList( List<ToDoModel> todos, ToDoViewModel vmNotifier ) { return ListView.builder( itemCount: todos.length, itemBuilder: (context, index) { final todo = todos[index]; return ListTile( title: Text( todo.title, style: TextStyle( decoration: todo.isCompleted ? TextDecoration.lineThrough : null, ), ), leading: Icon( Icons.check, size: 32, color: todo.isCompleted ? Colors.green : Colors.black26, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon(Icons.edit), onPressed: () { // Todo編集ダイアログを表示 showEditDialog(vmNotifier, todo); }, ), IconButton( icon: Icon(Icons.delete), onPressed: () { // Todo削除処理を発火 vmNotifier.removeTodo(todo.id); }, ), ], ), ); }, ); } Widget _buildLoadingOrErrorDialog(AsyncValue<List<ToDoModel>> vm) { return vm.when( // 状態(state)がローディング中の場合 loading: () => const Center( child: CircularProgressIndicator(strokeWidth: 6.0, color: Colors.grey,), ), // 状態(state)がエラーの場合 error: (error, _) { // レンダリング完了後にエラーダイアログを表示 WidgetsBinding.instance.addPostFrameCallback((_) { CommonErrorDialog.show(exception: error as CustomException); }); return Container(); }, // 空のWidgetを返す data: (_) => Container(), ); } }
認証画面、Todoリスト画面で使用するWidget
-
lib/widgets/auth_button.dart
import 'package:flutter/material.dart'; class AuthButton extends StatelessWidget { final String label; final VoidCallback onPressed; final Color color; const AuthButton({ Key? key, required this.label, required this.onPressed, this.color = Colors.indigo, }) : super(key: key); @override Widget build(BuildContext context) { return ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16.0), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), backgroundColor: color, ), child: Text( label, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, ), ), ); } }
-
lib/widgets/auth_text_field.dart
import 'package:flutter/material.dart'; class AuthTextField extends StatelessWidget { final String hintText; final IconData icon; final bool obscureText; final TextEditingController? controller; const AuthTextField({ Key? key, required this.hintText, required this.icon, this.obscureText = false, this.controller, }) : super(key: key); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: TextField( controller: controller, obscureText: obscureText, decoration: InputDecoration( hintText: hintText, prefixIcon: Icon(icon, color: Colors.indigo), filled: true, fillColor: Colors.indigo[50], border: OutlineInputBorder( borderRadius: BorderRadius.circular(12.0), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12.0), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12.0), borderSide: const BorderSide(color: Colors.indigo), ), ), ), ); } }
-
lib/widgets/common_app_bar.dart
import 'package:flutter/material.dart'; import '../config/routes/routes_provider.dart'; class CommonAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; final List<Widget>? actions; final Color backgroundColor; final Widget? leading; const CommonAppBar({ Key? key, required this.title, this.actions, this.backgroundColor = Colors.blue, this.leading, }) : super(key: key); @override Widget build(BuildContext context) { return AppBar( title: Text(title), backgroundColor: backgroundColor, actions: actions, leading: leading ); } @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); // AppBarの高さ }
-
lib/widgets/common_error_dialog.dart
import 'package:flutter/material.dart'; import 'package:spotify_app/config/routes/routes.dart'; import 'package:spotify_app/model/custom_exception.dart'; class CommonErrorDialog { static Future<void> show({ required CustomException exception, }) { return showDialog( context: navigationKey.currentContext!, builder: (_) { return AlertDialog( title: const Text('エラー'), content: Text('${exception.message}\n\n${exception.message}'), actions: <Widget>[ TextButton( child: const Text("確認"), onPressed: () => Navigator.pop(navigationKey.currentContext!), ), ], ); }, ); } }
-
lib/widgets/to_do_dialog.dart
import 'package:flutter/material.dart'; import 'package:spotify_app/config/routes/routes.dart'; import 'package:spotify_app/model/to_do_model.dart'; import 'package:spotify_app/providers/to_do_view_model.dart'; void showAddDialog(ToDoViewModel vmNotifier) { final TextEditingController controller = TextEditingController(); showDialog( context: navigationKey.currentContext!, builder: (context) { return AlertDialog( title: Text('追加'), content: TextField( controller: controller, decoration: InputDecoration(hintText: 'タイトル'), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('キャンセル'), ), TextButton( onPressed: () { // ToDo新規作成処理を発火 vmNotifier.addTodo(controller.text); Navigator.of(context).pop(); }, child: Text('追加'), ), ], ); }, ); } void showEditDialog(ToDoViewModel vmNotifier, ToDoModel todo) { final TextEditingController controller = TextEditingController(text: todo.title); bool isCompleted = todo.isCompleted; showDialog( context: navigationKey.currentContext!, builder: (context) { return AlertDialog( title: Text('編集'), content: StatefulBuilder( builder: (context, setState) { return Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: controller, decoration: InputDecoration(hintText: 'タイトル'), ), Row( children: [ Text('完了:'), Switch( value: isCompleted, onChanged: (value) { setState(() { isCompleted = value; // トグルボタンの状態変更をUIに反映 }); }, ), ], ), ], ); }, ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('キャンセル'), ), TextButton( onPressed: () { // ToDo更新処理を発火 vmNotifier.updateTodo( todo.id, controller.text, isCompleted, // 完了状態を渡す ); Navigator.of(context).pop(); }, child: Text('更新'), ), ], ); }, ); }
Flutter Secure Storage
Golangから受け取ったJWTTokenを端末のローカルに保存する
-
lib/config/storage
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; class SecureStorage { FlutterSecureStorage storage = const FlutterSecureStorage(); save(String key, String data) async { await storage.write(key: key, value: data); } Future<String> load(String key) async { //tokenがない場合はnull return (await storage.read(key: key) ?? ""); } delete(String key) async { //tokenがない場合はnull await storage.write(key: key, value: ""); } Future<String> getValue(String key) async { var value = await storage.read(key: key) ?? ""; return value; } } class Config { //api timeout時間 static Duration apiDuration = const Duration(seconds: 35); //api取得後のログインtoken保存場所のkey static String secureStorageJwtTokenKey = "jwt-token-key"; //api取得後のユーザーネーム保存場所のkey static String secureStorageUserKey = "user-key"; }