Go(JWT認証)✖️RiverpodでTodoアプリを作ってみたPart3（Flutter環境構築＆画面作成 編）

完全自分用の学習備忘録。

詳細な解説等はコード内でコメントアウトしております。

環境構築、認証画面とToDoリスト画面を作成していきます。

### 環境構築

適当なFlutterプロジェクトを作成する。（詳細な手順は省きます。他記事を参照ください🙇）

- pubspec.yaml
    
    ```go
    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
    
    ```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
    
    ```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
    
    ```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
    
    ```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` 
    
    ```dart
    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
    
    ```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
    
    ```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
    
    ```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
    
    ```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
    
    ```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
    
    ```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
    
    ```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
    
    ```dart
    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";
    }
    
    ```
    

###