0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

環境構築、認証画面と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";
    }
    
    

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?