ゲーム開発
Unity
UnrealEngine
C++
Blender
ゲーム数学
ゲームAI
グラフィックス
サウンド
アニメーション
GBDK
制作日記
IT関連
ツール開発
フロントエンド関連
サーバサイド関連
WordPress関連
ソフトウェア設計
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
ラーメン日記
四コマ漫画
その他
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • Blender
    • ゲーム数学
    • ゲームAI
    • グラフィックス
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • IT関連
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • WordPress関連
    • ソフトウェア設計
    • おすすめ技術書
  • 音楽
    • DTM
    • 楽器・機材
    • ピアノ
  • ラーメン日記
    • 四コマ漫画
      • その他
        • おすすめアイテム
        • おもしろコラム
      1. ホーム
      2. 20221210_01

      【Flutter3】Googleスプレッドシートと連携した英単語学習アプリを作る

      ツール開発アプリ開発(Unity以外)FlutterFlutter
      2022-12-11

      マイケル
      マイケル
      みなさんこんにちは!
      マイケルです!
      エレキベア
      エレキベア
      こんにちクマ〜〜〜
      マイケル
      マイケル
      今回は気分転換でFlutterを触ってみました。
      作ったのは下記のような、シンプルな英単語学習アプリになります!
      ↑英単語学習アプリ
      エレキベア
      エレキベア
      Flutter久しぶりクマ〜〜〜
      マイケル
      マイケル
      内容としては単純なものになりますが、データはGoogleスプレッドシート上で作成したものをAPI経由で取得するようにしています!
      エレキベア
      エレキベア
      DBの代わりにスプレッドシートを使った感じクマね
      マイケル
      マイケル
      今回はこのアプリの実装内容について解説していこうと思います!
      ソースコードの方はGitHubにも上げていますので、こちらもよければご参照ください!

      GitHub – masarito617/flutter-english-study-app-sample

      エレキベア
      エレキベア
      この量だとコードを読むのが一番手っ取り早そうクマね
      マイケル
      マイケル
      なお、Flutterのバージョンは3.3.9を使用しています。
      バージョンによって差異が生じる可能性があるため、そちらはご了承ください!

      作成するアプリ

      画面構成

      マイケル
      マイケル
      今回作成したアプリは大きく
      ・タイトル画面
      ・単語選択画面

      の2つの画面から出来ています。
      タイトル画面
      単語選択画面
      マイケル
      マイケル
      英単語の日本語訳を4択から選ぶ構成となっています。
      エレキベア
      エレキベア
      シンプルなクイズ形式のアプリクマね
      Googleスプレッドシート
      マイケル
      マイケル
      そして一点工夫した点としては、英単語データはGoogleスプレッドシート上から読み込むようにしたことです。
      下記のように英単語と日本語訳を設定しておくことで読み込むことができます。
      ↑英単語データはGoogleスプレッドシートから読み込む
      エレキベア
      エレキベア
      これなら手軽に問題の編集や追加が行えそうクマね
      マイケル
      マイケル
      読込はGoogle Sheets APIというAPIを使用していました。
      こちらも詳細は後ほど記載します!

      フォルダ構成

      マイケル
      マイケル
      ソースコードのフォルダ構成は下記のようにしています。
      main.dartとpages配下のdartファイルがメインとなります。
      lib
      ├── components
      │   └── layout_widgets.dart
      ├── data
      │   └── english_word_data.dart
      ├── pages
      │   ├── select_word_page.dart
      │   └── title_page.dart
      ├── settings
      │   └── googleapi_settings.dart
      └── main.dart
      ↑ソースコードのフォルダ構成
      エレキベア
      エレキベア
      このボリュームだとコンパクトクマね

      タイトル画面

      マイケル
      マイケル
      それでは実装を見ていきます。
      まずはタイトル画面の実装になります。
      ↑タイトル画面

      タイトル画面全体の実装

      マイケル
      マイケル
      main.dartファイルが処理の起点となっていて、ここで全体のテーマを設定しています。
      homeにtitle_page.dartのTitlePage()を指定することでタイトル画面を表示します。
      import 'package:flutter/material.dart';
      import 'pages/title_page.dart';
      void main() {
        runApp(const MyApp());
      }
      class MyApp extends StatelessWidget {
        const MyApp({super.key});
        @override
        Widget build(BuildContext context) {
          return MaterialApp(
            title: 'えいすた',
            debugShowCheckedModeBanner: false,
            theme: ThemeData(
              brightness: Brightness.dark,
              primaryColor: Colors.lime,
            ),
            home: const TitlePage(),
          );
        }
      }
      
      ↑main処理には全体のテーマを設定
      エレキベア
      エレキベア
      アプリ全体の設定をしているクマね
      マイケル
      マイケル
      TitlePageはStatefulWidgetとして定義していて、大枠となるWidget構成やボタン押下時の処理を記述しています。
      import 'package:flutter/material.dart';
      import 'select_word_page.dart';
      import '../components/layout_widgets.dart';
      import '../data/english_word_data.dart';
      /// タイトル画面
      class TitlePage extends StatefulWidget {
        const TitlePage({super.key});
        @override
        State<TitlePage> createState() => _TitlePageState();
      }
      class _TitlePageState extends State<TitlePage> {
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(
              title: const Text('えいすた!'),
              leading: Icon(
                Icons.book,
                color: Theme.of(context).primaryColor,
              ),
            ),
            body: Center(
              child: Column(
                children: [
                  HalfScreenArea(
                    child: Center(
                      child: TitleTextAreaWidget(
                        errorMessage: _errorMessage,
                      ),
                    ),
                  ),
                  HalfScreenArea(
                    child: Center(
                      child: TitleButtonAreaWidget(
                        isOptionShuffle: _isOptionShuffle,
                        onChangedOptionShuffleCheckBox:
                            onChangedOptionShuffleCheckBox,
                        onPressedStartButton: onPressedStartButton,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          );
        }
      ・・・
      }
      ・・・
      
      ↑タイトル画面をStatefulWidgetとして定義
      マイケル
      マイケル
      HalfScreenAreaというのは画面半分サイズのエリアを取得するよう、共通のWidgetとして定義したものです。
      こちらは問題選択画面でも使用します。
      import 'package:flutter/material.dart';
      /// 画面半分サイズのエリア
      class HalfScreenArea extends StatelessWidget {
        final Widget child;
        const HalfScreenArea({super.key, required this.child});
        @override
        Widget build(BuildContext context) {
          // デバイスの高さを取得して設定
          final double deviceHeight = MediaQuery.of(context).size.height;
          return Expanded(
            flex: 1,
            child: SizedBox(
              height: deviceHeight,
              child: child,
            ),
          );
        }
      }
      
      ↑画面半分のエリア定義
      エレキベア
      エレキベア
      画面を半々で作っているクマね
      マイケル
      マイケル
      上半分をタイトルテキストエリア、下半分をタイトルボタンエリアとしてStatelessWidgetとして分割しています。
      こちらパラメータやボタン押下処理を受け取って設定しているだけですね。
      /// タイトルテキストエリア
      class TitleTextAreaWidget extends StatelessWidget {
        final String errorMessage; // エラーメッセージ
        const TitleTextAreaWidget({super.key, required this.errorMessage});
        @override
        Widget build(BuildContext context) {
          return Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // タイトル
              const Text(
                'Let\'s English!!',
                style: TextStyle(
                  fontSize: 24,
                ),
              ),
              const SizedBox(height: 20),
              // エラーメッセージ
              SizedBox(
                height: 28,
                child: Text(
                  errorMessage,
                  style: const TextStyle(
                    fontSize: 16,
                    color: Colors.red,
                  ),
                ),
              ),
              const SizedBox(height: 40),
            ],
          );
        }
      }
      /// タイトルボタンエリア
      class TitleButtonAreaWidget extends StatelessWidget {
        final bool isOptionShuffle;
        final Function onChangedOptionShuffleCheckBox;
        final Function onPressedStartButton;
        const TitleButtonAreaWidget(
            {super.key,
            required this.isOptionShuffle,
            required this.onChangedOptionShuffleCheckBox,
            required this.onPressedStartButton});
        @override
        Widget build(BuildContext context) {
          return Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // シャッフルオプション
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Checkbox(
                    value: isOptionShuffle,
                    onChanged: (isOn) => onChangedOptionShuffleCheckBox(isOn),
                  ),
                  const Text(
                    '問題をシャッフルする',
                    style: TextStyle(
                      fontSize: 16,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 20),
              // STARTボタン
              SizedBox(
                width: 120,
                height: 60,
                child: ElevatedButton(
                  style: ElevatedButton.styleFrom(
                    textStyle: const TextStyle(fontSize: 20),
                    foregroundColor: Theme.of(context).backgroundColor,
                    backgroundColor: Theme.of(context).primaryColor,
                  ),
                  onPressed: () => onPressedStartButton(),
                  child: const Text('START'),
                ),
              ),
            ],
          );
        }
      }
      
      ↑WidgetはそれぞれStatelessWidgetとして分離
      エレキベア
      エレキベア
      一つにまとめて書いてしまうとかなり長くなってしまうクマからね

      ボタン押下処理

      マイケル
      マイケル
      各ボタンの押下処理は下記のようになっています。
      シャッフルするかのチェックボックスは単純にフラグを切り替えるだけで、STARTボタン押下時には英単語データを取得して単語選択ページに遷移するよう実装しています。
      ・・・
      class _TitlePageState extends State<TitlePage> {
      ・・・
        bool _isOptionShuffle = false; // シャッフルするか?
        String _errorMessage = ""; // エラーメッセージ
        bool _isDoProcess = false; // 処理中か?
        /// シャッフルチェックボックス切替時
        void onChangedOptionShuffleCheckBox(bool isOn) {
          setState(() {
            _isOptionShuffle = isOn;
          });
        }
        /// STARTボタン押下時
        void onPressedStartButton() async {
          if (_isDoProcess) return;
          _isDoProcess = true;
          setState(() => _errorMessage = "");
          // データ取得
          var englishWordDataList =
              await EnglishWordDataRepository.getEnglishWordDataListFromApi();
          if (englishWordDataList.isEmpty) {
            setState(() => _errorMessage = "データが取得できません");
            _isDoProcess = false;
            return;
          }
          // データを渡してページ遷移
          if (!mounted) return;
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => SelectWordPage(
                englishWordDataList: englishWordDataList,
                isOptionShuffle: _isOptionShuffle,
              ),
            ),
          );
          _isDoProcess = false;
        }
      }
      ・・・
      
      ↑各ボタンの処理
      エレキベア
      エレキベア
      setStateで状態を変更するために、
      StatefullWidget内で処理を記述する必要があるクマね
      マイケル
      マイケル
      アクションも分離する方法はあるだろうけど、この量ならこれでも充分そうだね
      英単語データの取得処理についてはこの後解説します!

      Gooogleスプレッドシートからのデータ取得

      マイケル
      マイケル
      Googleスプレッドシートからデータを取得する処理に関しては下記のように実装しています。
      一般的なAPI取得の処理で、指定URLからJSONレスポンスを取得する実装となっています。
      import 'dart:convert';
      import 'package:http/http.dart' as http;
      import '../settings/googleapi_settings.dart';
      /// 英単語データ
      class EnglishWordData {
        EnglishWordData({required this.englishWord, required this.japaneseWord});
        final String englishWord; // 英単語
        final String japaneseWord; // 日本語
      }
      /// 英単語データリポジトリ
      class EnglishWordDataRepository {
        /// APIからのデータ取得
        static Future<List<EnglishWordData>> getEnglishWordDataListFromApi() async {
          final List<EnglishWordData> result = [];
          try {
            // レスポンス取得
            final url = GoogleApiSettings.createGoogleSheetsApiGetUrl();
            final res = await http.get(Uri.parse(url));
            if (res.statusCode != 200) {
              throw ('Failed to Load English Word Data.');
            }
            // 中身のチェック
            final Map<String, dynamic> message = json.decode(res.body);
            if (message['values'] == null) {
              throw ('Please Set English Word Data.');
            }
            // 英単語データとして変換
            final List<dynamic> values = message['values'];
            values.forEach((value) => {
                  result.add(
                      EnglishWordData(englishWord: value[0], japaneseWord: value[1]))
                });
          } catch (e) {
            print(e); // エラーはログに出力して握りつぶす
          }
          return result;
        }
      }
      
      ↑英単語データの取得処理
      マイケル
      マイケル
      リクエストにはhttpパッケージが必要なため、pubspac.yamlに指定してflutter pub getしておく必要があります。
      dependencies:
        flutter:
          sdk: flutter
        http: ^0.13.5
      ↑httpパッケージを追加
      エレキベア
      エレキベア
      API実行は定番クマね
      マイケル
      マイケル
      そして肝心のデータ取得のURLについてですが、今回はGoogle Sheets APIの
      https://sheets.googleapis.com/v4/spreadsheets/${spreadsSheetsUrl}/values/${sheetName}?key=${apiKey}

      の形式でリクエストしています。
      settings/googleapi_settings.dartに各値からURLを生成するようにしてあるので、もし動かす場合はこちらに各自のスプレッドシートID、シート名、APIキーを設定しましょう。
      Google Sheets API | Google Developers
      class GoogleApiSettings {
        // この辺は各自設定してください
        static const String spreadsSheetsUrl = "";
        static const String sheetName = "";
        static const String apiKey = "";
        /// GoogleSheetsAPI(v4.spreadsheets.values - get)のURL生成
        /// 詳細: https://developers.google.com/sheets/api/reference/rest
        static String createGoogleSheetsApiGetUrl() {
          if (spreadsSheetsUrl.isEmpty || sheetName.isEmpty || apiKey.isEmpty) {
            throw ('please set google api settings.');
          }
          return 'https://sheets.googleapis.com/v4/spreadsheets/${spreadsSheetsUrl}/values/${sheetName}?key=${apiKey}';
        }
      }
      
      ↑Google Sheets API のURL生成
      エレキベア
      エレキベア
      この辺は各々設定する必要があるクマね
      マイケル
      マイケル
      それぞれの取得方法について簡単に書いておくと、
      ・スプレッドシートID
       →スプレッドシートを公開した際のURLから取得
      ・シート名
       →スプレッドシートで読み込むシート名
      ・APIキー
       →Google Cloudでプロジェクトを生成してGoogle Sheets APIを有効化して認証情報から生成
      からそれぞれ取得することができます。
      ↑スプレッドシートIDは共有URLから取得
      ↑Google Cloudで新規プロジェクトを作成
      ↑Google Sheets APIを有効化
      ↑APIキーを生成
      エレキベア
      エレキベア
      これならサクッと作れそうクマね

      単語選択画面

      マイケル
      マイケル
      そして次は単語選択画面です!
      こちらは画面内で、
      ・選択前
      ・選択後
      ・結果表示
      の3つの表示状態に切り替わる内容になっています。
      エレキベア
      エレキベア
      状態を管理して表示を分ける必要があるクマね

      単語選択画面全体の実装

      マイケル
      マイケル
      大枠のWidget表示については下記のようになっています。
      Enum型でQuestionDisplayStateとして状態を管理し、その状態を監視して表示を切り替えています。
      import 'package:flutter/material.dart';
      import '../components/layout_widgets.dart';
      import '../data/english_word_data.dart';
      /// 問題の表示状態
      enum QuestionDisplayState {
        none,
        ok,
        ng,
        result,
      }
      /// 単語選択画面
      class SelectWordPage extends StatefulWidget {
        final List<EnglishWordData> englishWordDataList;
        final bool isOptionShuffle;
        const SelectWordPage(
            {super.key,
            required this.englishWordDataList,
            required this.isOptionShuffle});
        @override
        State<SelectWordPage> createState() => _SelectWordPageState();
      }
      class _SelectWordPageState extends State<SelectWordPage> {
        // 英単語データリスト
        List<EnglishWordData> _englishWordDataList = [];
        // 問題の表示状態
        QuestionDisplayState _questionDisplayState = QuestionDisplayState.none;
        // 問題データ
        EnglishWordData? _questionWordData;
        int _questionIndex = 0;
        int _okAnswerCount = 0;
        // 選択単語リスト
        List<String> _selectWordList = [];
        // 選択単語数
        static const int selectWordCount = 4;
      ・・・
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(
              title: const Text(''),
            ),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: displaySelectWordPageWidgetList(),
              ),
            ),
          );
        }
        List<Widget> displaySelectWordPageWidgetList() {
          // 結果表示
          if (_questionDisplayState == QuestionDisplayState.result) {
            return [
              SelectWordResultAreaWidget(
                totalIndex: _englishWordDataList.length,
                correctCount: _okAnswerCount,
              ),
            ];
          }
          // 問題表示
          return [
            HalfScreenArea(
              child: SelectWordQuestionAreaWidget(
                question: _questionWordData?.englishWord ?? 'empty',
                answer: _questionWordData?.japaneseWord ?? 'empty',
                index: _questionIndex + 1,
                totalIndex: _englishWordDataList.length,
                questionDisplayState: _questionDisplayState,
              ),
            ),
            HalfScreenArea(
              child: SelectWordButtonsAreaWidget(
                selectWordList: _selectWordList,
                onPressedSelectWordButton: onPressedSelectWordButton,
                isShowNext: _questionDisplayState == QuestionDisplayState.ok ||
                    _questionDisplayState == QuestionDisplayState.ng,
                onPressedNextButton: onPressedNextButton,
              ),
            ),
          ];
        }
      ・・・
      }
      ・・・
      
      ↑QuestionDisplayStateの状態によって表示するWidgetを切り替える
      エレキベア
      エレキベア
      それぞれのWidgetについてはまた分割しているクマね

      選択前の初期表示

      マイケル
      マイケル
      まずは選択前の初期表示から見ていきます。
      ↑選択前の表示
      マイケル
      マイケル
      こちらはinitState内でタイトル画面から受け取った英単語データから問題と選択リストを生成しています。
        @override
        void initState() {
          super.initState();
          // 遷移元からデータを受け取る
          _englishWordDataList = widget.englishWordDataList;
          // オプション指定されていたらシャッフルする
          if (widget.isOptionShuffle) {
            _englishWordDataList.shuffle();
          }
          // 最初の問題を生成
          _questionIndex = 0;
          _okAnswerCount = 0;
          createQuestion(_questionIndex);
        }
        /// 問題の生成
        void createQuestion(int index) {
          _questionDisplayState = QuestionDisplayState.none;
          _questionWordData = _englishWordDataList[index];
          _selectWordList =
              createRandomSelectWordList(_questionWordData?.japaneseWord ?? 'empty');
        }
        /// 単語選択リスト生成
        List<String> createRandomSelectWordList(String answer) {
          // 単語データをコピー
          var copyEnglishWordDataList = List.of(_englishWordDataList);
          copyEnglishWordDataList.shuffle();
          // 選択する単語リストを生成
          List<String> selectWordList = [];
          selectWordList.add(answer);
          for (var i = 0; i < copyEnglishWordDataList.length; i++) {
            var japaneseWord = copyEnglishWordDataList[i].japaneseWord;
            // 答えとなる日本語は除く
            if (answer == japaneseWord) {
              continue;
            }
            // 指定数設定したら抜ける
            selectWordList.add(japaneseWord);
            if (selectWordList.length >= selectWordCount) {
              break;
            }
          }
          selectWordList.shuffle();
          return selectWordList;
        }
      
      ↑問題と選択リストの生成
      マイケル
      マイケル
      選択リストに関しては、答えとなる日本語に加えてランダムで選択した日本語を加えて4つ分生成するようにしてあります。
      エレキベア
      エレキベア
      選択できる日本語も設定したデータから適当に選んでいるクマね
      マイケル
      マイケル
      問題の表示部分に関してもタイトル画面と同様、画面半分ずつに区切って表示しています。
      上半分を問題文エリア、下半分を単語選択ボタンエリアとして定義しました。
      問題文に関しては下記のように受け取ったパラメータの表示に加えて、stateも受け取ることで答えも表示できるよう対応しています。
      /// 問題文エリア
      class SelectWordQuestionAreaWidget extends StatelessWidget {
        final String question;
        final String answer;
        final int index;
        final int totalIndex;
        final QuestionDisplayState questionDisplayState;
        const SelectWordQuestionAreaWidget(
            {super.key,
            required this.question,
            required this.answer,
            required this.index,
            required this.totalIndex,
            required this.questionDisplayState});
        @override
        Widget build(BuildContext context) {
          return Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              // 問題数
              Padding(
                padding: const EdgeInsets.all(24),
                child: Text(
                  '$index / $totalIndex',
                  style: const TextStyle(
                    fontSize: 20,
                  ),
                ),
              ),
              Expanded(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    const SizedBox(height: 16),
                    // 問題文
                    Text(
                      question,
                      style: const TextStyle(
                        fontSize: 28,
                      ),
                    ),
                    const SizedBox(height: 8),
                    // 結果表示
                    getAnsewerWordTextWidget(answer, questionDisplayState),
                    const SizedBox(height: 32),
                    getAnsewerResultTextWidget(questionDisplayState),
                  ],
                ),
              ),
            ],
          );
        }
        Widget getAnsewerWordTextWidget(String answer, QuestionDisplayState state) {
          var color = Colors.white;
          if (state == QuestionDisplayState.none) {
            color = color.withOpacity(0.0);
          }
          return Text(
            answer,
            style: TextStyle(
              fontSize: 18,
              color: color,
            ),
          );
        }
        Widget getAnsewerResultTextWidget(QuestionDisplayState state) {
          var message = "";
          var color = Colors.black;
          switch (state) {
            case QuestionDisplayState.ok:
              message = "○";
              color = Colors.red;
              break;
            case QuestionDisplayState.ng:
              message = "×";
              color = Colors.blue;
              break;
            case QuestionDisplayState.none:
            case QuestionDisplayState.result:
              break;
          }
          return SizedBox(
            height: 42, // 文字の内容に限らず高さを固定
            child: Text(
              message,
              style: TextStyle(
                fontSize: 32,
                color: color,
              ),
            ),
          );
        }
      }
      
      ↑状態に応じて答えの表示可否を切り替える
      ↑選択後には答えを表示する
      マイケル
      マイケル
      単語選択ボタン群に関しても、状態からボタンの活性可否やNEXTボタンの表示可否を切り替えるようにしています。
      選択時には選んだ日本語をパラメータとして押下処理に渡すようにしています。
      /// 単語選択ボタンエリア
      class SelectWordButtonsAreaWidget extends StatelessWidget {
        final List<String> selectWordList;
        final Function onPressedSelectWordButton;
        final bool isShowNext;
        final Function onPressedNextButton;
        const SelectWordButtonsAreaWidget(
            {super.key,
            required this.selectWordList,
            required this.onPressedSelectWordButton,
            required this.isShowNext,
            required this.onPressedNextButton});
        @override
        Widget build(BuildContext context) {
          return Column(children: [
            // 単語選択ボタン群
            Expanded(
              flex: 5,
              child: GridView.count(
                physics: const NeverScrollableScrollPhysics(),
                mainAxisSpacing: 20,
                crossAxisSpacing: 20,
                padding: const EdgeInsets.all(50.0),
                childAspectRatio: 2.5,
                crossAxisCount: 2,
                scrollDirection: Axis.vertical,
                children: selectWordList
                    .map((selectWord) => SelectWordButtonWidget(
                          text: selectWord,
                          onPressed: isShowNext ? null : onPressedSelectWordButton,
                        ))
                    .toList(),
              ),
            ),
            // NEXTボタン
            if (isShowNext)
              Expanded(
                flex: 3,
                child: Align(
                  alignment: Alignment.topRight,
                  child: Padding(
                    padding: const EdgeInsets.only(right: 48),
                    child: TextButton(
                      onPressed: () => onPressedNextButton(),
                      child: Text(
                        'NEXT ▶︎',
                        style: TextStyle(
                          fontSize: 18,
                          color: Theme.of(context).primaryColor,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
          ]);
        }
      }
      /// 単語選択ボタン
      class SelectWordButtonWidget extends StatelessWidget {
        final String text;
        final Function? onPressed;
        const SelectWordButtonWidget(
            {super.key, required this.text, required this.onPressed});
        @override
        Widget build(BuildContext context) {
          return ElevatedButton(
            style: ElevatedButton.styleFrom(
              textStyle: const TextStyle(fontSize: 14),
              foregroundColor: Theme.of(context).backgroundColor,
              backgroundColor: Theme.of(context).primaryColor,
            ),
            onPressed: onPressed == null ? null : () => onPressed!(text),
            child: Text(text),
          );
        }
      }
      
      ↑単語選択ボタンの表示
      エレキベア
      エレキベア
      StatelessWidgetでも親から状態を受け取ることで表示を切り替えることができるクマね

      選択処理

      マイケル
      マイケル
      選択処理に関しては、選んだ日本語が合っているかどうかの答え合わせをしています。
      NEXTボタンを押下した際には次の問題へ進み、最後の問題の場合には最終的な結果を表示します。
      class _SelectWordPageState extends State<SelectWordPage> {
      ・・・
        /// 単語ボタン押下処理
        void onPressedSelectWordButton(String selectWord) {
          // 答え合わせ
          setState(() {
            var isCorrect = selectWord == _questionWordData?.japaneseWord;
            if (isCorrect) _okAnswerCount++;
            _questionDisplayState =
                isCorrect ? QuestionDisplayState.ok : QuestionDisplayState.ng;
          });
        }
        /// NEXTボタン押下処理
        void onPressedNextButton() {
          setState(() {
            // 最後まで問題を出したら結果表示
            _questionIndex++;
            if (_englishWordDataList.length <= _questionIndex) {
              _questionDisplayState = QuestionDisplayState.result;
              return;
            }
            // 次の問題を表示
            createQuestion(_questionIndex);
          });
        }
      }
      ・・・
      
      エレキベア
      エレキベア
      この辺はシンプルクマね

      結果表示

      マイケル
      マイケル
      そして最終的な結果表示に関しては、カウントした正解数を表示させているだけになります。
      ↑最終的な結果表示
      /// 結果表示
      class SelectWordResultAreaWidget extends StatelessWidget {
        final int totalIndex;
        final int correctCount;
        const SelectWordResultAreaWidget(
            {super.key, required this.totalIndex, required this.correctCount});
        @override
        Widget build(BuildContext context) {
          return Center(
            child: Column(
              children: [
                Text(
                  '正解数: $correctCount / $totalIndex',
                  style: const TextStyle(
                    fontSize: 24,
                  ),
                ),
                const SizedBox(height: 40),
                SizedBox(
                  width: 120,
                  height: 60,
                  child: ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      textStyle: const TextStyle(fontSize: 20),
                      foregroundColor: Theme.of(context).backgroundColor,
                      backgroundColor: Theme.of(context).primaryColor,
                    ),
                    onPressed: () {
                      Navigator.pop(context);
                    },
                    child: const Text('BACK'),
                  ),
                ),
              ],
            ),
          );
        }
      }
      
      エレキベア
      エレキベア
      この辺は説明不要クマね
      マイケル
      マイケル
      アプリ全体の解説は以上になります!
      流れについては大体分かったのではないでしょうか!

      おわりに

      マイケル
      マイケル
      というわけで今回はFlutterで簡単なアプリを作ってみました!
      どうだったかな??
      エレキベア
      エレキベア
      何も考えなくてもある程度UIが綺麗になるのと、
      これだけでアプリが作れるのは素晴らしいと思ったクマ〜〜
      マイケル
      マイケル
      Flutter感出まくりだけど、パッと作れるから本当楽だね!
      ただUI部分のコードが作り上どうしてもインデントが深くなってしまうから、整理してなるべく可読性はよくしておきたいなと思ったよ
      エレキベア
      エレキベア
      この規模ならいいクマが、
      大きなアプリを作る場合はアーキテクチャもちゃんと調べた方がよさそうクマね
      マイケル
      マイケル
      楽しかったからまた触ってみよう!
      それでは今日はこの辺で!!
      アデューー!!
      エレキベア
      エレキベア
      クマ〜〜〜〜

      【Flutter3】Googleスプレッドシートと連携した英単語学習アプリを作る 〜完〜

      ツール開発アプリ開発(Unity以外)FlutterFlutter
      2022-12-11

      関連記事
      【Unity】Timeline × Excelでスライドショーを効率よく制作する
      2024-10-31
      【Node.js】廃止されたAmazonアソシエイト画像リンクをAmazon Product Advertising API経由で復活させる
      2024-01-08
      【Electron × Vue3】カテゴリ情報のCSVデータを操作するツールを作る
      2023-12-31
      【Electron × Vue3】画像をリサイズして任意の場所に保存するツールを作る
      2023-12-31
      【Electron × Vue3】Electron × Vue3 × TypeScript × Vite でツール開発環境を整える
      2023-12-31
      【Python】Pythonスクリプトをexe、app化する【cx_Breeze】
      2021-08-29
      【Python】Pillowを使ってピクセル操作!画像フィルタをかけてみる
      2021-02-17
      【FFmpeg】shellコマンドでファイル形式変換!動画をGIFに変換してみる
      2020-11-09