Flutter講座(forROS2)03 WebAPIを操作するFlutterアプリの作成

# 環境
この記事は以下の環境で動いています。

| 項目 | 値 |
|:-:|:-:|
| CPU | Core i5-8250U |
| Ubuntu | 22.04 |
| ROS | humble |

インストールについては[ROS講座02 インストール](https://qiita.com/srs/items/e0e0a9dc3f94c2d3348e)を参照してください。
またこの記事のプログラムはgithubにアップロードされています。[ROS講座11 gitリポジトリ](https://qiita.com/srs/items/25a56a6abda41dcf396e)を参照してください。

当flutterアプリのソースコードは[srs_simple_tablet_client](https://github.com/project-srs/srs_simple_tablet_client)で更新しています。

# 新しいflutterプロジェクトの作成

```shell
flutter create srs_simple_tablet_client
```

# packageの追加
必要なライブライを追加します。以下のコマンドで追加できます。

```shell
cd srs_simple_tablet_client
flutter pub add shared_preferences
flutter pub add web_socket_channel
flutter pub add http
```

# ソースコード

今回は

* トップページ： ipアドレスを入力して、メインページに遷移
* メインページ: サーバーとやり取りして情報を表示したり、コマンドを送ったりする

という構成にします。main.dart、TopPage.dart、ExecutePage.dart　の3つのファイルを記述します。

## TopPage.dart

```dart:srs_simple_tablet_client/lib/TopPage.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import './ExecutePage.dart';

class TopPage extends StatefulWidget {
  const TopPage({super.key});

  @override
  State<TopPage> createState() => _TopPage();
}

class _TopPage extends State<TopPage> {
  var _hostname_input_controller = TextEditingController();
  final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
  
  @override
  void initState() {
    super.initState();
  
    Future(() async {
      final SharedPreferences prefs = await SharedPreferences.getInstance();
      setState(() {
        _hostname_input_controller.text = prefs.getString('target_hostname') ?? '';
        print(prefs.getString('target_hostname'));
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('srs simple tablet client'),
        centerTitle: true,
      ),
      body: Container(
        padding: EdgeInsets.all(32.0),
        child: Center(
          child: Column(
            children: <Widget>[
              Container(
                width: 150,
                child: TextField(
                  controller: _hostname_input_controller,
                ),
              ),
              SizedBox(height: 10),
              ElevatedButton(
                onPressed: () async{
                  var hostname = _hostname_input_controller.text;
                  final SharedPreferences prefs = await _prefs;
                  prefs.setString('target_hostname', hostname);

                  Navigator.push(
                      context,
                      MaterialPageRoute(builder: (context)=>ExecutePage(hostname: hostname),)
                  );
                },
                child: Text('Execute Window'),
              ),
            ],
          ),
        ),
      ),
    );  
  }
}
```


## ExecutePage.dart

```dart:srs_simple_tablet_client/lib/ExecutePage.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';

class SimpleDialogSample extends StatelessWidget {
  final List<Widget> optionList;

  const SimpleDialogSample({Key? key, required this.optionList}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return SimpleDialog(
      title: Text('select item'),
      children: optionList,
    );
  }
}

class ExecutePage extends StatefulWidget {
  const ExecutePage({Key? key, required this.hostname}) : super(key: key);

  final String hostname;
  final int port = 8010;

  @override
  State<ExecutePage> createState() => _ExecutePage();
}

class _ExecutePage extends State<ExecutePage> {
  final _scrollController = ScrollController();
  WebSocketChannel? statusChannel;

  @override
  void initState() {
    super.initState();
    statusChannel = WebSocketChannel.connect(Uri.parse('ws://${widget.hostname}:8010/status'));
  }

  @override
  Widget build(BuildContext context) {
    createDrawerListItem(goal_id, key, display_name){
      return ListTile(
        title: Text(display_name),
        onTap: () async {
          final response = await http.post(
              Uri.http('${widget.hostname}:8010', '/select/drawer'),
              headers: <String, String>{
                'Content-Type': 'application/json; charset=UTF-8',
              },
              body: jsonEncode(<String, String>{
                'goal_id': goal_id,
                'key': key
              }));
          print("response: " + response.body);
          Navigator.of(context).pop();
        }
      );
    }

    createActionOptionItem(goal_id, key, display_name){
      return SimpleDialogOption(
        child: Text(display_name),
        onPressed: () async {
          final response = await http.post(
            Uri.http('${widget.hostname}:8010', '/select/action'),
            headers: <String, String>{
              'Content-Type': 'application/json; charset=UTF-8',
            },
            body: jsonEncode(<String, String>{
              'goal_id': goal_id,
              'key': key
            }));
          print("response: " + response.body);
          Navigator.pop(context);
        },
      );
    }

    return StreamBuilder(
      stream: statusChannel?.stream,
      builder: (context, snapshot) {
        var modeText = "no connection";
        var modeColor = Colors.white54;
        var boardIconFileName = 'images/326633_error_icon.png';
        var boardText = "erorr";
        List<Widget> noteWidgets = [];
        Widget? actionButtonWidget = null;
        List<Widget> modeWidgets = [];

        if (snapshot.connectionState == ConnectionState.active && snapshot.hasData) {
          var jsonData = jsonDecode(snapshot.data);

          // select.drawer
          modeWidgets.add(const DrawerHeader(
            child: Text("Operation Mode")
          ));
          for (var item in jsonData["select"]["drawer"]["options"]){
            modeWidgets.add(createDrawerListItem(item["goal_id"], item["key"], item["display_name"]));
          }

          // select.action
          if (0 < jsonData["select"]["action"]["options"].length) {
            List<Widget> optionList = [];
            for (var item in jsonData["select"]["action"]["options"]) {
              optionList.add(createActionOptionItem(item["goal_id"], item["key"], item["display_name"]));
            }

            actionButtonWidget = FloatingActionButton(
              onPressed: () async {
                final String? selectedText = await showDialog<String>(
                  context: context,
                  builder: (_) {
                    return SimpleDialogSample(optionList: optionList,);
                  }
                );
                print(selectedText);
              },
              backgroundColor: Colors.white,
              child: const Icon(Icons.navigation),
            );
          }

          // display.header
          modeText = jsonData['display']['header']['name'];
          final Map<String, Color> modeColorMap = {
            'red' : Colors.red,
            'green' : Colors.green,
            'blue' : Colors.blue,
            'yellow' : Colors.yellow,
            'gray' : Colors.white54
          };
          if (modeColorMap.containsKey(jsonData['display']['header']['color'])) {
            modeColor = modeColorMap[jsonData['display']['header']['color']]!;
          }
          // display.board
          final Map<String, String> iconImageMap = {
            'error' : "images/326633_error_icon.png",
            'move' : "images/9057017_play_button_o_icon.png",
            'pause' : "images/3671827_outline_pause_icon.png",
            'turn' : "images/6428070_arrow_recycle_refresh_reload_return_icon.png",
            'manual' : "images/9025635_game_controller_icon.png",
            'wait_input' : "images/9165539_tap_touch_icon.png",
          };
          if (iconImageMap.containsKey(jsonData["display"]["board"]['icon'])) {
            boardIconFileName = iconImageMap[jsonData["display"]["board"]['icon']]!;
          }
          boardText = jsonData["display"]["board"]["message"];

          // notes
          for (var jsonItem in jsonData['note']['contents']){
            var noteColor = Colors.white38;
            final Map<String, Color> noteColorMap = {
              'info' : Colors.white,
              'warning' : Colors.yellow,
              'error' : Colors.red,
            };
            if (noteColorMap.containsKey(jsonItem['level'])) {
              noteColor = noteColorMap[jsonItem['level']]!;
            }
            noteWidgets.add(
              Text(
                jsonItem["message"],
                style: TextStyle(fontSize: 30, color:noteColor),
              )
            );
          }
        }

        return Scaffold(
          backgroundColor: Colors.black,
          appBar: AppBar(
            backgroundColor: modeColor,
            title: Text(
              modeText,
              style: TextStyle(
                fontSize: 80,
              ),
            ),
            centerTitle: true,
            toolbarHeight: 150, 
          ),
          endDrawer: Drawer(
            child: ListView(
              children: modeWidgets
            )
          ),
          floatingActionButton: actionButtonWidget,
          body: Column(
            children: <Widget>[
              Expanded(
                flex: 4, // 割合.
                child: Container(
                  color: Colors.white12,
                  child: Scrollbar(
                    thumbVisibility: true,
                    trackVisibility: true,
                    thickness: 12,
                    controller: _scrollController,
                    child: ListView(
                      controller: _scrollController,
                      children: noteWidgets
                    ),
                  ),
                ),
              ),
              Expanded(
                flex: 6, // 割合.
                child: Container(
                  child: Column(
                    children: [
                      Expanded(
                        child: Padding(
                          padding: EdgeInsets.all(15),
                          child: Image.asset(boardIconFileName, color: Colors.white)
                        ),
                      ),
                      Text(boardText,
                        style: TextStyle(
                          fontSize: 80,
                          color: Colors.white
                        ),
                      ),
                    ],
                  )
                ),            
              )
            ]
          ),
        );
      }
    );
  }
```

## main.dart

```dart:srs_simple_tablet_client/lib/main.dart
import 'package:flutter/material.dart';
import './TopPage.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'srs_simple_tablet_client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: TopPage(),
    );
  }
}
```

## 画像の配置

`srs_simple_tablet_client/image` 以下にDartから参照する画像を配置します。
配置後以下のようにファイルを更新する必要があります。

```yaml:srs_simple_tablet_client/pubspec.yaml
flutter:
  assets:    # 追加 
    - images # 追加
```

# 実行

```shell:サーバー側の実行
source ~/ros2_ws/install/setup.bash 
ros2 run srs_simple_tablet_server_nodes api_server 
```

```shell:flutterプログラムの実行
cd srs_simple_tablet_client
flutter run -d linux
```

![client_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254442/66aa9bae-7e9b-4701-83a3-4cca6f72822f.png)

![client_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254442/0535aac9-339e-41a7-a470-6d03d3a1f454.png)

# 参考

# 目次ページへのリンク
[ROS講座の目次へのリンク](https://qiita.com/srs/items/5f44440afea0eb616b4a)