ゲーム開発
Unity
UnrealEngine
C++
ゲーム数学
ゲームAI
サウンド
アニメーション
GBDK
制作日記
3DCG
Houdini
Blender
USD
グラフィックス
テクノロジ
ツール開発
フロントエンド関連
サーバサイド関連
ソフトウェア設計
ハードウェア関連
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
その他
都会のエレキベア
ラーメン日記
四コマ漫画
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • ゲーム数学
    • ゲームAI
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • 3DCG
    • Houdini
    • Blender
    • USD
    • グラフィックス
  • テクノロジ
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • ソフトウェア設計
    • ハードウェア関連
    • おすすめ技術書
  • 音楽
    • 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
記事をSNSで共有する
X
Facebook
LINE
はてなブックマーク
Pocket
LinkedIn
Reddit

著者の各種アカウント
フォローいただけると大変励みになります!

関連記事
【VSCode】ドラッグ&ドロップで画像ファイルをリサイズ・保存する拡張機能を作る
2025-11-22
【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