方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Flutter × Go】眠りモードに至るまでの時間をアプリで設定して、サーバーに反映させる。

この記事は約32分で読めます。

はじめに

現在、方言を話すおしゃべり猫型ロボット「ミーア」を開発中。

https://mia-cat.com

以前、ESP32のlight sleep mode機能を使って、ミーアをおやすみモードに移行する部分のコードを作成した。この時は、PlatformIOに直接スリープモードへの移行時間を設定していたが、ユーザーのアプリで移行時間を設定できるように変更する。

こちらの、おしゃべり設定画面に「おやすみモードへの時間(オーナーからの操作が一定時間なかった時におやすみモードに移行する時間)」という設定を追加する。

データベースにカラム追加→サーバー側実装→アプリ実装→ESP32実装の順に進める

Userテーブルにスリープモードへの移行時間カラムを追加

マイグレーションファイルを作成

現在のUser構造体は下記。こちらに新しくスリープモードへの移行時間カラム(sleep_transition_time)を追加する。

データベースには PlanetScale (MySQL) を利用している。

下記コマンドをターミナルで実行して、マイグレーションファイルを作成。

-ext sql: ファイルの拡張子として .sql を指定

-format 2006010215 : マイグレーションファイルの接頭辞として YYYYMMDDHH(年月日時)の形式のタイムスタンプがつく。

Zsh
$ cd scripts/migrations

# migrate create -ext sql -format 2006010215 <filename> 
$ migrate create -ext sql -format 2006010215 add_sleep_transition_time_to_users

/Users/ky/dev/clocky/clocky_be/scripts/migration/2024012522_add_sleep_transition_time_to_users.up.sql
/Users/ky/dev/clocky/clocky_be/scripts/migration/2024012522_add_sleep_transition_time_to_users.down.sql

コマンドを実行すると、upファイルとdownファイルの両方が作成される。

.up.sql はマイグレーションを適用する際のSQLを含み、.down.sql はマイグレーションをロールバックする際のSQLを含む。

マイグレーションファイルの中身を作成

upファイル

  • 今回はスリープモード移行時間のデフォルトを10分とする
Zsh
ALTER TABLE users
  ADD COLUMN sleep_transition_time INT DEFAULT 10 COMMENT 'おしゃべり設定:スリープモード移行時間';

downファイル

Zsh
ALTER TABLE users
  DROP COLUMN sleep_transition_time;

マイグレーションを実行

DB のマイグレーションにはgolang-migrateというライブラリを利用している。

Zsh
$ make migrate-up
docker exec -it clocky_api_local migrate 
                -path "./scripts/migration" 
                -database "mysql://****:****@tcp(db:3306)/clocky-db" up 
2024012522/u add_sleep_transition_time_to_users (31.309743ms)

# mysqlにログインして、describe usersG; を実行

無事、カラムが追加されたことを確認できた。

APIリクエスト処理(サーバー側)

User構造体にスリープ移行時間を追加

sleep_transition_time 列をデータベースに追加したので、この新しいデータをGoのコード内で扱うことができるようにするため、User 構造体にも同様に SleepTransitionTime というフィールドを追加する。

dbjson タグは、それぞれデータベースとJSON形式でのフィールド名を指定。

user.goファイル

Go
type User struct {
    // 他のフィールド
    SleepTransitionTime types.NullInt64 `db:"sleep_transition_time" json:"sleep_transition_time"`
    // 他のフィールドの続き
}

user_handler.goファイルのHandleUpdateUser関数で、アプリからのユーザーデータ更新に関するAPIリクエストは処理している。

Go
func (h *UserHandler) HandleUpdateUser(c echo.Context) error {
	uid := c.Get("uid").(string)
	user := &User{}
	if err := c.Bind(user); err != nil {
		return err
	}
	user.UID = uid

	// DB更新
	updatedUser, err := UpdateUser(h.db, user)
	if err != nil {
		c.Logger().Errorf("failed to update user: %v", err)
		return echo.NewHTTPError(http.StatusInternalServerError, "failed to update user")
	}

	// デバイスIDが存在するとき
	if updatedUser.DeviceID.Valid {
		// デバイス設定を取得
		ds := GetDesiredDeviceShadow(updatedUser)
		jsonData, err := json.Marshal(ds)
		if err != nil {
			c.Logger().Errorf("Error marshaling data: %s", err)
			return echo.NewHTTPError(http.StatusInternalServerError, "failed to marshal deviceSetting")
		}

		// DeviceShadowを反映
		if err = h.sm.UpdateShadow(updatedUser.DeviceID.String, jsonData); err != nil {
			c.Logger().Errorf("failed to update device shadow: %v", err)
			return echo.NewHTTPError(http.StatusInternalServerError, "failed to update device shadow")
		}
	}
	return c.JSON(http.StatusOK, updatedUser)
}

UpdateUser関数に、APIリクエストの際の更新カラムとして追加

なので、DB更新に相当するUpdateUser関数(user_db.goファイル)に、sleep_transition_timeを追加する。

coalesce 関数は、提供された第一引数が NULL でない場合はその値を使用し、そうでない場合は既存の値(第二引数)を維持する。coalesceは「結合する」という意味

Go
func UpdateUser(db *sqlx.DB, user *User) (*User, error) {
	tx, err := db.Beginx()
	if err != nil {
		return nil, err
	}

	query := `update users
			  set device_id				    = coalesce(:device_id, device_id),
			      name                      = coalesce(:name, name),
				  gender                    = coalesce(:gender, gender),
				  prefecture                = coalesce(:prefecture, prefecture),
				  city                      = coalesce(:city, city),
				  birth_date                = coalesce(:birth_date, birth_date),
				  phrase_type               = coalesce(:phrase_type, phrase_type),
				  talk_start_time           = coalesce(:talk_start_time, talk_start_time),
				  talk_end_time             = coalesce(:talk_end_time, talk_end_time),
				  talk_frequency            = coalesce(:talk_frequency, talk_frequency),
				  weather_announcement_time = coalesce(:weather_announcement_time, weather_announcement_time),
				  work_start_time           = coalesce(:work_start_time, work_start_time),
				  work_end_time             = coalesce(:work_end_time, work_end_time),
					sleep_transition_time     = coalesce(:sleep_transition_time, sleep_transition_time),
				  volume                    = coalesce(:volume, volume),
				  updated_at                = NOW()
			  where uid = :uid;`

	_, execErr := tx.NamedExec(query, user)

	if execErr != nil {
		tx.Rollback()
		return nil, execErr
	}

	// 更新後のユーザー情報を取得する
	var updatedUser User
	selectQuery := "select * from users where uid = ?"
	if err := tx.Get(&updatedUser, selectQuery, user.UID); err != nil {
		err := tx.Rollback()
		if err != nil {
			return nil, err
		}
		return nil, err
	}

	if err := tx.Commit(); err != nil {
		return nil, err
	}

	return &updatedUser, nil
}

アプリ側実装

UserクラスにsleepTransitionTimeを追加

lib→api→user.dartファイルにsleepTransitionTimeを追加する。

Dart
import 'package:clocky_app/api/time_converter.dart';
import 'package:clocky_app/models/date.dart';
import 'package:clocky_app/models/hour_minute.dart';
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

@JsonSerializable(explicitToJson: true, includeIfNull: false)
@DateTimeConverter()
class User {
  final int? id;
  final String? uid;
  final String? deviceId;
  final String? name;
  final String? gender;
  final String? prefecture;
  final String? city;
  final Date? birthDate;
  final String? phraseType;
  final HourMinute? talkStartTime;
  final HourMinute? talkEndTime;
  final int? talkFrequency;
  final HourMinute? weatherAnnouncementTime;
  final HourMinute? workStartTime;
  final HourMinute? workEndTime;
  final int? volume;
  final int? sleepTransitionTime; // 追加

  User(
      {this.id,
      this.uid,
      this.deviceId,
      this.name,
      this.gender,
      this.prefecture,
      this.city,
      this.birthDate,
      this.phraseType,
      this.talkStartTime,
      this.talkEndTime,
      this.talkFrequency,
      this.weatherAnnouncementTime,
      this.workStartTime,
      this.workEndTime,
      this.createdAt,
      this.updatedAt,
      this.volume,
      this.sleepTransitionTime}); // 追加

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  get startTime => null;

  Map<String, dynamic> toJson() => _$UserToJson(this);
}

user.g.dartファイルの生成

Dart
$ dart run build_runner build

dart run build_runner build コマンドで、user.g.dart ファイルを生成する。User クラスの toJson および fromJson メソッドが自動的に生成される。

ちなみに、以前にビルドを実行して生成されたファイルが残っている状態で、再度ビルドを実行した場合には、下記のようなwarningが出る。その場合、「1 – Delete」を選択して、既存の生成ファイルを削除する。。これにより、build_runner はクリーンな状態でビルドを実行し、最新のソースコードに基づいた正しい生成ファイルを作成できるようになる。

ShellScript
 ~/dev/clocky/clocky_app   feat-ota-update  ? ⍟13  dart run build_runner build                                                                    1064  07:32:59
Building package executable... (11.8s)
Built build_runner:build_runner.
[INFO] Generating build script completed, took 626ms
[INFO] Precompiling build script... completed, took 11.0s
[INFO] Building new asset graph completed, took 2.0s
[INFO] Found 3 declared outputs which already exist on disk. This is likely because the`.dart_tool/build` folder was deleted, or you are submitting generated files to your source repository.
Delete these files?
1 - Delete
2 - Cancel build
3 - List conflicts

user.dart はデータモデル自体を表し、user.g.dart はそのデータモデルを JSON (サーバーにリクエストする際の形式)と相互に変換するためのコードを自動生成するためのファイル。

詳細設定画面に、設定項目追加

Dart
class SettingsTab extends ConsumerStatefulWidget {
  const SettingsTab({Key? key}) : super(key: key);

  @override
  _SettingsTabState createState() => _SettingsTabState();
}

class _SettingsTabState extends ConsumerState<SettingsTab> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("詳細設定"),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16.0),
        children: <Widget>[
          sectionHeader('おしゃべり設定'),
          buildSection(
              ['話す時間', '話す頻度', '天気お知らせ時刻', '仕事', 'カレンダー連携', 'おやすみモード移行時間']),
          sectionHeader('オーナー情報'),
          buildSection(['オーナー情報']),
          sectionHeader('このアプリについて'),
          buildSection(['お客様サポート', 'ご利用規約']),
        ],
      ),
    );
  }

  Widget buildSection(List<String> items) {
    return Container(
      margin: const EdgeInsets.only(bottom: 24.0),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(5.0),
        boxShadow: [
          BoxShadow(
            color: Colors.grey.withOpacity(0.2),
            spreadRadius: 1.0,
            blurRadius: 5.0,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children:
            items.map((item) => buildItem(item, items.last == item)).toList(),
      ),
    );
  }

  Widget buildItem(String item, bool isLast) {
    final user = ref.watch(userProvider);
    return Container(
      decoration: BoxDecoration(
        border: isLast
            ? null
            : const Border(
                bottom: BorderSide(
                  color: Colors.grey,
                  width: 0.5,
                ),
              ),
      ),
      child: Column(
        children: [
          ListTile(
            title: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  item,
                  style: const TextStyle(
                    fontSize: 15,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                Flexible(
                  child: Text(
                    _getSubText(user, item),
                    style: TextStyle(
                      fontSize: 15,
                      color: Colors.grey[600],
                    ),
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
              ],
            ),
            trailing: const Icon(Icons.chevron_right),
            onTap: () async {
              switch (item) {
                case 'おやすみモード移行時間':
                  await Navigator.push(
                    context,
                    MaterialPageRoute(
                      builder: (context) => const SleepTransitionTimeSettingScreen(),
                    ),
                  );
                  break;
              }
            },
          ),
          
        ],
      ),
    );
  }

}

設定画面に、おやすみモード移行時間が追加された。

Navigator.pushで、SleepTransitionTimeSettingScreenクラスに移動するように設定。次に、おやすみモード移行時間設定画面を作成する。

おやすみモード移行設定画面の作成

おやすみモード移行時間の選択画面を作成

10分ごとの選択肢が最大で6つ表示され、上限を60分に制限する。

initState メソッド内で初期状態として User クラスから取得した sleepTransitionTime を読み込んで表示する。

選択された時間が userNotifier を介して User クラスに反映され、画面上でも更新される。

Dart
// ignore_for_file: library_private_types_in_public_api

import 'package:clocky_app/api/user.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class SleepTransitionTimeSettingScreen extends ConsumerStatefulWidget {
  const SleepTransitionTimeSettingScreen({Key? key}) : super(key: key);

  @override
  _SleepTransitionTimeSettingScreenState createState() =>
      _SleepTransitionTimeSettingScreenState();
}

class _SleepTransitionTimeSettingScreenState
    extends ConsumerState<SleepTransitionTimeSettingScreen> {
  int? sleepTransitionTime;

  @override
  void initState() {
    super.initState();
    final user = ref.read(userProvider);
    sleepTransitionTime = user?.sleepTransitionTime;
  }

  @override
  Widget build(BuildContext context) {
    final userNotifier = ref.read(userProvider.notifier);

    return Scaffold(
      appBar: AppBar(
        title: const Text('おやすみモード移行時間'),
      ),
      body: ListTile(
        title: const Text('おやすみモード移行時間'),
        trailing: Text('$sleepTransitionTime分後'),
        onTap: () async {
          final selectedTransitionTime = await showDialog<int>(
            context: context,
            builder: (BuildContext context) {
              return SimpleDialog(
                title: const Text('おやすみモード移行時間'),
                children: List.generate(6, (index) {
                  // 上限を60分に設定
                  final timeInMinutes = (index + 1) * 10;
                  if (timeInMinutes <= 60) {
                    return SimpleDialogOption(
                      onPressed: () {
                        Navigator.pop(context, timeInMinutes);
                      },
                      child: Text('$timeInMinutes 分後'),
                    );
                  } else {
                    return Container(); // 上限を超える場合は非表示
                  }
                }),
              );
            },
          );
          if (selectedTransitionTime != null) {
            userNotifier
                .updateUser(User(sleepTransitionTime: selectedTransitionTime));
            setState(() {
              sleepTransitionTime = selectedTransitionTime;
            });
          }
        },
      ),
    );
  }
}

一応、機能としてはできたが、このままでは、ユーザーからしたら、お休みモードって何?となると思うので、説明文を挿入する。

おやすみモード説明文の挿入

お休みモード移行時間の設定画面に、説明文を記載する。ChatGPTにユーザーにもわかりやすい文章を作成してもらうように相談。

悪くない感じ。ST7735ディスプレイや、gpio wake up機能など、技術的な用語を含んでいるので、チューニング。

こんな感じの説明文にしてみる。

おやすみモードとは
おやすみモードは、ミーアが省エネモードに入り、リラックスして休むモードのことを指します。このモードでは、ミーアは、眠った目の表情になり、しゃべりません。ただし、完全にシャットダウンしているわけではなく、頭に触れると再び起き上がり活動を再開します。


この分量のテキストを、Listのsubtitleとして入れたら、すごいことになったw

テキストの文量を少し修正して、説明文を次のListTileのSubtextに移動。

前よりは良い感じになった。この説明文で伝わるかどうかは若干懸念が残るが。

Dart
// ignore_for_file: library_private_types_in_public_api

import 'package:clocky_app/api/user.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class SleepTransitionTimeSettingScreen extends ConsumerStatefulWidget {
  const SleepTransitionTimeSettingScreen({Key? key}) : super(key: key);

  @override
  _SleepTransitionTimeSettingScreenState createState() =>
      _SleepTransitionTimeSettingScreenState();
}

class _SleepTransitionTimeSettingScreenState
    extends ConsumerState<SleepTransitionTimeSettingScreen> {
  int? sleepTransitionTime;

  @override
  void initState() {
    super.initState();
    final user = ref.read(userProvider);
    sleepTransitionTime = user?.sleepTransitionTime ?? 10;
  }

  @override
  Widget build(BuildContext context) {
    final userNotifier = ref.read(userProvider.notifier);

    return Scaffold(
      appBar: AppBar(
        title: const Text('おやすみモード移行時間'),
      ),
      body: Column(
        children: [
          ListTile(
            title: const Text('おやすみモード移行時間'),
            trailing: Text('$sleepTransitionTime分後'),
            onTap: () async {
              final selectedTransitionTime = await showDialog<int>(
                context: context,
                builder: (BuildContext context) {
                  return SimpleDialog(
                    title: const Text('おやすみモード移行時間'),
                    children: List.generate(6, (index) {
                      // 上限を60分に設定
                      final timeInMinutes = (index + 1) * 10;
                      if (timeInMinutes <= 60) {
                        return SimpleDialogOption(
                          onPressed: () {
                            Navigator.pop(context, timeInMinutes);
                          },
                          child: Text('$timeInMinutes 分後'),
                        );
                      } else {
                        return Container(); // 上限を超える場合は非表示
                      }
                    }),
                  );
                },
              );
              if (selectedTransitionTime != null) {
                userNotifier.updateUser(
                    User(sleepTransitionTime: selectedTransitionTime));
                setState(() {
                  sleepTransitionTime = selectedTransitionTime;
                });
              }
            },
          ),
          const ListTile(
            subtitle: Text(
              'おやすみモードでは、ミーアは眠った目の表情になり、しゃべりません。'
              '頭に触れると再び活動を再開します。n'
              '一番最後に操作してからおやすみモードに移行するまでの時間を設定してください。',
            ),
          ),
        ],
      ),
    );
  }
}

検証

まず、デフォルトでは10分後で設定されており、これを仮に40分後に変更する。そうすると、設定画面では40分後に変更される。

Dart
flutter: Request updateUser: {"sleep_transition_time":40}

というAPIリクエストがサーバーに飛ぶ

MySQL内部では該当ユーザーのスリープ移行時間の設定が更新される。

そして、アプリの詳細設定画面では、userProviderから取得したUserオブジェクトのデータを使用して、更新されたsleep_transition_timeを表示する。

Dart
class _SettingsTabState extends ConsumerState<SettingsTab> {
  @override
  void initState() {
    super.initState();
  }

Widget buildItem(String item, bool isLast) {
    final user = ref.watch(userProvider);
    return Container(

String _getSubText(User? user, String item) {
    switch (item) {
      case 'おやすみモード移行時間':
        if (user?.sleepTransitionTime == null) {
          return '未設定';
        }
        return '${user!.sleepTransitionTime}分後';
      default:
        return '';
    }
  }
}

ちなみに、初回ユーザー登録時は、おやすみモード移行時間の設定はされていないが、サーバー側のDBで初期値で10分後を入れているので、会員登録後に詳細設定画面を開いた状態でも、10分後が表示される。

これで、アプリとサーバー側に関しては完了した。あとは、更新されたおやすみモード移行時間をデバイス(ESP32)側に反映させる必要がある。そちらに関しては、こちらの記事で。

コメント

タイトルとURLをコピーしました