はじめに
現在、方言を話すおしゃべり猫型ロボット「ミーア」を開発中。
以前、ESP32のlight sleep mode機能を使って、ミーアをおやすみモードに移行する部分のコードを作成した。この時は、PlatformIOに直接スリープモードへの移行時間を設定していたが、ユーザーのアプリで移行時間を設定できるように変更する。
こちらの、おしゃべり設定画面に「おやすみモードへの時間(オーナーからの操作が一定時間なかった時におやすみモードに移行する時間)」という設定を追加する。
データベースにカラム追加→サーバー側実装→アプリ実装→ESP32実装の順に進める
Userテーブルにスリープモードへの移行時間カラムを追加
マイグレーションファイルを作成
現在のUser構造体は下記。こちらに新しくスリープモードへの移行時間カラム(sleep_transition_time)を追加する。
データベースには PlanetScale (MySQL) を利用している。
下記コマンドをターミナルで実行して、マイグレーションファイルを作成。
-ext sql
: ファイルの拡張子として .sql を指定
-format 2006010215
: マイグレーションファイルの接頭辞として YYYYMMDDHH
(年月日時)の形式のタイムスタンプがつく。
$ 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分とする
ALTER TABLE users
ADD COLUMN sleep_transition_time INT DEFAULT 10 COMMENT 'おしゃべり設定:スリープモード移行時間';
downファイル
ALTER TABLE users
DROP COLUMN sleep_transition_time;
マイグレーションを実行
DB のマイグレーションにはgolang-migrateというライブラリを利用している。
$ 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
というフィールドを追加する。
db
と json
タグは、それぞれデータベースとJSON形式でのフィールド名を指定。
user.goファイル
type User struct {
// 他のフィールド
SleepTransitionTime types.NullInt64 `db:"sleep_transition_time" json:"sleep_transition_time"`
// 他のフィールドの続き
}
user_handler.goファイルのHandleUpdateUser関数で、アプリからのユーザーデータ更新に関するAPIリクエストは処理している。
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は「結合する」という意味
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を追加する。
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 run build_runner build
dart
run build_runner build
コマンドで、user.g.dart
ファイルを生成する。User
クラスの toJson
および fromJson
メソッドが自動的に生成される。
ちなみに、以前にビルドを実行して生成されたファイルが残っている状態で、再度ビルドを実行した場合には、下記のようなwarningが出る。その場合、「1 – Delete」を選択して、既存の生成ファイルを削除する。。これにより、build_runner
はクリーンな状態でビルドを実行し、最新のソースコードに基づいた正しい生成ファイルを作成できるようになる。
~/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 (サーバーにリクエストする際の形式)と相互に変換するためのコードを自動生成するためのファイル。
詳細設定画面に、設定項目追加
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
クラスに反映され、画面上でも更新される。
// 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に移動。
前よりは良い感じになった。この説明文で伝わるかどうかは若干懸念が残るが。
// 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分後に変更される。
flutter: Request updateUser: {"sleep_transition_time":40}
というAPIリクエストがサーバーに飛ぶ
MySQL内部では該当ユーザーのスリープ移行時間の設定が更新される。
そして、アプリの詳細設定画面では、userProvider
から取得したUser
オブジェクトのデータを使用して、更新されたsleep_transition_timeを表示する。
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)側に反映させる必要がある。そちらに関しては、こちらの記事で。
コメント