GPT-4VでFlutterの計算機アプリを依頼したら凄かった

# はじめに

以下の記事を読んで衝撃的だったので、私もFlutterで計算機アプリを作成してみました。

https://qiita.com/yanagih/items/4c8c57cdbbec2041d35f

@yanagih さん、いつも素晴らしい記事をありがとうございます！Open Interpreter の記事最高です！

# 概要

iPhoneの計算機アプリのキャプチャ画像をChatGPTにアップロードして、色々依頼したら、10分ぐらいでFlutterの計算機アプリが出来ちゃった。

| (Input) 計算機のキャプチャ | (Output) 完成したFlutterアプリ |
| --------------- | --------- |
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/342406c3-4f8a-47fc-429c-68549060a712.jpeg" alt="iPhone_計算機.jpeg" width="300"/> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/15a54de0-2e4b-b062-9c2b-b7407aedf16f.png" alt="STEP6.png" width="300"/> |

１０分ぐらいでこの品質のアプリが出来て、尚且つテストコードまで出せるなんて神。
もう少しレイアウト調整したかったのですが、最後はChatGPTのネットワークエラーが頻発したので、ここまでにしておきました。

## 備考

途中経過のキャプチャ一覧
| STEP2 | STEP3 | STEP4 | STEP5 | STEP6 |
| --------- | --------- | --------- | --------- | --------- |
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/e386b5e9-50e5-dda7-8118-0cbf35abdc85.png" alt="STEP2.png" width="200"/> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/4230269d-4088-2309-25a5-bee8d00a81f9.png" alt="STEP3.png" width="200"/> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/be215d4d-cb1a-39c3-fc7b-2fc4c752a605.png" alt="STEP4.png" width="200"/> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/c29834f1-b218-b1e8-5847-11acdc392bb7.png" alt="STEP5.png" width="200"/> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/15a54de0-2e4b-b062-9c2b-b7407aedf16f.png" alt="STEP6.png" width="200"/> |

↑の表は以下のブックマークレットで作成

https://qiita.com/YokohamaHori/items/1ef375891067b7d6c27a

FlutterのソースコードはDartPadですぐに実行できます。

https://dartpad.dev/?id

# 早速計算機アプリを作ってみる

## STEP1: 「あなたは、Flutterのスペシャリストです。この電卓プログラムを再現してください。」

依頼内容

ChatGPTに計算機アプリのキャプチャをアップロードしてから、

```markdown
あなたは、Flutterのスペシャリストです。この電卓プログラムを再現してください。
```

出てきたソース

```dart
import 'package:flutter/material.dart';

void main() => runApp(CalculatorApp());

class CalculatorApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Calculator(),
    );
  }
}

class Calculator extends StatefulWidget {
  @override
  _CalculatorState createState() => _CalculatorState();
}

class _CalculatorState extends State<Calculator> {
  String display = "0";

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Calculator')),
      body: Column(
        children: [
          Expanded(
            child: Container(
              alignment: Alignment.bottomRight,
              padding: EdgeInsets.all(32),
              child: Text(display, style: TextStyle(fontSize: 36)),
            ),
          ),
          for (var row in [
            ['7', '8', '9', '×'],
            ['4', '5', '6', '−'],
            ['1', '2', '3', '+'],
            ['AC', '0', '=', '/']
          ])
            Row(
              children: row.map((btn) => buildButton(btn)).toList(),
            )
        ],
      ),
    );
  }

  Widget buildButton(String title) {
    return Expanded(
      child: FlatButton(
        color: Colors.orange,
        onPressed: () => onButtonPressed(title),
        child: Text(title, style: TextStyle(fontSize: 24, color: Colors.white)),
      ),
    );
  }

  void onButtonPressed(String value) {
    setState(() {
      if (value == 'AC') {
        display = '0';
      } else if ('0123456789'.contains(value)) {
        if (display == '0') {
          display = value;
        } else {
          display += value;
        }
      } else {
        // ここで計算ロジックを追加することができます。
        // 今回のサンプルでは省略しています。
      }
    });
  }
}

```

メッセージ

```
このコードは、基本的なボタンのレイアウトと表示を持つシンプルな電卓のUIをFlutterで作成しています。しかし、実際の計算ロジック（加算、減算、乗算など）は省略されています。このロジックを実装するには、onButtonPressed関数を編集する必要があります。
このコードを使用してFlutterで電卓アプリケーションを作成する際の参考としてください。
```

DartPadで実行したら、エラーが出たので、エラー内容をそのまま貼り付けてみる

## STEP2: 「The method 'FlatButton' isn't defined for the type '_CalculatorState'.」

出てきたソース

```dart
Widget buildButton(String title) {
  return Expanded(
    child: TextButton(
      style: TextButton.styleFrom(
        backgroundColor: Colors.orange,
        primary: Colors.white,
        padding: EdgeInsets.all(16),
      ),
      onPressed: () => onButtonPressed(title),
      child: Text(title, style: TextStyle(fontSize: 24)),
    ),
  );
}

```

DartPadで実行
![STEP2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/e386b5e9-50e5-dda7-8118-0cbf35abdc85.png)

おー、それっぽいのが出てきた。けど、計算ロジックはまだ入ってないので依頼してみる

## STEP3: 「実際の計算ロジック（加算、減算、乗算など）も追加してください」

出てきたソース

```dart
import 'package:flutter/material.dart';

void main() => runApp(CalculatorApp());

class CalculatorApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Calculator(),
    );
  }
}

class Calculator extends StatefulWidget {
  @override
  _CalculatorState createState() => _CalculatorState();
}

class _CalculatorState extends State<Calculator> {
  String display = "0";
  String? operation;
  double? firstValue;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Calculator')),
      body: Column(
        children: [
          Expanded(
            child: Container(
              alignment: Alignment.bottomRight,
              padding: EdgeInsets.all(32),
              child: Text(display, style: TextStyle(fontSize: 36)),
            ),
          ),
          for (var row in [
            ['7', '8', '9', '×'],
            ['4', '5', '6', '−'],
            ['1', '2', '3', '+'],
            ['AC', '0', '=', '/']
          ])
            Row(
              children: row.map((btn) => buildButton(btn)).toList(),
            )
        ],
      ),
    );
  }

  Widget buildButton(String title) {
    return Expanded(
      child: TextButton(
        style: TextButton.styleFrom(
          backgroundColor: Colors.orange,
          primary: Colors.white,
          padding: EdgeInsets.all(16),
        ),
        onPressed: () => onButtonPressed(title),
        child: Text(title, style: TextStyle(fontSize: 24)),
      ),
    );
  }

  void onButtonPressed(String value) {
    setState(() {
      if ('0123456789'.contains(value)) {
        if (display == '0' || (operation != null && firstValue == null)) {
          display = value;
        } else {
          display += value;
        }
      } else if (value == 'AC') {
        display = '0';
        operation = null;
        firstValue = null;
      } else if (['+', '−', '×', '/'].contains(value)) {
        operation = value;
        firstValue = double.parse(display);
        display = '0';
      } else if (value == '=') {
        if (operation != null && firstValue != null) {
          switch (operation) {
            case '+':
              display = (firstValue! + double.parse(display)).toString();
              break;
            case '−':
              display = (firstValue! - double.parse(display)).toString();
              break;
            case '×':
              display = (firstValue! * double.parse(display)).toString();
              break;
            case '/':
              if (double.parse(display) == 0.0) {
                display = "Error";
              } else {
                display = (firstValue! / double.parse(display)).toStringAsFixed(2); // 小数点以下2桁で表示
              }
              break;
          }
          firstValue = null;
          operation = null;
        }
      }
    });
  }
}

```

DartPadで実行
![STEP3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/4230269d-4088-2309-25a5-bee8d00a81f9.png)

おー、それっぽく動いてる！が、小数点ボタンがないので追加依頼＋その他レイアウト調整もお願いしてみる。

## STEP4: レイアウト調整

何度かやり取りをした結果、以下のソースになった

```dart
import 'package:flutter/material.dart';

void main() => runApp(CalculatorApp());

class CalculatorApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Calculator(),
    );
  }
}

class Calculator extends StatefulWidget {
  @override
  _CalculatorState createState() => _CalculatorState();
}

class _CalculatorState extends State<Calculator> {
  String display = "0";
  String? operation;
  double? firstValue;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Expanded(
          child: Container(alignment: Alignment.bottomRight, padding: EdgeInsets.all(30.0),
                           child: Text(display, style: TextStyle(fontSize: 70, color: Colors.white)))),
        buildButtonRow(['7', '8', '9', '/']),
        buildButtonRow(['4', '5', '6', '×']),
        buildButtonRow(['1', '2', '3', '−']),
        buildButtonRow(['AC', '0', '.', '+']),
        buildButtonRow(['', '', '', '=']),
      ],
    );
  }

  Widget buildButtonRow(List<String> titles) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: titles.map((title) => buildButton(title)).toList(),
    );
  }

  Widget buildButton(String title) {
    Color buttonColor;
    Color textColor = Colors.white; // デフォルトのテキストの色

    if (['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'].contains(title)) {
      buttonColor = Colors.grey[800]!;
    } else if (['/', '×', '−', '+', '='].contains(title)) {
      buttonColor = Colors.orange;
    } else {
      buttonColor = Colors.grey[300]!;
      textColor = Colors.black;
    }

    return title.isEmpty ? Container(width: 70, height: 70) : Container(
      width: 70,
      height: 70,
      margin: EdgeInsets.all(8.0),
      child: ElevatedButton(
        onPressed: () => onButtonPressed(title),
        child: Text(title, style: TextStyle(fontSize: 24, color: textColor)),
        style: ElevatedButton.styleFrom(
          primary: buttonColor,
          onPrimary: Colors.white,
          shape: CircleBorder(),
        ),
      ),
    );
  }

  void onButtonPressed(String value) {
    setState(() {
      if ('0123456789'.contains(value)) {
        if (display == '0' || (operation != null && firstValue == null)) {
          display = value;
        } else {
          display += value;
        }
      } else if (value == 'AC') {
        display = '0';
        operation = null;
        firstValue = null;
      } else if (['+', '−', '×', '/'].contains(value)) {
        operation = value;
        firstValue = double.parse(display);
        display = '0';
      } else if (value == '=') {
        if (operation != null && firstValue != null) {
          switch (operation) {
            case '+':
              display = (firstValue! + double.parse(display)).toString();
              break;
            case '−':
              display = (firstValue! - double.parse(display)).toString();
              break;
            case '×':
              display = (firstValue! * double.parse(display)).toString();
              break;
            case '/':
              if (double.parse(display) == 0.0) {
                display = "Error";
              } else {
                display = (firstValue! / double.parse(display)).toStringAsFixed(2); // 小数点以下2桁で表示
              }
              break;
          }
          firstValue = null;
          operation = null;
        }
      } else if (value == '.' && !display.contains('.')) {
        display += '.';
      }
    });
  }
}

```

DartPadで実行
![STEP4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/be215d4d-cb1a-39c3-fc7b-2fc4c752a605.png)

おー、これはいい感じになった！だけど、ソースがやっつけ感満載なので、リファクタリングを依頼してみる。

## STEP5: リファクタリング

依頼内容

```
OKです！それでは、そのソースを以下を意識してリファクタリングして下さい
- あなたは開発歴10年以上のベテランプログラマーです。
- 拡張性、変更容易性、テスト容易性を重視して、設計を見直して下さい。
- 解説は不要なので、ソースコードだけ全文ください。
```

出てきたソース

```dart
import 'package:flutter/material.dart';

void main() => runApp(CalculatorApp());

class CalculatorApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(backgroundColor: Colors.black, body: Calculator()),
    );
  }
}

class Calculator extends StatefulWidget {
  @override
  _CalculatorState createState() => _CalculatorState();
}

class _CalculatorState extends State<Calculator> {
  String display = "0";
  String? operation;
  double? firstValue;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Expanded(child: Container(alignment: Alignment.bottomRight, padding: EdgeInsets.all(16.0), child: Text(display, style: TextStyle(fontSize: 32, color: Colors.white)))),
        for (var row in _buttonRows) buildButtonRow(row),
      ],
    );
  }

  final _buttonRows = [
    ['7', '8', '9', '/'],
    ['4', '5', '6', '×'],
    ['1', '2', '3', '−'],
    ['AC', '0', '.', '+'],
    ['', '', '', '=']
  ];

  Widget buildButtonRow(List<String> titles) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: titles.map((title) => CalculatorButton(title: title, onPressed: onButtonPressed)).toList(),
    );
  }

  void onButtonPressed(String value) {
    setState(() {
      if ('0123456789'.contains(value)) {
        if (display == '0' || (operation != null && firstValue == null)) {
          display = value;
        } else {
          display += value;
        }
      } else if (value == 'AC') {
        display = '0';
        operation = null;
        firstValue = null;
      } else if (['+', '−', '×', '/'].contains(value)) {
        operation = value;
        firstValue = double.parse(display);
        display = '0';
      } else if (value == '=') {
        if (operation != null && firstValue != null) {
          switch (operation) {
            case '+':
              display = (firstValue! + double.parse(display)).toString();
              break;
            case '−':
              display = (firstValue! - double.parse(display)).toString();
              break;
            case '×':
              display = (firstValue! * double.parse(display)).toString();
              break;
            case '/':
              if (double.parse(display) == 0.0) {
                display = "Error";
              } else {
                display = (firstValue! / double.parse(display)).toStringAsFixed(2); // 小数点以下2桁で表示
              }
              break;
          }
          firstValue = null;
          operation = null;
        }
      } else if (value == '.' && !display.contains('.')) {
        display += '.';
      }
    });
  }
}

class CalculatorButton extends StatelessWidget {
  final String title;
  final Function(String) onPressed;
  const CalculatorButton({required this.title, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    Color buttonColor = _buttonColors.containsKey(title) ? _buttonColors[title]! : Colors.grey[300]!;
    Color textColor = (title == 'AC') ? Colors.black : Colors.white;
    return title.isEmpty ? _emptySpace() : _button(buttonColor, textColor);
  }

  Widget _emptySpace() => Container(width: 70, height: 70);

  Widget _button(Color color, Color textColor) {
    return Container(
      width: 70,
      height: 70,
      margin: EdgeInsets.all(8.0),
      child: ElevatedButton(
        onPressed: () => onPressed(title),
        child: Text(title, style: TextStyle(fontSize: 24, color: textColor)),
        style: ElevatedButton.styleFrom(
          primary: color,
          shape: CircleBorder(),
        ),
      ),
    );
  }

  static const _buttonColors = {
    '0': Colors.grey,
    '1': Colors.grey,
    '2': Colors.grey,
    '3': Colors.grey,
    '4': Colors.grey,
    '5': Colors.grey,
    '6': Colors.grey,
    '7': Colors.grey,
    '8': Colors.grey,
    '9': Colors.grey,
    '.': Colors.grey,
    '/': Colors.orange,
    '×': Colors.orange,
    '−': Colors.orange,
    '+': Colors.orange,
    '=': Colors.orange,
  };
}

```

DartPadで実行
![STEP5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/c29834f1-b218-b1e8-5847-11acdc392bb7.png)

ちょっと、ちょっと、ちょっと。細かいことは抜きにして、これはかなり良い感じになった！
1分ぐらいで対応したリファクタリングとは思えないレベル。
あとはリファクタリングでボタンの色が変わってしまったので、もう少し調整してもらう。

## STEP6: ボタンの色が変わってしまったので、もう少し調整

```dart
import 'package:flutter/material.dart';

void main() => runApp(CalculatorApp());

class CalculatorApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(backgroundColor: Colors.black, body: Calculator()),
    );
  }
}

class Calculator extends StatefulWidget {
  @override
  _CalculatorState createState() => _CalculatorState();
}

class _CalculatorState extends State<Calculator> {
  String display = "0";
  String? operation;
  double? firstValue;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Expanded(child: Container(alignment: Alignment.bottomRight, padding: EdgeInsets.all(16.0), child: Text(display, style: TextStyle(fontSize: 70, color: Colors.white)))),
        for (var row in _buttonRows) buildButtonRow(row),
      ],
    );
  }

  final _buttonRows = [
    ['7', '8', '9', '/'],
    ['4', '5', '6', '×'],
    ['1', '2', '3', '−'],
    ['AC', '0', '.', '+'],
    ['', '', '', '=']
  ];

  Widget buildButtonRow(List<String> titles) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: titles.map((title) => CalculatorButton(title: title, onPressed: onButtonPressed)).toList(),
    );
  }

  void onButtonPressed(String value) {
    setState(() {
      if ('0123456789'.contains(value)) {
        if (display == '0' || (operation != null && firstValue == null)) {
          display = value;
        } else {
          display += value;
        }
      } else if (value == 'AC') {
        display = '0';
        operation = null;
        firstValue = null;
      } else if (['+', '−', '×', '/'].contains(value)) {
        operation = value;
        firstValue = double.parse(display);
        display = '0';
      } else if (value == '=') {
        if (operation != null && firstValue != null) {
          switch (operation) {
            case '+':
              display = (firstValue! + double.parse(display)).toString();
              break;
            case '−':
              display = (firstValue! - double.parse(display)).toString();
              break;
            case '×':
              display = (firstValue! * double.parse(display)).toString();
              break;
            case '/':
              if (double.parse(display) == 0.0) {
                display = "Error";
              } else {
                display = (firstValue! / double.parse(display)).toStringAsFixed(2); // 小数点以下2桁で表示
              }
              break;
          }
          firstValue = null;
          operation = null;
        }
      } else if (value == '.' && !display.contains('.')) {
        display += '.';
      }
    });
  }
}

class CalculatorButton extends StatelessWidget {
  final String title;
  final Function(String) onPressed;
  const CalculatorButton({required this.title, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    Color buttonColor = _buttonColors.containsKey(title) ? _buttonColors[title]! : Colors.grey;
    Color textColor = (title == 'AC') ? Colors.black : Colors.white;
    return title.isEmpty ? _emptySpace() : _button(buttonColor, textColor);
  }

  Widget _emptySpace() => Container(width: 70, height: 70);

  Widget _button(Color color, Color textColor) {
    return Container(
      width: 70,
      height: 70,
      margin: EdgeInsets.all(8.0),
      child: ElevatedButton(
        onPressed: () => onPressed(title),
        child: Text(title, style: TextStyle(fontSize: 24, color: textColor)),
        style: ElevatedButton.styleFrom(
          primary: color,
          shape: CircleBorder(),
        ),
      ),
    );
  }

  static const _buttonColors = {
    '0': Colors.white24,
    '1': Colors.white24,
    '2': Colors.white24,
    '3': Colors.white24,
    '4': Colors.white24,
    '5': Colors.white24,
    '6': Colors.white24,
    '7': Colors.white24,
    '8': Colors.white24,
    '9': Colors.white24,
    '.': Colors.white24,
    '/': Colors.orange,
    '×': Colors.orange,
    '−': Colors.orange,
    '+': Colors.orange,
    '=': Colors.orange,
  };
}

```

DartPadで実行
![STEP6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/15a54de0-2e4b-b062-9c2b-b7407aedf16f.png)

うわ、10分ぐらいでこの完成度は凄すぎる！

本当ならレイアウトをもう少し調整してもらいたいところだけど、ChatGPTがネットワークエラー頻発してるので、レイアウト調整はこれで終了とする。
残りのレイアウト調整はプロンプトで指示を続けて、他にもバグがあればそのまま伝えれば、いずれ完成すると思うし。

最後にテストコードを出してもらい、今回は終わりとする。

## STEP7: テストコード

依頼内容

```
素晴らしい！テストコードもお願いします！
```

出てきたソース

```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'your_main_file_name.dart'; // 上のソースコードが保存されているファイル名に置き換えてください。

void main() {
  testWidgets('Calculator display starts with 0', (WidgetTester tester) async {
    // アプリをビルド
    await tester.pumpWidget(CalculatorApp());

    // displayが0で始まるか確認
    expect(find.text('0'), findsOneWidget);
  });

  testWidgets('Pressing number buttons updates the display', (WidgetTester tester) async {
    await tester.pumpWidget(CalculatorApp());

    // '1' ボタンをタップ
    await tester.tap(find.text('1'));
    await tester.pump();

    expect(find.text('1'), findsOneWidget);

    // '2' ボタンをタップ
    await tester.tap(find.text('2'));
    await tester.pump();

    expect(find.text('12'), findsOneWidget);
  });

  testWidgets('Pressing AC resets the display', (WidgetTester tester) async {
    await tester.pumpWidget(CalculatorApp());

    // 数字を入力
    await tester.tap(find.text('9'));
    await tester.pump();
    expect(find.text('9'), findsOneWidget);

    // 'AC' をタップしてリセット
    await tester.tap(find.text('AC'));
    await tester.pump();

    expect(find.text('0'), findsOneWidget);
  });

  // 他の計算ロジックやボタンの動作に関するテストも追加できます。
}
```

注意：テストコードの動作確認はしてないです。

# さいごに

マジ凄いっす。これは本当に凄い。
あまり動作確認はしてないので、バグはあると思いますが、許して下さい。