88
98

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

未経験者がKotlinでおうちのIoT化アプリを作ってみた

Last updated at Posted at 2020-10-04

AndroidアプリとIoT

これまでセンサとRaspberryPiを使って我が家のIoT化を進めて来ましたが、収集したデータの確認にPCを起動するのが面倒なので、

スマホで状況確認したい
という(個人的)要望が高まってきました。

そこで今回は、上記の要望に応えるためにAndroidアプリをKotlinで作成してみました!
(GitHubにもアップロードしております)
all.png

Kotlin・スマホアプリはおろか、JavaやWebアプリの開発経験すらないので手探りでの実装となりましたが、
私のように
「一定のプログラミング経験はあるけど、スマホアプリは敷居が高そう…」
という方の指針となる記事を目指したいと思います。

間違い等あれば指摘していただければと思います。
kabu_chart_smartphone_man.png

Andoroidアプリ超概要

そもそもスマホアプリ開発ってどんな仕組み?というところから分からなかったので、
概要を調べてみました。

applidevelop.png

Android向けを大きく分けると、
①Android専用フレームワーク
②クロスプラットフォームフレームワーク
③ノンプログラミング系
に分かれるようです。
表にすると下のようになります

主な言語 主な開発環境
①Android専用 Java, Kotlin Android Studio
②クロスプラットフォーム Flutter(Dartベース), Xamarin(C#ベース), React Native(JSベース) Android Studio(Flutter, React Native), Visual Studio(Xamarin)
③ノンプログラミング系 - Monaca(HTMLベース), Yappli

今回は
・アプリの基礎を学ぶためにオーソドックスな手法を選択したかったこと
・グラフ用ライブラリMPAndroidChartが使用できること
という2つの観点から、Kotlinを採用しました。

Kotlin、使ってみるとnull安全やコンストラクタの短縮実装など初心者に優しい仕様が多く、すごく良いです!

IoTデータ可視化アプリに必要な要件とは?

IoTデータスマホで表示するためには、下記の要件が必要となりそうです
** 1.グラフ表示**(時系列での変化や各センサ比較をGUIで可視化したい)
** 2.スマホの小さな画面で表示**(1画面に詰め込む以外の工夫が必要)
** 3.センサの生存確認したい**(電池切れやトラブル発生を見える化)

要件実現のための機能

上記要件を満たすために、下記のような機能を盛り込むことにしました。
** 1.Android用グラフライブラリ"MPAndroidChart"を使用して各種グラフ表示**
** 2.タブで一定カテゴリごとにグラフを分ける + タップ操作で拡大縮小可能**
** 3.センサごとの最終データ取得時刻を一覧表示**
requirements.png

3.に関しては、生存確認のため一定期間データ取得できていないセンサを黄色強調する機能も追加しました
sensordead.png

また、画面遷移は下図のように構成しております
transition.png

必要なもの

・IoTデータ取得システムの構築(こちらを参照ください
・RaspberryPi(今回はPi3 ModelBを使用)
・Nature Remo
・各種温湿度センサ
・開発用のPC(今回はWindows10を使用)
・Android Studio(今回は4.0.1を使用)
・動作させるAndroidスマホ(今回はPixel 3aを使用)

手順

1. AndroidStudioのインストール

1-1. Android Studio本体

こちらを参考にインストールしてください
https://www.atmarkit.co.jp/ait/articles/1709/04/news015.html

1-2. エミュレータ

こちらを参考にインストールしてください
https://techacademy.jp/magazine/2396

2. プロジェクトの作成

Android Studioを起動すると表示される以下の画面で、"Start a new Android Studio project"をクリックします。
1_open_android_studio.png

テンプレート選択画面が表示されるので、"Empty Activity"をクリックします※「Tabbed Activity」を選択したくなるところですが、このテンプレートはほぼ同内容のタブを複数作成するのに向いており、今回のようにタブごとに内容が異なる場合は向かないようです。
2_select_empty_activity.png
好きなプロジェクト名、パッケージ名を記入し、
言語:Kotlin、Minimum SDK:API26を選択します。
3_configure_project.png

以上でプロジェクトの作成は完了です。

3. プロジェクトの全体構成把握

3-1. プロジェクトに含まれるファイル

プロジェクト内には、以下のような多くのファイルが含まれています。
4_files.png

主に見るべきは下表のファイルとなります。

No. 種類 名前 役割
1 gradle build.grandle(Project) プロジェクト全体にインストールするプラグインの宣言
2 gradle build.grandle(Module:app) サブプロジェクト毎にインストールするプラグインの宣言
3 xml AndroidManifest.xml アプリ全体のコンポーネントを宣言
4 kotlin MainActivity.kt メイン画面の処理プログラム
5 xml activity_main.xml メイン画面(アクティビティ)のレイアウト

上記に新たなコード・ファイルを追加していくことで、より複雑な画面・処理を実現する事ができます。

3-2. Androidアプリの構成

どのようなファイルを追加すべきか調べるために、Androidアプリの全体構成を調べてみました。
Android公式サイトによると、Androidアプリの構成は以下のようになっているようです。
** A.アクティビティ:画面構成を定義**
** B.サービス:バックグラウンド処理を定義**
** C.ブロードキャスト レシーバ**
** D.コンテンツ プロバイダ**
上記構成に基づいたアプリの全体コンポーネント構造を宣言するのが、前節1"AndroidManifest.xml"のようです。
主にAがUIを担当し、B~Dがバックエンド側の処理を主に担うようですが、
今回はバックエンド処理はクラウドサービス(MongoDB Realm)に一任するため、
ほぼAに特化した構成となります。

3-3. メイン画面の構成

下記画面遷移の下段(ログイン画面以外。ログイン画面は4章で作成)が相当します。
transition.png

メイン画面はタブを含む複雑な構成となっているため、アクティビティにFragmentという構成要素を内包した実装にします。

アクティビティの構成

画面構成を定義するアクティビティですが、基本的には下記2種類のファイルが必要となります。
・クラスファイル(.ktあるいは.javaファイル):処理を記載
・レイアウト(.xmlファイル):画面のレイアウトを定義

ですので、Androidにおいて画面やUI処理を作成するには
.ktファイルと、対応した.xmlファイルとをセットで追加する
ことが基本となります。

タブとFragment

複雑な画面構成、例えば画面の一部のみ遷移させるような動作に対応するためには、
「Fragment」(リンクの記事がActivityとの関係が分かりやすいです)
という階層構造を作るためのパーツを使用します。
今回のようなタブを持つアプリでは、タブごとにFragmentを作成するのが基本のようです。
Fragmentも「処理を記載した.ktファイル」と「レイアウトを記載した.xmlファイル」が必要となります。

メイン画面のファイル構成まとめ

以上をふまえると、メイン画面をタブ制御するためには下記ファイルが必要となります。
mainactivity.png

今回は「サマリー」「温度」「湿度」の3種類のタブを作成しますが、
それぞれのタブに3種類のFragment(.ktファイルと.xmlファイルのセット)が対応します。

また、タブのスワイプ遷移を制御するためにPagerAdapterというクラスを使用します。
こちらによると、FragmentPagerAdapterとFragmentStatePagerAdapterの2種類がありますが、
今回はタブが3個と少ないので、FragmentPagerAdapterを使用します。

3-4. バックエンド処理とログイン画面

クラウドDBへのアクセスを実施するため、ログイン画面およびDBとの同期処理に下記ファイルが必要となります。
backend.png
メイン画面とは異なり、MainActivityに内包された処理ではないので、
androidManifest.xmlにファイル名と処理順を記載する必要があります。
詳しくは4章で解説します。

3-5. その他のクラス

汎用処理をクラス化・モジュール化して保持しておきます
これらの処理はActivity内に記載しても良いですが、コードの整理&流用を想定して別モジュールに保持します。
具体的には、下記の処理が相当します
データ整形クラス:バックエンド処理で取得したデータを、グラフ表示しやすいよう整形
時間操作メソッド集:タイムゾーンの変換等
グラフ表示用モジュール:MPAndroidChartを簡易に扱うためのメソッド集。詳細は別記事
common_classes.png

3-6. その他のリソースファイル

補助的に使用するレイアウトや、画像等を保持しておきます。
詳しくは5章で解説します。

4. バックエンド処理の作成

DB上にあるセンサデータをスマホ側にダウンロードする処理が相当します。
今回はクラウドNoSQL DBである、MongoDB Atlasから、データを取得します。

4-1. バックエンド処理の難しさ

たったこれだけの処理なのですが、大苦戦し、想像の10倍くらい開発工数が掛かってしまいました

通常JavaからMongoDBに接続する際は、
Java MongoDB Driverを使用します。
しかしAndroid Studioから動作させようとするとJava、Kotlinどちらでもうまくいきませんでした。

こちら
https://stackoverflow.com/questions/42708540/android-studio-connecting-to-mongodb-server-with-mongo-java-driver
を見る限り、有志でAndroidから接続できるよう改造したドライバーも存在するようですが、
こちら
https://www.it-swarm.dev/ja/android/android%e3%82%a2%e3%83%97%e3%83%aa%e3%81%8b%e3%82%89mongodb%e3%81%ab%e6%8e%a5%e7%b6%9a%e3%81%99%e3%82%8b%e3%81%9f%e3%82%81%e3%81%ae%e3%82%88%e3%82%8a%e8%89%af%e3%81%84%e6%96%b9%e6%b3%95/835026235/
を見ると、長期間更新がなされておらず、そもそもモバイルアプリからサーバ上のDBに直接アクセスすることは、セキュリティの観点から推奨されないようです。

どうしたものかと調べてみたところ、MongoDB社からリリースされたクラウドサービス
MongoDB Realm
が、
スマホアプリとクラウドDBをデータ連携
という用途にドンピシャでマッチしていそうだったので、本アプリに組み込んでみました。

別途記事を作成したので、こちらの記事の4-7まで実装を進めてください

5. リソースファイルの作成

UIに使用する各種リソース (画像、色、デザインレイアウト等)を導入します。
リソースファイルにどんな種類があるかは、公式サイトをご参照ください

今回使用する下記のファイルを準備します

5-1. drawable

画像や形状指定に使用するファイルを格納

画像

アプリ内で使用する下記の画像を、下記手順でdrawableフォルダ内に格納します
ondokei_mini.png
water_character_mini.png
aircon_off_raw.png
aircon_cold.png
aircon_hot.png
aircon_dry.png
denryoku_mark.png
refresh.png

対象画像をdrawableフォルダ内にドラッグ&ドロップします
5_guiimage1.png
出てきたメニューでOKを押します
6_guiimage2.png
※フォルダ構成によってはコピーではなく移動されてしまうので、事前に画像をバックアップしておくと安全です

角丸テキストボックス

Summaryタブ内で使用する角丸テキストボックスを、色ごとに3種類作成します
オレンジ:round_text_orange.xml
青:round_text_blue.xml
グレー:round_text_gray.xml

drawableフォルダを右クリックし、New → Drawale Resource Fileを選択します
7_make_roundtext1.png

ファイル名を入力し、Root elementに"shape"と入力してOKを押します
8_make_roundtext2.png

各ファイルの内容は下記となります

round_text_orange.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/roundTextOrangeColor" />//色
    <corners android:radius="@dimen/roundTextRadius" />//角丸の半径
</shape>
round_text_blue.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/roundTextBlueColor" />//色
    <corners android:radius="@dimen/roundTextRadius" />//角丸の半径
</shape>
round_text_gray.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/roundTextGrayColor" />//色
    <corners android:radius="@dimen/roundTextRadius" />//角丸の半径
</shape>

アイコン

アイコンを作成します。

まず、下図のようにアイコン用の画像を準備します(やっつけ仕事でスミマセン笑)
サイズは108dp x108dp推奨との事ですが、後で調整可能なので多少ずれても構いません
image.png

res → New → Image Assetと選択
image.png

Foreground Layer → Image → フォルダのマークをクリックし、
先ほどのアイコン画像を指定してください。
image.png

Resizeツマミを動かし、アイコンが黒い枠内に収まるよう調整
image.png

Background Layerタブをクリック → Source Asset = Colorを選択 → 色指定部分をダブルクリック → 背景の色を指定します
image.png

Next → Finishと進む
image.png

エミュレータで実行し、思った通りのアイコンが表示されれば成功です
image.png

5-2. values

文字列、整数、色など、単純な値を保持しておきます。
プログラム中に記載しても良いですが、ハードコーディングを排除する観点で、まとめてリソース化することが望ましいそうです。

文字列

プロジェクト作成時から存在する、strings.xmlに追記します

strings.xml
<resources>
    <string name="app_name">HomeIoTViewer</string>//アプリのタイトル
    <string name="username">Email</string>//Eメールアドレス入力ボックスのヒント文字
    <string name="password">Password</string>//パスワード入力ボックスのヒント文字
    <string name="create_account">Create account</string>//アカウント作成ボタンの文字
    <string name="login">Login</string>//ログインボタンの文字
    <string name="logout">Log Out</string>//ログアウトボタンの文字
    <string name="refresh">Refresh</string>//更新ボタンの文字
    <string name="tab_name_1">Summary</string>//1個目のタブの文字
    <string name="tab_name_2">Temperature</string>//2個目のタブの文字
    <string name="tab_name_3">Humidity</string>//3個目のタブの文字
</resources>

寸法

各GUIパーツや文字の大きさを指定するには、dimens.xmlを作成して内容を記載します
8_make_dimens.png

dimens.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="tabTopMargin">5dp</dimen>//タブ最上部の表示マージン
    <dimen name="tabBottomMargin">10dp</dimen>//タブ最下部の表示マージン
    <dimen name="pieChartSize">90dp</dimen>//温湿度表示用の円グラフサイズ
    <dimen name="pieTableMargin">10dp</dimen>//温湿度表示用円グラフとセンサ一覧テーブルの間のマージン
    <dimen name="timeZoneTextSize">8sp</dimen>//タイムゾーン表示の文字サイズ
    <dimen name="roundTextRadius">5dp</dimen>//センサ一覧の角丸テキストのアール
    <dimen name="pieTempSeriesMargin">5dp</dimen>//場所ごと温度表示用円グラフと温度推移グラフの間のマージン
    <dimen name="tempSeriesStatsMargin">8dp</dimen>//温度推移グラフと最高最低気温グラフの間のマージン
    <dimen name="statsCandleStickChartSize">180dp</dimen>//最高最低気温(湿度)グラフのサイズ
    <dimen name="pieHumidSeriesMargin">5dp</dimen>//場所ごと温度表示用円グラフと温度推移グラフの間のマージン
    <dimen name="humidSeriesStatsMargin">8dp</dimen>//温度推移グラフと最高最低気温グラフの間のマージン
</resources>

各種レイアウトに使用する色をcolors.xmlにまとめて定義します

colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#6200EE</color>
    <color name="colorPrimaryDark">#3700B3</color>
    <color name="colorAccent">#03DAC5</color>\\選択タブのテキストカラー
    <color name="tabTextColor">#ff6347</color>\\未選択タブのテキストカラー
    <color name="roundTextOrangeColor">#ff6347</color>\\角丸テキストボックス(オレンジ)の塗りつぶし色
    <color name="roundTextBlueColor">#4169e1</color>\\角丸テキストボックス(青)の塗りつぶし色
    <color name="roundTextGrayColor">#696969</color>\\角丸テキストボックス(グレー)の塗りつぶし色
    <color name="toolTipBgColor">#999999</color>\\ツールチップの背景色
    <color name="toolTipTextColor">#ffffff</color>\\ツールチップのテキスト色
</resources>

6. グラフ描画の準備

グラフ描画に必要なライブラリ導入、およびグラフに使用するデータ整形処理を実装します。

6-1. MPAndroidChartの導入

グラフ描画ライブラリであるMPAndroidChartを導入します

build.grandle(Project)に、

allprojects {
    repositories{
        :
        maven { url 'https://jitpack.io' }
        :

という記載を加えます。
9_mpandroidchart_build.gradle.png

build.grandle(Module:app)に、

dependencies {
    :
    implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
    :

という記載を加えます。
10_mpandroidchart_build.gradle_app.png

メニューバーの「File → Close Project」でプロジェクトを閉じて開き直すと、
ライブラリが反映されます。

6-2. グラフ描画用パッケージ「CustomMPAndroidChart」の導入

グラフ描画用ライブラリMPAndroidChartを改造して、使いやすくしてみました(別記事で紹介)
本アプリでも、このパッケージを使用します

フォルダの作成

javaフォルダ直下のパッケージフォルダを右クリックし、New → Packageを選択し、"chart"と名前をつけます
11_chart.png

パッケージファイルの作成

こちらを参考に
・LineChartMethods.kt:折れ線グラフ用メソッド集
・BarChartMethods.kt:棒グラフ用メソッド集
・CandleStickChartMethods.kt:ローソク足グラフ(株価のチャートのようなグラフ)用メソッド集
・PieChartMethods.kt:折れ線グラフ用メソッド集
・MarkerViews.kt:ツールヒント表示用クラス
・ChartFormats.kt:UIフォーマット指定用クラスを集めたモジュール
・simple_marker_view.xml:ツールヒントで使用するレイアウトファイル。
の7ファイルを作成してください
(simple_marker_view.xmlのみres/layoutフォルダ内、残りは上で作成した「chart」フォルダ内に作成)

6.3 データ操作用モジュールの作成

MongoDB Realmから取得したデータ(sensorsコレクションから取得したsensorQuery、およびsensor_listsコレクションから作成したlistQuery)を、
グラフ表示に適した形に整形するクラスを作成します。
作成先はMainActivity.ktと同じフォルダです。

RealmSensorDataConverter.kt
package com.mongodb.homeiotviewer

import com.mongodb.homeiotviewer.model.Sensor
import com.mongodb.homeiotviewer.model.SensorList
import io.realm.RealmQuery
import java.text.SimpleDateFormat
import java.util.*

/**
 * センサごと温湿度データ保持用データクラス (直近データ取得に使用)
 * @param[listQuery]:センサの一覧
 * @param[sensorQuery]: 日時
 */
class RealmSensorDataConverter(
    val listQuery: RealmQuery<SensorList>,
    val sensorQuery: RealmQuery<Sensor>) {
    //クラス内変数
    private var sensorList: List<SensorList>//センサ一覧
    private var sensorData: List<Sensor>//センサデータ(List化してクエリに影響を与えずに保持)
    private var lineData: List<Sensor>//折れ線グラフ用センサデータ(sensorDataから期間抜き出し)
    private var statsData: List<Sensor>//最高最低表示用CandleStickグラフ用センサデータ(sensorDataから期間抜き出し)
    //定数
    val SENSOR_TIME_ZONE = TimeZone.getTimeZone("Asia/Tokyo")//センサ取得時刻のタイムゾーン
    val TIME_SERIES_PERIOD: Int = 2//折れ線グラフに表示する期間(日)
    val STATS_PERIOD: Int = 30//最高最低表示用CandleStickグラフに表示する期間(日)

    //初期化 (基本データの計算)
    init{
        sensorList = listQuery.findAll().toList()//センサ一覧
        sensorData = sensorQuery.findAll().toList()//センサデータ(List化してクエリに影響を与えずに保持)

        //折れ線グラフ用センサデータ(「現在日時-TIME_SERIES_PERIOD」以降を取得)
        val currentDate = Date()//現在日時
        val lineStartDate = Calendar.getInstance().run{
            this.time = currentDate
            this.add(Calendar.DATE, -TIME_SERIES_PERIOD)
            this.time
        }
        lineData = sensorData.filter { it.Date_Master > lineStartDate }!!

        //折れ線グラフ用センサデータ(「現在日時-STATS_PERIOD」以降を取得)
        val statsStartDate = Calendar.getInstance().run{
            this.time = currentDate
            this.add(Calendar.DATE, -STATS_PERIOD)
            this.time
        }
        statsData = sensorData.filter { it.Date_Master > statsStartDate }!!
    }

    //センサごとの最新値取得
    fun GetNewestData(): MutableList<TempHumidData> {
        //格納用
        val newestData: MutableList<TempHumidData> = mutableListOf()
        //センサごとに走査
        for(sensorinfo in sensorList){
            //センサ番号
            val no: Int = sensorinfo.no?.toInt()!!
            //温湿度格納用
            var temp: Double? = null
            var humid: Double? = null
            //そのセンサでnullでない最後の取得時刻
            val dateField = "no" + "%02d".format(no) + "_Date"
            val lastDate = sensorQuery.maximumDate(dateField)
            //上記最新時刻のデータを取得
            //クエリに影響を与えないよう、フィルタ処理はListに対して実行
            val newestDoc: Sensor? = sensorData.filter { it.getDate(no) == lastDate }.firstOrNull()
            //温度測定しているセンサのとき
            if(sensorinfo.temperature!!){
                temp = newestDoc?.getTemprature(no)//温度
            }
            //湿度測定しているセンサのとき
            if(sensorinfo.humidity!!){
                humid = newestDoc?.getHumidity(no)//湿度
            }
            //日時
            var date = newestDoc?.getDate(no)!!
            //タイムゾーン変換(デフォルトだとGMTとして読み込まれているものを、センサのタイムゾーンに変換)
            date = changeTimeZone(date, TimeZone.getTimeZone("GMT"), SENSOR_TIME_ZONE)
            newestData.add(TempHumidData(sensorinfo.sensorname!!, date, sensorinfo.place!!,temp, humid))
        }
        return  newestData
    }

    //最新のエアコンOnOff情報を取得(複数ある場合はNoが最も若いもののみ使用)
    fun GetNewestAircon(): Pair<String?, String?> {
        //格納用
        var airconPower: String? = null
        var airconMode: String? = null
        //センサごとに走査
        for (sensorinfo in sensorList) {
            //センサ番号
            val no: Int = sensorinfo.no?.toInt()!!
            //エアコンデータを取得
            if(sensorinfo.aircon!!){
                //そのセンサがnullでない最後の取得時刻
                val dateField = "no" + "%02d".format(no) + "_Date"
                val lastDate = sensorQuery.maximumDate(dateField)
                //上記最新時刻のデータを取得
                //クエリに影響を与えないよう、フィルタ処理はListに対して実行
                val newestDoc: Sensor? = sensorData.filter { it.getDate(no) == lastDate }.firstOrNull()
                //エアコンOnOff情報
                airconPower = newestDoc?.getAirconPower(no)
                //エアコン冷暖房情報
                airconMode = newestDoc?.getAirconMode(no)
                //ループを抜ける
                break
            }
        }
        return Pair(airconPower, airconMode)
    }

    //最新の消費電力データを取得(複数ある場合はNoが最も若いもののみ使用)
    fun GetNewestPower(): Int? {
        //格納用
        var watt: Int? = null
        //センサごとに走査
        for (sensorinfo in sensorList) {
            //センサ番号
            val no: Int = sensorinfo.no?.toInt()!!
            //電力情報を取得
            if(sensorinfo.power!!){
                //そのセンサがnullでない最後の取得時刻
                val dateField = "no" + "%02d".format(no) + "_Date"
                val lastDate = sensorQuery.maximumDate(dateField)
                //上記最新時刻のデータを取得
                //クエリに影響を与えないよう、フィルタ処理はListに対して実行
                val newestDoc: Sensor? = sensorData.filter { it.getDate(no) == lastDate }.firstOrNull()
                //エアコンOnOff情報
                watt = newestDoc?.getWatt(no)?.toInt()
                //ループを抜ける
                break
            }
        }
        return watt
    }

    //場所ごとの時系列気温データ(折れ線グラフ用)
    fun getPlaceTempData(): Map<String, MutableList<Pair<Date, Double>>> {
        //場所とセンサ番号の辞書作成
        val places: List<String> =
            sensorList.filter { it.temperature == true }.mapNotNull { it.place }.distinct()
        val nPlace = places.count()
        val placeDict =
            sensorList.filter { it.temperature == true }.groupBy({ it.place }, { it.no?.toInt()!! })
        //場所ごと平均気温保持用リスト
        val placeTempData: MutableMap<String, MutableList<Pair<Date, Double>>> = mutableMapOf()
        //リストを場所ごとに初期化
        for (pl in places){
            placeTempData[pl] = mutableListOf()
        }

        //全データを走査
        for (sensorDoc in lineData) {
            var aveTemps: MutableMap<String, Double> = mutableMapOf()
            //場所ごとに平均気温を計算
            for (pl in places) {
                var sumTemp = 0.0
                var cnt = 0
                //場所内の全気温の和を計算
                for (no in placeDict[pl]!!) {
                    val temp = sensorDoc.getTemprature(no)
                    if (temp != null) {
                        sumTemp += temp
                        cnt++
                    }
                }
                //場所内にnullでない気温が存在するとき、平均気温を計算
                if (cnt > 0) {
                    aveTemps[pl] = sumTemp / cnt
                }
            }
            //全ての場所の平均気温が計算できているとき、平均気温保持用リストに追加
            if (aveTemps.count() == nPlace) {
                val masterDate = changeTimeZone(sensorDoc.Date_Master, TimeZone.getTimeZone("GMT"), SENSOR_TIME_ZONE)//タイムゾーン変換
                for (pl in places) {
                    placeTempData[pl]?.add(Pair(masterDate, aveTemps[pl]!!))
                }
            }
        }
        return placeTempData
    }

    //場所ごとの時系列湿度データ(折れ線グラフ用)
    fun getPlaceHumidData(): Map<String, MutableList<Pair<Date, Double>>> {
        //場所とセンサ番号の辞書作成
        val places: List<String> =
            sensorList.filter { it.humidity == true }.mapNotNull { it.place }.distinct()
        val nPlace = places.count()
        val placeDict =
            sensorList.filter { it.humidity == true }.groupBy({ it.place }, { it.no?.toInt()!! })
        //場所ごと平均気温保持用リスト
        val placeHumidData: MutableMap<String, MutableList<Pair<Date, Double>>> = mutableMapOf()
        //リストを場所ごとに初期化
        for (pl in places){
            placeHumidData[pl] = mutableListOf()
        }

        //全データを走査
        for (sensorDoc in lineData) {
            var aveHumids: MutableMap<String, Double> = mutableMapOf()
            //場所ごとに平均気温を計算
            for (pl in places) {
                var sumHumid = 0.0
                var cnt = 0
                //場所内の全気温の和を計算
                for (no in placeDict[pl]!!) {
                    val humid = sensorDoc.getHumidity(no)
                    if (humid != null) {
                        sumHumid += humid
                        cnt++
                    }
                }
                //場所内にnullでない気温が存在するとき、平均気温を計算
                if (cnt > 0) {
                    aveHumids[pl] = sumHumid / cnt
                }
            }
            //全ての場所の平均気温が計算できているとき、平均気温保持用リストに追加
            if (aveHumids.count() == nPlace) {
                val masterDate = changeTimeZone(sensorDoc.Date_Master, TimeZone.getTimeZone("GMT"), SENSOR_TIME_ZONE)//タイムゾーン変換
                for (pl in places) {
                    placeHumidData[pl]?.add(Pair(masterDate, aveHumids[pl]!!))
                }
            }
        }
        return placeHumidData
    }

    //日ごとの最高最低平均気温データ(ローソク足グラフ用)
    fun getDailyTempStatsData(place: String): Map<String, List<Pair<Date, Double>>>{
        //////まず対象場所の温度データを取得//////
        //対象場所の平均気温保持用リスト
        val tempData: MutableList<Pair<Date, Double>> = mutableListOf()
        //対象のセンサ番号のリスト
        val sensorIds = sensorList.filter { it.temperature == true && it.place == place}.map{it.no?.toInt()!!}
        //全データを走査
        for (sensorDoc in statsData) {
            //平均気温を計算
            var sumTemp = 0.0
            var cnt = 0
            //場所内の全気温の和を計算
            for (no in sensorIds) {
                val temp = sensorDoc.getTemprature(no)
                if (temp != null) {
                    sumTemp += temp
                    cnt++
                }
            }
            //場所内にnullでない気温が存在するとき、平均気温を計算
            if (cnt > 0) {
                val masterDate = changeTimeZone(sensorDoc.Date_Master, TimeZone.getTimeZone("GMT"), SENSOR_TIME_ZONE)//タイムゾーン変換
                tempData.add(Pair(masterDate ,sumTemp / cnt))
            }
        }

        //////最低最高気温を計算//////
        //日付でグルーピング(システムの日付なので注意)
        val sdf = SimpleDateFormat("yyyy/M/d")
        val grby = tempData.groupBy{ sdf.parse(sdf.format(it.first)) }
        //統計値算出(最低、平均、最高)
        val min = grby.map { Pair(it.key, it.value.map { it.second }.min()!!) }
        val avg = grby.map { Pair(it.key, it.value.map { it.second }.average()!!) }
        val max = grby.map { Pair(it.key, it.value.map { it.second }.max()!!) }
        //最高最低平均気温保持用リスト
        val tempStatsData: Map<String, List<Pair<Date, Double>>> = mapOf("min" to min, "avg" to avg, "max" to max)

        return tempStatsData
    }

    //日ごとの最高最低平均気温データ(ローソク足グラフ用)
    fun getDailyHumidStatsData(place: String): Map<String, List<Pair<Date, Double>>>{
        //////まず対象場所の温度データを取得//////
        //対象場所の平均気温保持用リスト
        val humidData: MutableList<Pair<Date, Double>> = mutableListOf()
        //対象のセンサ番号のリスト
        val sensorIds = sensorList.filter { it.humidity == true && it.place == place}.map{it.no?.toInt()!!}
        //全データを走査
        for (sensorDoc in statsData) {
            //平均気温を計算
            var sumHumid = 0.0
            var cnt = 0
            //場所内の全気温の和を計算
            for (no in sensorIds) {
                val humid = sensorDoc.getHumidity(no)
                if (humid != null) {
                    sumHumid += humid
                    cnt++
                }
            }
            //場所内にnullでない気温が存在するとき、平均気温を計算
            if (cnt > 0) {
                val masterDate = changeTimeZone(sensorDoc.Date_Master, TimeZone.getTimeZone("GMT"), SENSOR_TIME_ZONE)//タイムゾーン変換
                humidData.add(Pair(masterDate ,sumHumid / cnt))
            }
        }

        //////最低最高気温を計算//////
        //日付でグルーピング(システムの日付なので注意)
        val sdf = SimpleDateFormat("yyyy/M/d")
        val grby = humidData.groupBy{ sdf.parse(sdf.format(it.first)) }
        //統計値算出(最低、平均、最高)
        val min = grby.map { Pair(it.key, it.value.map { it.second }.min()!!) }
        val avg = grby.map { Pair(it.key, it.value.map { it.second }.average()!!) }
        val max = grby.map { Pair(it.key, it.value.map { it.second }.max()!!) }
        //最高最低平均気温保持用リスト
        val humidStatsData: Map<String, List<Pair<Date, Double>>> = mapOf("min" to min, "avg" to avg, "max" to max)

        return humidStatsData
    }
}
/**
 * センサごと温湿度データ保持用データクラス (直近データ取得に使用)
 * @param[sensorName]:センサ名
 * @param[date]: 日時
 * @param[place]: センサ設置場所
 * @param[temperature]: 温度
 * @param[humidity]: 湿度
 */
data class TempHumidData(val sensorName: String, val date: Date, val place: String, val temperature: Double?, val humidity: Double?)

各メソッドの内容は下記となります。
・GetNewestData():生存確認用のセンサごと最新データを取得("サマリー"タブで使用)
・GetNewestAircon():最新のエアコン稼働情報を取得("サマリー"タブで使用)
・GetNewestPower():最新の消費電力データを取得("サマリー"タブで使用)
・getPlaceTempData():気温折れ線グラフ用時系列データを取得("気温"タブで使用)
・getDailyTempStatsData():日ごとの最高最低気温データを取得("気温"タブで使用)
・getPlaceHumidData():湿度折れ線グラフ用時系列データを取得("湿度"タブで使用)
・getDailyHumidStatsData():日ごとの最高最低湿度データを取得("湿度"タブで使用)

6-4. 時間操作メソッド集の作成

6-3で使用するタイムゾーン変換用メソッドを含むTimeMethods.ktを、MainActivity.ktと同フォルダに作成します

TimeMethods.kt
package com.mongodb.homeiotviewer

import java.text.SimpleDateFormat
import java.util.*

//タイムゾーンを時刻を変えないまま変換(例: GMT 0:00 → Asia/Tokyo 0:00)
fun changeTimeZone(srcDate: Date, srcTimeZone: TimeZone, dstTimeZone: TimeZone): Date{
    val sdf: SimpleDateFormat = SimpleDateFormat("yyyyMMddHHmmssSSS")
    sdf.setTimeZone(srcTimeZone)
    val strDate = sdf.format(srcDate)//指定タイムゾーンでの現在時刻文字列
    sdf.setTimeZone(dstTimeZone)//タイムゾーンを指定タイムゾーンに設定
    return sdf.parse(strDate)
}

7. メイン画面とタブの作成

下図のような、3つのタブを持った画面を作成していきます
all.png
必要なファイル構成は3-3をご参照ください

7-1. 全体レイアウト(activity_main.xml)の作成

activity_main.xmlを開き、元々記載されているTextViewを削除します
20_delete_textview.png
下記のようなコードに書き換えます

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <androidx.viewpager.widget.ViewPager
        android:id="@+id/pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tab_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:tabIndicatorColor="@color/colorAccent"
            app:tabTextColor="#000000"
            app:tabSelectedTextColor="@color/colorAccent"/>
    </androidx.viewpager.widget.ViewPager>
</androidx.constraintlayout.widget.ConstraintLayout>

※各パーツの機能について
ViewPager:スワイプによる画面遷移を制御
TabLayout:タブ全体のレイアウト
(タブ毎のレイアウトTabItemもここで指定することがありますが、今回はTabPagerAdapter.ktで動的に指定します)

下図のようにエラーが出た場合、Add dependency on…をクリックします。
21_add_dependency.png
build.grandle(Module:app)にモジュールが追加されていることを確認します
22_confirm_add_dependency_material.png

7-2. MainActivity.ktに、TabPagerAdapter紐づけ&データ整形処理を追記

メイン画面から、タブのスワイプ遷移を制御するTabPagerAdapterへの紐づけ処理&6-2でメソッド化したデータ整形処理を追加します。

追加する処理
  :
import com.mongodb.homeiotviewer.tab.TabPagerAdapter
import kotlinx.android.synthetic.main.activity_main.*//追加

class MainActivity : AppCompatActivity() {
      :
    override fun onStart() {
          :
        //ログイン中ユーザが存在しないとき
        if (user == null) {
              :
        }
        //ログイン中ユーザが存在するとき
        else {
              :
            Realm.getInstanceAsync(config, object: Realm.Callback() {
                override fun onSuccess(realm: Realm) {
                    //同期したRealmインスタンスを親クラスMainActivityのインスタンスに設定
                    this@MainActivity.realm = realm
                    //クエリ操作用インスタンス作成
                    val listQuery: RealmQuery<SensorList> = realm.where<SensorList>().sort("no")//sensor_listsコレクション
                    val sensorQuery: RealmQuery<Sensor>  = realm.where<Sensor>().sort("Date_Master")//sensorsコレクション

                    //データ変換クラスのインスタンス作成
                    val sensorConverter = RealmSensorDataConverter(listQuery, sensorQuery)
                    //最新センサデータ
                    val newestData = sensorConverter.GetNewestData()
                    //最新エアコンおよび電力データ
                    val airconData = sensorConverter.GetNewestAircon()
                    val watt = sensorConverter.GetNewestPower()
                    //場所ごとの時系列気温データ(折れ線グラフ用)
                    val placeTempData = sensorConverter.getPlaceTempData()
                    //場所ごとの時系列湿度データ(折れ線グラフ用)
                    val placeHumidData = sensorConverter.getPlaceHumidData()
                    //日ごと最高最低平均気温データ
                    val tempStatsData = sensorConverter.getDailyTempStatsData("outdoor_shade")
                    //日ごと最高最低平均湿度データ
                    val humidStatsData = sensorConverter.getDailyHumidStatsData("outdoor_shade")

                    //Adapterの生成
                    pager.adapter = TabPagerAdapter(supportFragmentManager,
                        newestData,
                        airconData,
                        watt,
                        placeTempData,
                        placeHumidData,
                        tempStatsData,
                        humidStatsData,
                        this@MainActivity)
                    tab_layout.setupWithViewPager(pager)
                }
            })
        }
    }

また、下記のバックエンドテスト用処理は不要となるので削除します。

削除する処理
  :
import android.widget.TextView
      :
    private lateinit var queryTestView: TextView//クエリ結果表示用のtextViewインスタンス
      :
    override fun onCreate(savedInstanceState: Bundle?) {
          :
             ////////ここから下を削除////////
             val result = listQuery.sort("no").findAll()
             var resultString: String = ""
             for(device in result){
                 resultString += "${device.no},${device.sensorname},${device.place}\n"
             }
             //クエリ結果表示用のtextViewインスタンス作成
             queryTestView = findViewById(R.id.query_test)

追加、削除後のMainActivity.ktは下記のようになるはずです。

MainActivity.kt
package com.mongodb.homeiotviewer

import android.content.Intent
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.mongodb.homeiotviewer.model.Sensor
import com.mongodb.homeiotviewer.model.SensorList
import com.mongodb.homeiotviewer.tab.TabPagerAdapter
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.where
import io.realm.mongodb.User
import io.realm.mongodb.sync.SyncConfiguration
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {
    private lateinit var realm: Realm//Realmデータベースのインスタンス
    private var user: User? = null
    private var restartFlg: Boolean = false//再起動用のフラグ

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //Realmデータベースのインスタンス初期化
        realm = Realm.getDefaultInstance()
    }

    override fun onStart() {
        super.onStart()
        //ログイン中ユーザの取得
        try {
            user = app.currentUser()
        } catch (e: IllegalStateException) {
            Log.w(TAG(), e)
        }
        //ログイン中ユーザが存在しない時、ログイン画面を表示する
        if (user == null) {
            // if no user is currently logged in, start the login activity so the user can authenticate
            startActivity(Intent(this, LoginActivity::class.java))
        }
        //ログイン中ユーザが存在するとき
        else {
            //MongoDB Realmとの同期設定
            val partitionValue: String = "Project HomeIoT"//
            val config = SyncConfiguration.Builder(user!!, "Project HomeIoT")
                .waitForInitialRemoteData()
                .build()
            //上記設定をデフォルトとして保存
            Realm.setDefaultConfiguration(config)
            //非同期バックグラウンド処理でMongoDB Realmと同期実行
            Realm.getInstanceAsync(config, object: Realm.Callback() {
                override fun onSuccess(realm: Realm) {
                    //同期したRealmインスタンスを親クラスMainActivityのインスタンスに設定
                    this@MainActivity.realm = realm
                    //クエリ操作用インスタンス作成
                    val listQuery: RealmQuery<SensorList> = realm.where<SensorList>().sort("no")//sensor_listsコレクション
                    val sensorQuery: RealmQuery<Sensor>  = realm.where<Sensor>().sort("Date_Master")//sensorsコレクション

                    //データ変換クラスのインスタンス作成
                    val sensorConverter = RealmSensorDataConverter(listQuery, sensorQuery)
                    //最新センサデータ
                    val newestData = sensorConverter.GetNewestData()
                    //最新エアコンおよび電力データ
                    val airconData = sensorConverter.GetNewestAircon()
                    val watt = sensorConverter.GetNewestPower()
                    //場所ごとの時系列気温データ(折れ線グラフ用)
                    val placeTempData = sensorConverter.getPlaceTempData()
                    //場所ごとの時系列湿度データ(折れ線グラフ用)
                    val placeHumidData = sensorConverter.getPlaceHumidData()
                    //日ごと最高最低平均気温データ
                    val tempStatsData = sensorConverter.getDailyTempStatsData("outdoor_shade")
                    //日ごと最高最低平均湿度データ
                    val humidStatsData = sensorConverter.getDailyHumidStatsData("outdoor_shade")

                    //Adapterの生成
                    pager.adapter = TabPagerAdapter(supportFragmentManager,
                        newestData,
                        airconData,
                        watt,
                        placeTempData,
                        placeHumidData,
                        tempStatsData,
                        humidStatsData,
                        this@MainActivity)
                    tab_layout.setupWithViewPager(pager)
                }
            })
        }
    }

    override fun onStop() {
        super.onStop()
        user.run {
            realm.close()
        }
    }

    //アクティビティ終了時の処理(realmインスタンスをClose)
    override fun onDestroy() {
        super.onDestroy()
        // if a user hasn't logged out when the activity exits, still need to explicitly close the realm
        realm.close()
        //再起動時
        if(restartFlg){
            restartFlg = false
            val intent  = Intent()
            intent.setClass(this, this.javaClass)
            this.startActivity(intent)
        }
    }

    //logoutメニューをMainActivity上に設置
    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.logout_menu, menu)
        return true
    }

    //logoutメニューを押したときの処理(ログアウト)
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.action_logout -> {
                user?.logOutAsync {
                    if (it.isSuccess) {
                        // always close the realm when finished interacting to free up resources
                        realm.close()
                        user = null
                        Log.v(TAG(), "user logged out")
                        startActivity(Intent(this, LoginActivity::class.java))
                    } else {
                        Log.e(TAG(), "log out failed! Error: ${it.error}")
                    }
                }
                true
            }
            R.id.action_refresh -> {
                restartFlg = true
                this.finish()
                true
            }
            else -> {
                super.onOptionsItemSelected(item)
            }
        }
    }
}

7-3. TabPagerAdapter.ktの作成

タブのスワイプ遷移を制御するTabPagerAdapter.ktを作成します

javaフォルダ直下のパッケージフォルダを右クリックし、New → Packageを選択し、"tab"と名前をつけます
11_chart.png

作成した"tab"フォルダを右クリックし、New → Kotlin File/Classを選択し、"TabPagerAdapter.kt"と名前を付け、下記のコードを記載します。
23_make_tab_class.png

TabPagerAdapter.kt
package com.mongodb.homeiotviewer.tab

import android.content.Context
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import com.mongodb.homeiotviewer.TempHumidData
import java.util.*

class TabPagerAdapter(
    fm: FragmentManager,
    val newestData: MutableList<TempHumidData>,
    val airconData: Pair<String?, String?>,
    val watt: Int?,
    val placeTempData: Map<String, MutableList<Pair<Date, Double>>>,
    val placeHumidData: Map<String, MutableList<Pair<Date, Double>>>,
    val tempStatsData: Map<String, List<Pair<Date, Double>>>,
    val humidStatsData: Map<String, List<Pair<Date, Double>>>,
    private val context: Context)
    : FragmentPagerAdapter(fm){

    //各タブごとのFragmentインスタンス作成
    override fun getItem(position: Int): Fragment {
        when(position){
            0 -> { return SummaryFragment() }
            1 -> { return TempFragment() }
            else ->  { return HumidFragment() }
        }
    }

    //各タブごとのタイトル付与
    override fun getPageTitle(position: Int): CharSequence? {
        when(position){
            0 -> { return "サマリー" }
            1 -> { return "気温" }
            else ->  { return "湿度" }
        }
    }

    //タブの最大値をset
    override fun getCount(): Int {
        return 3
    }
}

コメントにも記載していますが、各メソッドの意味は下記となります(引数「position」は何番目のタブかを表す)
getItem(position):各タブごとのFragmentインスタンス作成
getPageTitle(position):各タブごとのタイトル付与
getCount():タブの最大値をset()(positionの最大値+1なので注意)

余談ですが、ViewPagerではgetItem等のメソッド呼び出しは、スワイプした隣のタブまで実行されるそうです。
(次のスワイプに備えた準備)
例えばタブ1からタブ2にスワイプしたタイミングで、タブ3のFragmentインスタンスまで生成されることとなります。
スワイプ時に隣のタブの処理が想定外に実行されないよう、注意してコーディングする必要がありそうですね。

7-4. タブ(Fragment)ごとのレイアウト作成

"Summary"、"Temperature"、"Humidity"の3種類のタブに対応したレイアウト (fragment_tab_*.xml)を作成します。

下図のように、app\res\layoutフォルダ内に"fragment_tab_summary.xml"ファイルを作成します。
24_make_fragment_xml1.png
25_make_fragment_xml2.png
同様の手順で、"fragment_tab_temp.xml"、"fragment_tab_humid.xml"も作成します。

7-5. タブ(Fragment)ごとのクラスファイル作成

"Summary"、"Temperature"、"Humidity"の3種類のタブに対応したFragmentクラスを作成します。

"tab"フォルダを右クリックし、New → Kotlin File/Classを選択し、"SummaryFragment.kt"と名前を付けたクラスを作成し、下記コードを記載します
23_make_tab_class.png

SummaryFragment.kt
package com.mongodb.homeiotviewer.tab

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.mongodb.homeiotviewer.R

class SummaryFragment: Fragment(){
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_tab_summary,container,false)
    }
}

同様の手順で、"TempFragment.kt"、"HumidFragment.kt"も作成します.

TempFragment.kt
package com.mongodb.homeiotviewer.tab

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.mongodb.homeiotviewer.R

class TempFragment: Fragment(){
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_tab_temp,container,false)
    }
}
HumidFragment.kt
package com.mongodb.homeiotviewer.tab

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.mongodb.homeiotviewer.R

class HumidFragment: Fragment(){
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_tab_humid,container,false)
    }
}

7-6. タブ表示の確認

下図のように、エミュレータでタブが表示され、スワイプで遷移できれば成功です
26_confirm_tab_emurator.png

8.「サマリー」タブのUI実装

ここからはタブごとにレイアウトと処理内容をコーディングします。
まずは下図のような「サマリー」タブの中身を作成していきます。
Summary.png

8-1. レイアウト

7-4で作成したfragment_tab_summary.xmlを下記のように書き換え、「サマリー」タブのレイアウトを完成させます

fragment_tab_summary.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#333333"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/textViewTempLabel"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:gravity="end|center_vertical"
        android:text="気温"
        android:textColor="@android:color/white"
        android:textSize="18sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/imageViewThermometer"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/piechartTempIndoor"
        android:layout_marginTop="@dimen/tabTopMargin" />
    <ImageView
        android:id="@+id/imageViewThermometer"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:scaleType="fitStart"
        app:srcCompat="@drawable/ondokei_mini"
        app:layout_constraintStart_toEndOf="@+id/textViewTempLabel"
        app:layout_constraintEnd_toStartOf="@+id/textViewHumidLabel"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/piechartTempIndoor"
        android:layout_marginTop="@dimen/tabTopMargin" />
    <TextView
        android:id="@+id/textViewHumidLabel"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:text="湿度"
        android:textColor="@android:color/white"
        android:textSize="18sp"
        android:gravity="end|center_vertical"
        app:layout_constraintStart_toEndOf="@+id/imageViewThermometer"
        app:layout_constraintEnd_toStartOf="@+id/imageViewWaterCharacter"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/piechartTempIndoor"
        android:layout_marginTop="@dimen/tabTopMargin" />
    <ImageView
        android:id="@+id/imageViewWaterCharacter"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:scaleType="fitStart"
        app:srcCompat="@drawable/water_character_mini"
        app:layout_constraintStart_toEndOf="@id/textViewHumidLabel"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/piechartTempIndoor"
        android:layout_marginTop="@dimen/tabTopMargin" />

    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartTempIndoor"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/piechartTempOutdoor"
        app:layout_constraintTop_toBottomOf="@+id/imageViewThermometer"
        app:layout_constraintBottom_toTopOf="@+id/textViewSensorTableLabel"
        android:layout_marginBottom="@dimen/pieTableMargin" />
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartTempOutdoor"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartTempIndoor"
        app:layout_constraintEnd_toStartOf="@+id/piechartHumidIndoor"
        app:layout_constraintTop_toBottomOf="@+id/imageViewThermometer"
        app:layout_constraintBottom_toTopOf="@+id/textViewSensorTableLabel"
        android:layout_marginBottom="@dimen/pieTableMargin" />
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartHumidIndoor"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartTempOutdoor"
        app:layout_constraintEnd_toStartOf="@+id/piechartHumidOutdoor"
        app:layout_constraintTop_toBottomOf="@+id/imageViewThermometer"
        app:layout_constraintBottom_toTopOf="@+id/textViewSensorTableLabel"
        android:layout_marginBottom="@dimen/pieTableMargin" />
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartHumidOutdoor"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartHumidIndoor"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/imageViewThermometer"
        app:layout_constraintBottom_toTopOf="@+id/textViewSensorTableLabel"
        android:layout_marginBottom="@dimen/pieTableMargin" />

    <TextView
        android:id="@+id/textViewSensorTableLabel"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="センサ取得状況"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/piechartTempIndoor"
        app:layout_constraintBottom_toTopOf="@+id/sensorTableLayout"/>
    <TextView
        android:id="@+id/textViewSummaryTimeZone"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="end|bottom"
        android:text=""
        android:textColor="@android:color/white"
        android:textSize="@dimen/timeZoneTextSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/piechartTempIndoor"
        app:layout_constraintBottom_toTopOf="@+id/sensorTableLayout"/>
    <TableLayout
        android:id="@+id/sensorTableLayout"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_marginStart="5dp"
        android:layout_marginEnd="5dp"
        android:background="#eeeeee"
        android:stretchColumns="0"
        android:shrinkColumns="0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textViewSensorTableLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewAirconLabel">

        <TableRow
            android:id="@+id/tableRow1"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="5dp">

            <TextView
                android:id="@+id/textViewSensorName"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:background="@drawable/round_text_gray"
                android:gravity="center"
                android:paddingLeft="6dp"
                android:paddingTop="2dp"
                android:paddingRight="6dp"
                android:paddingBottom="2dp"
                android:text="SensorName"
                android:textColor="#fafafa"
                android:textSize="13sp" />

            <TextView
                android:id="@+id/textViewPlace"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="2dp"
                android:background="@drawable/round_text_gray"
                android:gravity="center"
                android:paddingLeft="6dp"
                android:paddingTop="2dp"
                android:paddingRight="6dp"
                android:paddingBottom="2dp"
                android:text="Place"
                android:textColor="#fafafa"
                android:textSize="13sp" />

            <TextView
                android:id="@+id/textViewLastDate"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="2dp"
                android:background="@drawable/round_text_gray"
                android:gravity="center"
                android:paddingLeft="6dp"
                android:paddingTop="2dp"
                android:paddingRight="6dp"
                android:paddingBottom="2dp"
                android:text="LastDate"
                android:textColor="#fafafa"
                android:textSize="13sp" />

            <TextView
                android:id="@+id/textViewTemperature"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="2dp"
                android:background="@drawable/round_text_orange"
                android:gravity="center"
                android:paddingLeft="6dp"
                android:paddingTop="2dp"
                android:paddingRight="6dp"
                android:paddingBottom="2dp"
                android:fontFamily="sans-serif-condensed"
                android:text="Temperature"
                android:textColor="#fafafa"
                android:textSize="13sp" />

            <TextView
                android:id="@+id/textViewHumidity"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="2dp"
                android:background="@drawable/round_text_blue"
                android:gravity="center"
                android:paddingLeft="10dp"
                android:paddingTop="2dp"
                android:paddingRight="10dp"
                android:paddingBottom="2dp"
                android:text="Humidity"
                android:textColor="#fafafa"
                android:textSize="13sp" />
        </TableRow>
    </TableLayout>
    <TextView
        android:id="@+id/textViewAirconLabel"
        android:layout_width="0dp"
        android:layout_height="30dp"
        android:gravity="center_horizontal|top"
        android:text="エアコン"
        android:textColor="@android:color/white"
        android:textSize="18sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/textViewPowerLabel"
        app:layout_constraintTop_toBottomOf="@+id/sensorTableLayout"
        app:layout_constraintBottom_toTopOf="@+id/imageViewAircon"/>
    <ImageView
        android:id="@+id/imageViewAircon"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:scaleType="fitCenter"
        app:srcCompat="@drawable/aircon_off"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="@+id/textViewAirconLabel"
        app:layout_constraintTop_toBottomOf="@+id/textViewAirconLabel"
        app:layout_constraintBottom_toBottomOf="parent"
        android:layout_marginBottom="@dimen/tabBottomMargin"/>
    <TextView
        android:id="@+id/textViewPowerLabel"
        android:layout_width="0dp"
        android:layout_height="30dp"
        android:gravity="center_horizontal|top"
        android:text="消費電力"
        android:textColor="@android:color/white"
        android:textSize="18sp"
        app:layout_constraintStart_toEndOf="@+id/textViewAirconLabel"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/sensorTableLayout"
        app:layout_constraintBottom_toTopOf="@+id/imageViewAircon"/>
    <ImageView
        android:id="@+id/imageViewPower"
        android:layout_width="60dp"
        android:layout_height="40dp"
        android:scaleType="fitEnd"
        app:srcCompat="@drawable/denryoku_mark_mini"
        app:layout_constraintStart_toEndOf="@+id/textViewAirconLabel"
        app:layout_constraintEnd_toStartOf="@+id/textViewPower"
        app:layout_constraintTop_toBottomOf="@+id/textViewPowerLabel"
        app:layout_constraintBottom_toBottomOf="parent"
        android:layout_marginBottom="@dimen/tabBottomMargin"/>
    <TextView
        android:id="@+id/textViewPower"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="W"
        android:textColor="@android:color/white"
        android:textSize="18sp"
        app:layout_constraintStart_toEndOf="@+id/imageViewPower"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textViewPowerLabel"
        app:layout_constraintBottom_toBottomOf="parent"
        android:layout_marginBottom="@dimen/tabBottomMargin"/>
</androidx.constraintlayout.widget.ConstraintLayout>

PieChartは円グラフのウィジェットとなります

ちなみに、各ウィジェットの位置調整はAndroidStudioのGUI画面でもできますが、
慣れていないと座標がハードコーディングだらけのカオスな指定となってしまうので、
こちらを参考にテキストエディタで座標指定するのが良いかと思います

下図のように、Android Studioの「Split」表示を使用すると、コードとGUIを比較表示できて調整しやすいかと思います。
image.png

8-2. クラスファイル

7-5で作成したSummaryFragment.ktを下記のように書き換え、"サマリー"タブの処理を完成させます

SummaryFragment.kt
package com.mongodb.homeiotviewer.tab

import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TableLayout
import android.widget.TableRow
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.mongodb.homeiotviewer.R
import com.mongodb.homeiotviewer.TempHumidData
import com.mongodb.homeiotviewer.chart.*
import kotlinx.android.synthetic.main.fragment_tab_summary.view.*
import kotlinx.android.synthetic.main.table_row_sensorinfo.view.*
import java.text.SimpleDateFormat
import java.util.*

class SummaryFragment(val newestSensorData: MutableList<TempHumidData>,
                      val airconData: Pair<String?, String?>,
                      val watt: Int?)
    : Fragment() {
    //固定値変数
    val SENSOR_FAIL_THRESHOLD: Double = 30.0//「現在時刻 - 最新取得時間」がここで指定した分以上なら、異常発生とみなしてセンサ情報テーブルを強調表示

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        //サンプルデータ作成
        val sumpleData = makeSummarySumple()
        //使用データをサンプルにするか本番用にするか選択
        val tempHumidData = newestSensorData

        //レイアウトからViewを生成
        val summaryView: View = inflater.inflate(R.layout.fragment_tab_summary, container, false)

        //現在の温湿度データの描画
        refreshSummaryTempData(tempHumidData, summaryView)

        //タイムゾーンを表示
        summaryView.textViewSummaryTimeZone.text = "[TimeZone=${TimeZone.getDefault().id}]  "

        //センサテーブル情報表示
        refreshSensorTable(tempHumidData, summaryView.sensorTableLayout)

        //エアコンおよび消費電力情報表示
        refreshAirconAndPower(summaryView.imageViewAircon, summaryView.textViewPower, airconData, watt)
        return summaryView
    }

    //現在の温湿度データの描画
    private fun refreshSummaryTempData(sensorData: MutableList<TempHumidData>, view: View) {
        //屋内と屋外にデータを分ける
        val indoorData = sensorData.filter { it.place == "indoor" }
        val outdoorData = sensorData.filter { it.place.split("_").first() == "outdoor" }
        //屋内外の平均温湿度を計算
        val indoorTemp = indoorData.mapNotNull { it.temperature }.average()
        val outdoorTemp = outdoorData.mapNotNull { it.temperature }.average()
        val indoorHumid = indoorData.mapNotNull { it.humidity }.average()
        val outdoorHumid = outdoorData.mapNotNull { it.humidity }.average()
        //屋内温度を円グラフ用データに整形
        val (dimensionsIndoorTemp, valuesIndoorTemp) = makePieDashboardData(indoorTemp.toFloat(), -10f, 40f)
        //屋外温度を円グラフ用に整形
        val (dimensionsOutdoorTemp, valuesOutdoorTemp) = makePieDashboardData(outdoorTemp.toFloat(), -10f, 40f)
        //屋内湿度を円グラフ用に整形
        val (dimensionsIndoorHumid, valuesIndoorHumid) = makePieDashboardData(indoorHumid.toFloat(), 0f, 100f)
        //屋外湿度を円グラフ用に整形
        val (dimensionsOutdoorHumid, valuesOutdoorHumid) = makePieDashboardData(outdoorHumid.toFloat(), 0f, 100f)
        //Chartフォーマット
        val indoorTempChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "屋内\n${"%.1f".format(indoorTemp)}°C",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val outdoorTempChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "屋外\n${"%.1f".format(outdoorTemp)}°C",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val indoorHumidChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "屋内\n${"%.1f".format(indoorHumid)}%",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val outdoorHumidChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "屋外\n${"%.1f".format(outdoorHumid)}%",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        //DataSetフォーマット
        val indoorTempDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.rgb(243, 201, 14), Color.GRAY)//円の色
        )
        val outdoorTempDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.RED, Color.GRAY)//円の色
        )
        val indoorHumidDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.CYAN, Color.GRAY)//円の色
        )
        val outdoorHumidDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.BLUE, Color.GRAY)//円の色
        )

        //①場所ごとにEntryのリストを作成
        val indoorTempEntries = makePieChartEntries(dimensionsIndoorTemp, valuesIndoorTemp)
        val outdoorTempEntries = makePieChartEntries(dimensionsOutdoorTemp, valuesOutdoorTemp)
        val indoorHumidEntries = makePieChartEntries(dimensionsIndoorHumid, valuesIndoorHumid)
        val outdoorHumidEntries = makePieChartEntries(dimensionsOutdoorHumid, valuesOutdoorHumid)
        //②~⑦グラフ描画
        setupPieChart(indoorTempEntries, view.piechartTempIndoor, "室内温度",
            indoorTempChartFormat, indoorTempDSFormat)
        setupPieChart(outdoorTempEntries, view.piechartTempOutdoor, "室外温度",
            outdoorTempChartFormat, outdoorTempDSFormat)
        setupPieChart(indoorHumidEntries, view.piechartHumidIndoor, "室内湿度",
            indoorHumidChartFormat, indoorHumidDSFormat)
        setupPieChart(outdoorHumidEntries, view.piechartHumidOutdoor, "室外湿度",
            outdoorHumidChartFormat, outdoorHumidDSFormat)
    }

    //センサ情報テーブル表示
    private fun refreshSensorTable(newestSensorData: MutableList<TempHumidData>, tableLayout: TableLayout) {
        for ((i, sensor) in newestSensorData.withIndex()) {
            val tableRow: TableRow = getLayoutInflater().inflate(R.layout.table_row_sensorinfo, null) as TableRow
            //各列の情報を入力
            tableRow.rowtextSensorName.text = sensor.sensorName
            tableRow.rowtextPlace.text = sensor.place.split("_").first()//場所はアンダーバーで分けた最初のみ考慮(日なたと日陰は分けない)
            tableRow.rowtextLastDate.text = SimpleDateFormat("MM/dd HH:mm").format(sensor.date)
            tableRow.rowtextTemperature.text = "${sensor.temperature}°C"
            tableRow.rowtextHumidity.text = "${sensor.humidity}%"
            //取得時間が現在時刻より〇分以上前なら、背景色を変える
            val durationFromSuccess: Double = (Date().time - sensor.date.time) / (1000.0 * 60.0)
            emphasizeTextByThreshold(durationFromSuccess, SENSOR_FAIL_THRESHOLD, 0.0, Color.YELLOW,
                mutableListOf(tableRow.rowtextSensorName,tableRow.rowtextPlace, tableRow.rowtextLastDate, tableRow.rowtextTemperature, tableRow.rowtextHumidity))
            //行を追加
            tableLayout.addView(tableRow)
        }
    }

    //閾値を超えたらTextViewの色を変える
    private fun <T: Comparable<T>> emphasizeTextByThreshold(value: T, upper: T, lower: T, color: Int, rowTextViews: MutableList<TextView>){
        //上側閾値
        if((value > upper) or (value < lower)){
            for(textView in rowTextViews){
                textView.setBackgroundColor(color)
            }
        }
    }

    //エアコンおよび消費電力情報表示
    private fun refreshAirconAndPower(imageViewAircon: ImageView, textViewPower: TextView, airconData: Pair<String?, String?>, watt: Int?){
        //エアコンOn-Off情報を画像表示
        //エアコンOnのとき
        if((airconData.first == "power-on") or (airconData.first == "power-on_maybe"))
        {
            //モードによって表示画像を変える
            when(airconData.second){
                "cool" -> { imageViewAircon.setImageResource(R.drawable.aircon_cold) }
                "warm" -> { imageViewAircon.setImageResource(R.drawable.aircon_hot) }
                "dry" -> { imageViewAircon.setImageResource(R.drawable.aircon_dry) }
            }
        }
        //エアコンOffのとき
        else{
            imageViewAircon.setImageResource(R.drawable.aircon_off)
        }

        //消費電力を数値で表示
        if(watt != null){
            textViewPower.text = watt.toString() + " W"
        }
    }
}

各メソッドの内容は下記となります。
・refreshSummaryTempData():現在の気温および湿度円グラフを作成(円グラフ作成法はこちらも参考)
・refreshSensorTable():センサごと最新データ取得状況テーブルを作成
・emphasizeTextByThreshold():上記テーブルで一定期間データ取得できていない行を黄色強調
・refreshAirconAndPower():エアコンおよび消費電力情報表示

8-3. TabPagerAdapter.ktに引数追加

7-3で作成したTabPagerAdapter.ktを下記のように書き換え、SummaryFragmentの変更に合わせた引数を追加します

TabPagerAdapter.kt
   
override fun getItem(position: Int): Fragment {
        when(position){
            0 -> { return SummaryFragment(newestData, airconData, watt) }//ここに引数を追加
            1 -> { return TempFragment() }
            else ->  { return HumidFragment() }
   

9.「気温」タブのUI実装

下図のような「気温」タブの中身を作成していきます。
Temerature.png

9-1. レイアウト

7-4で作成したfragment_tab_temp.xmlを下記のように書き換え、「気温」タブのレイアウトを完成させます

fragment_tab_temp.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#333333"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/textViewTempPieLabel"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="現在の場所ごと気温"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/piechartTempIndoorTemp"
        android:layout_marginTop="@dimen/tabTopMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartTempIndoorTemp"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/piechartTempKitchen"
        app:layout_constraintTop_toBottomOf="@+id/textViewTempPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieTempSeriesMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartTempKitchen"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartTempIndoorTemp"
        app:layout_constraintEnd_toStartOf="@+id/piechartTempOutdoorShade"
        app:layout_constraintTop_toBottomOf="@+id/textViewTempPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieTempSeriesMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartTempOutdoorShade"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartTempKitchen"
        app:layout_constraintEnd_toStartOf="@+id/piechartTempOutdoorSunny"
        app:layout_constraintTop_toBottomOf="@+id/textViewTempPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieTempSeriesMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartTempOutdoorSunny"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartTempOutdoorShade"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textViewTempPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieTempSeriesMargin" />

    <TextView
        android:id="@+id/textViewSeriesLineChartLabel"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="場所ごと気温推移"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/piechartTempIndoorTemp"
        app:layout_constraintBottom_toTopOf="@+id/lineChartTempTimeSeries"/>
    <TextView
        android:id="@+id/textViewTempTimeZone"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="end|bottom"
        android:text=""
        android:textColor="@android:color/white"
        android:textSize="@dimen/timeZoneTextSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/piechartTempIndoorTemp"
        app:layout_constraintBottom_toTopOf="@+id/lineChartTempTimeSeries"/>
    <com.github.mikephil.charting.charts.LineChart
        android:id="@+id/lineChartTempTimeSeries"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textViewSeriesLineChartLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewTempStatsLabel"
        android:layout_marginBottom="@dimen/tempSeriesStatsMargin"/>
    <TextView
        android:id="@+id/textViewTempStatsLabel"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="屋外(日陰)の最高最低気温"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/lineChartTempTimeSeries"
        app:layout_constraintBottom_toTopOf="@+id/candleStickChartTempStats"/>
    <com.github.mikephil.charting.charts.CandleStickChart
        android:id="@+id/candleStickChartTempStats"
        android:layout_width="0dp"
        android:layout_height="@dimen/statsCandleStickChartSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textViewTempStatsLabel"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

LineChartは折れ線グラフ、CandleStickChartはローソク足グラフ(最高最低気温表示)のウィジェットとなります

9-2. クラスファイル

7-5で作成したTempFragment.ktを下記のように書き換え、"気温"タブの処理を完成させます

TempFragment.kt
package com.mongodb.homeiotviewer.tab

import android.content.Context
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.data.Entry
import com.mongodb.homeiotviewer.R
import com.mongodb.homeiotviewer.TempHumidData
import com.mongodb.homeiotviewer.chart.*
import kotlinx.android.synthetic.main.fragment_tab_temp.view.*
import java.text.SimpleDateFormat
import java.util.*

class TempFragment(
    val newestSensorData: MutableList<TempHumidData>,
    val placeTempData: Map<String, MutableList<Pair<Date, Double>>>,
    val tempStatsData: Map<String, List<Pair<Date, Double>>>)
    : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        //レイアウトからViewを生成
        val tempView: View = inflater.inflate(R.layout.fragment_tab_temp, container, false)
        //現在の温湿度データの描画
        refreshCurrentTempData(newestSensorData, tempView)

        //タイムゾーンを表示
        tempView.textViewTempTimeZone.text = "[TimeZone=${TimeZone.getDefault().id}]  "

        //温度推移グラフの描画
        //FragmentのContextはrequireContextを使用(https://developer.android.com/kotlin/common-patterns?hl=ja)
        refreshTempTimeSeriesData(this.requireContext(), placeTempData, tempView)

        //日ごとの最高最低平均気温グラフ描画
        refreshTempStatsData(this.requireContext(), tempStatsData, tempView)

        return tempView
    }

    //現在の気温データの描画
    private fun refreshCurrentTempData(sensorData: MutableList<TempHumidData>, view: View) {
        //屋内、キッチン、日陰、日なたにデータを分ける
        val indoorData = sensorData.filter { it.place == "indoor" }
        val kitchenData = sensorData.filter { it.place == "kitchen" }
        val shadeData = sensorData.filter { it.place == "outdoor_shade" }
        val sunnyData = sensorData.filter { it.place == "outdoor_sunny" }
        //場所ごとの平均気温を計算
        val indoorTemp = indoorData.mapNotNull { it.temperature }.average()
        val kitchenTemp = kitchenData.mapNotNull { it.temperature }.average()
        val shadeTemp = shadeData.mapNotNull { it.temperature }.average()
        val sunnyTemp = sunnyData.mapNotNull { it.temperature }.average()

        //Chartフォーマット
        val indoorChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "屋内\n${"%.1f".format(indoorTemp)}°C",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val kitchenChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "キッチン\n${"%.1f".format(kitchenTemp)}°C",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val shadeChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "日陰\n${"%.1f".format(shadeTemp)}°C",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val sunnyChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "日なた\n${"%.1f".format(sunnyTemp)}°C",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        //DataSetフォーマット
        val indoorDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.rgb(243, 201, 14), Color.GRAY)//円の色
        )
        val kitchenDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.rgb(128, 255, 0), Color.GRAY)//円の色
        )
        val shadeDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.rgb(185, 122, 87), Color.GRAY)//円の色
        )
        val sunnyDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.RED, Color.GRAY)//円の色
        )

        //屋内気温を円グラフ用データに整形
        val (dimensionsIndoorTemp, valuesIndoorTemp) = makePieDashboardData(
            indoorTemp.toFloat(),
            -10f,
            40f
        )
        //キッチン気温を円グラフ用に整形
        val (dimensionsKitchenTemp, valuesKitchenTemp) = makePieDashboardData(
            kitchenTemp.toFloat(),
            -10f,
            40f
        )
        //日陰気温を円グラフ用に整形
        val (dimensionsShadeTemp, valuesShadeTemp) = makePieDashboardData(
            shadeTemp.toFloat(),
            -10f,
            40f
        )
        //日なた気温を円グラフ用に整形
        val (dimensionsSunnyTemp, valuesSunnyTemp) = makePieDashboardData(
            sunnyTemp.toFloat(),
            -10f,
            40f
        )
        //①場所ごとにEntryのリストを作成
        val indoorEntries = makePieChartEntries(dimensionsIndoorTemp, valuesIndoorTemp)
        val kitchenEntries = makePieChartEntries(dimensionsKitchenTemp, valuesKitchenTemp)
        val shadeEntries = makePieChartEntries(dimensionsShadeTemp, valuesShadeTemp)
        val sunnyEntries = makePieChartEntries(dimensionsSunnyTemp, valuesSunnyTemp)
        //②~⑦円グラフ描画
        setupPieChart(
            indoorEntries, view.piechartTempIndoorTemp, "室内気温",
            indoorChartFormat, indoorDSFormat
        )
        setupPieChart(
            kitchenEntries, view.piechartTempKitchen, "室外気温",
            kitchenChartFormat, kitchenDSFormat
        )
        setupPieChart(
            shadeEntries, view.piechartTempOutdoorShade, "室内湿度",
            shadeChartFormat,shadeDSFormat
        )
        setupPieChart(
            sunnyEntries, view.piechartTempOutdoorSunny, "室外湿度",
            sunnyChartFormat, sunnyDSFormat
        )
    }

    //温度推移折れ線グラフの描画
    private fun refreshTempTimeSeriesData(
        context: Context,
        tempSeriesData: Map<String, MutableList<Pair<Date, Double>>>,
        view: View
    ) {
        //Chartフォーマット
        val tempLineChartFormat = LineChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor = null,//凡例文字色
            description = null,//グラフ説明
            bgColor = Color.BLACK,//背景塗りつぶし色
            touch = true,//タッチ有効
            xAxisEnabled = true,//X軸有効
            xAxisTextColor = Color.WHITE,//X軸文字色
            xAxisDateFormat = SimpleDateFormat("M/d H:mm"),//X軸の日付軸フォーマット(日付軸でないときnull指定)
            yAxisLeftEnabled = true,//左Y軸有効
            yAxisLeftTextColor = Color.WHITE,//左Y軸文字色
            yAxisRightEnabled = false,//右Y軸有効
            zoomDirection = "xy",//ズームの方向(x, y, xy, null=ズーム無効)
            zoomPinch = false,//ズームのピンチ動作をXY同時にするか(trueなら同時、falseなら1軸に限定)
            toolTipDirection = "xy",//ツールチップに表示する軸内容(x, y, xy, null=表示なし)
            toolTipDateFormat = SimpleDateFormat("M/d H:mm"),//ツールチップX軸表示の日付フォーマット(日付軸以外ならnull)
            toolTipUnitX = "",//ツールチップのX軸内容表示に付加する単位
            toolTipUnitY = "°C",//ツールチップのY軸内容表示に付加する単位
            timeAccuracy = false//時系列グラフのX軸を正確にプロットするか
        )
        //DataSetフォーマット(カテゴリ名のMapで作成)
        val tempLineDataSetFormat: Map<String, LineDataSetFormat> = mapOf(
            "indoor" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.rgb(243, 201, 14),
                2f,
                null,
                false,
                null,
                null
            ),
            "kitchen" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.rgb(128, 255, 0),
                2f,
                null,
                false,
                null,
                null
            ),
            "outdoor_shade" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.rgb(185, 122, 87),
                2f,
                null,
                false,
                null,
                null
            ),
            "outdoor_sunny" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.RED,
                2f,
                null,
                false,
                null,
                null
            )
        )
        //①場所ごとに必要期間のデータ抜き出してEntryのリストに入力
        val places = tempSeriesData.keys//場所のリスト
        val allLinesEntries: MutableMap<String, MutableList<Entry>> = mutableMapOf()
        for(pl in places){
            //要素数が0なら処理を終了
            if(tempSeriesData[pl]?.size == 0) return
            //Entryにデータ入力
            val x = tempSeriesData[pl]?.map { it.first }!!//X軸(日時データ)
            val y = tempSeriesData[pl]?.map { it.second.toFloat() }!!//Y軸(温度データ)
            allLinesEntries[pl] = makeDateLineChartData(x, y, tempLineChartFormat.timeAccuracy)//日時と温度をEntryのリストに変換
        }

        //②~⑦グラフの作成
        setupLineChart(allLinesEntries, view.lineChartTempTimeSeries, tempLineChartFormat, tempLineDataSetFormat, context)
    }

    //屋外日陰の最高最低気温推移ローソク足グラフの描画
    private fun refreshTempStatsData(
        context: Context,
        tempSeriesData: Map<String, List<Pair<Date, Double>>>,
        view: View
    ){
        //要素数が0なら終了
        if(tempSeriesData["max"]?.size == 0) return

        //Chartフォーマット
        val candleChartFormat = CandleChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor = null,//凡例文字色
            description = null,//グラフ説明
            bgColor = Color.BLACK,//背景塗りつぶし色
            touch = true,//タッチ有効
            xAxisEnabled = true,//X軸有効
            xAxisTextColor = Color.WHITE,//X軸文字色
            xAxisDateFormat = SimpleDateFormat("M/d"),//X軸の日付軸フォーマット(日付軸でないときnull指定)
            yAxisLeftEnabled = true,//左Y軸有効
            yAxisLeftTextColor = Color.WHITE,//左Y軸文字色
            yAxisRightEnabled = false,//右Y軸有効
            zoomDirection = "xy",//ズームの方向(x, y, xy, null=ズーム無効)
            zoomPinch = false,//ズームのピンチ動作をXY同時にするか(trueなら同時、falseなら1軸に限定)
            toolTipDirection = null,//ツールチップに表示する軸内容(x, y, xy, null=表示なし)
            toolTipDateFormat = SimpleDateFormat("M/d"),//ツールチップX軸表示の日付フォーマット(日付軸以外ならnull)
            toolTipUnitX = "",//ツールチップのX軸内容表示に付加する単位
            toolTipUnitY = ""//ツールチップのY軸内容表示に付加する単位
        )
        //DataSetフォーマット
        val candleDSFormat = CandleDataSetFormat(
            true,//値の表示有無
            Color.rgb(220, 120, 80),//値表示の文字色
            7f,//値表示の文字サイズ
            "%.0f",//値表示の文字書式
            YAxis.AxisDependency.LEFT,//使用する軸
            Color.rgb(220, 120, 80),//細線部分の色
            2.5f,//細線部分の太さ
            Color.rgb(255, 100, 50),//Open>Close時の太線部分の色
            Paint.Style.FILL,//Open>Close時の太線部分の塗りつぶし形式
            null,//Open<Close時の太線部分の色
            null//Open<Close時の太線部分の塗りつぶし形式
        )

        //①必要データをEntryのリストに入力
        val x = tempSeriesData["max"]?.map{it.first}!!
        val yHigh = tempSeriesData["max"]?.map{it.second.toFloat()}!!
        val yLow = tempSeriesData["min"]?.map{it.second.toFloat()}!!
        val yMidHigh = tempSeriesData["avg"]?.map{it.second.toFloat()+0.2f}!!
        val yMidLow = tempSeriesData["avg"]?.map{it.second.toFloat()-0.2f}!!
        val candleEntries = makeDateCandleChartData(x, yHigh, yLow, yMidHigh, yMidLow)

        //②~⑦グラフの作成
        setupCandleStickChart(
            candleEntries,
            view.candleStickChartTempStats,
            candleChartFormat,
            candleDSFormat,
            context
        )
    }
}

各メソッドの内容は下記となります。
・refreshCurrentTempData():現在の場所ごと気温円グラフを作成(円グラフ作成法はこちらも参考)
・refreshTempTimeSeriesData():場所ごと気温の推移折れ線グラフを作成(折れ線グラフ作成法はこちらも参考)
・refreshTempStatsData():屋外日陰の最高最低気温推移ローソク足グラフの描画(ローソク足グラフ作成法はこちらも参考)

9-3. TabPagerAdapter.ktに引数追加

7-3で作成したTabPagerAdapter.ktを下記のように書き換え、TempFragmentの変更に合わせた引数を追加します

TabPagerAdapter.kt
   
override fun getItem(position: Int): Fragment {
        when(position){
            0 -> { return SummaryFragment(newestData, airconData, watt) }
            1 -> { return TempFragment(newestData, placeTempData, tempStatsData) }//ここに引数を追加
            else ->  { return HumidFragment() }
   

10.「湿度」タブのUI実装

下図のような「湿度」タブの中身を作成していきます。
Humidity.png

10-1. レイアウト

7-4で作成したfragment_tab_humid.xmlを下記のように書き換え、「湿度」タブのレイアウトを完成させます

fragment_tab_humid.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#333333"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/textViewHumidPieLabel"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="現在の場所ごと湿度"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/piechartHumidIndoorHumid"
        android:layout_marginTop="@dimen/tabTopMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartHumidIndoorHumid"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/piechartHumidKitchen"
        app:layout_constraintTop_toBottomOf="@+id/textViewHumidPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieHumidSeriesMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartHumidKitchen"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartHumidIndoorHumid"
        app:layout_constraintEnd_toStartOf="@+id/piechartHumidOutdoorShade"
        app:layout_constraintTop_toBottomOf="@+id/textViewHumidPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieHumidSeriesMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartHumidOutdoorShade"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartHumidKitchen"
        app:layout_constraintEnd_toStartOf="@+id/piechartHumidOutdoorSunny"
        app:layout_constraintTop_toBottomOf="@+id/textViewHumidPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieHumidSeriesMargin"/>
    <com.github.mikephil.charting.charts.PieChart
        android:id="@+id/piechartHumidOutdoorSunny"
        android:layout_width="0dp"
        android:layout_height="@dimen/pieChartSize"
        app:layout_constraintStart_toEndOf="@+id/piechartHumidOutdoorShade"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textViewHumidPieLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewSeriesLineChartLabel"
        android:layout_marginBottom="@dimen/pieHumidSeriesMargin" />

    <TextView
        android:id="@+id/textViewSeriesLineChartLabel"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="場所ごと湿度推移"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/piechartHumidIndoorHumid"
        app:layout_constraintBottom_toTopOf="@+id/lineChartHumidTimeSeries"/>
    <TextView
        android:id="@+id/textViewHumidTimeZone"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="end|bottom"
        android:text=""
        android:textColor="@android:color/white"
        android:textSize="@dimen/timeZoneTextSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/piechartHumidIndoorHumid"
        app:layout_constraintBottom_toTopOf="@+id/lineChartHumidTimeSeries"/>
    <com.github.mikephil.charting.charts.LineChart
        android:id="@+id/lineChartHumidTimeSeries"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textViewSeriesLineChartLabel"
        app:layout_constraintBottom_toTopOf="@+id/textViewHumidStatsLabel"
        android:layout_marginBottom="@dimen/humidSeriesStatsMargin"/>
    <TextView
        android:id="@+id/textViewHumidStatsLabel"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:gravity="center_horizontal|center_vertical"
        android:text="屋外(日陰)の最高最低湿度"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/lineChartHumidTimeSeries"
        app:layout_constraintBottom_toTopOf="@+id/candleStickChartHumidStats"/>
    <com.github.mikephil.charting.charts.CandleStickChart
        android:id="@+id/candleStickChartHumidStats"
        android:layout_width="0dp"
        android:layout_height="@dimen/statsCandleStickChartSize"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textViewHumidStatsLabel"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

LineChartは折れ線グラフ、CandleStickChartはローソク足グラフ(最高最低気温表示)のウィジェットとなります

10-2. クラスファイル

7-5で作成したHumidFragment.ktを下記のように書き換え、"気温"タブの処理を完成させます

HumidFragment.kt
package com.mongodb.homeiotviewer.tab

import android.content.Context
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.data.Entry
import com.mongodb.homeiotviewer.R
import com.mongodb.homeiotviewer.TempHumidData
import com.mongodb.homeiotviewer.chart.*
import kotlinx.android.synthetic.main.fragment_tab_humid.view.*
import java.text.SimpleDateFormat
import java.util.*

class HumidFragment(
    val newestSensorData: MutableList<TempHumidData>,
    val placeHumidData: Map<String, MutableList<Pair<Date, Double>>>,
    val humidStatsData: Map<String, List<Pair<Date, Double>>>)
    : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        //レイアウトからViewを生成
        val humidView: View = inflater.inflate(R.layout.fragment_tab_humid, container, false)
        //現在の温湿度データの描画
        refreshCurrentHumidData(newestSensorData, humidView)

        //タイムゾーンを表示
        humidView.textViewHumidTimeZone.text = "[TimeZone=${TimeZone.getDefault().id}]  "

        //温度推移グラフの描画
        //FragmentのContextはrequireContextを使用(https://developer.android.com/kotlin/common-patterns?hl=ja)
        refreshHumidTimeSeriesData(this.requireContext(), placeHumidData, humidView)

        //日ごとの最高最低平均気温グラフ描画
        refreshHumidStatsData(this.requireContext(), humidStatsData, humidView)

        return humidView
    }
    //現在の湿度データの描画
    private fun refreshCurrentHumidData(sensorData: MutableList<TempHumidData>, view: View) {
        //屋内、キッチン、日陰、日なたにデータを分ける
        val indoorData = sensorData.filter { it.place == "indoor" }
        val kitchenData = sensorData.filter { it.place == "kitchen" }
        val shadeData = sensorData.filter { it.place == "outdoor_shade" }
        val sunnyData = sensorData.filter { it.place == "outdoor_sunny" }
        //屋内外の平均温湿度を計算
        val indoorHumid = indoorData.mapNotNull { it.humidity }.average()
        val kitchenHumid = kitchenData.mapNotNull { it.humidity }.average()
        val shadeHumid = shadeData.mapNotNull { it.humidity }.average()
        val sunnyHumid = sunnyData.mapNotNull { it.humidity }.average()

        //Chartフォーマット
        val indoorChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "屋内\n${"%.1f".format(indoorHumid)}%",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val kitchenChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "キッチン\n${"%.1f".format(kitchenHumid)}%",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val shadeChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "日陰\n${"%.1f".format(shadeHumid)}%",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        val sunnyChartFormat = PieChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor =  Color.WHITE,//凡例文字色
            description =  null,//グラフ説明
            bgColor = null,//背景色
            touch = false,//タッチ操作の有効
            centerText = "日なた\n${"%.1f".format(sunnyHumid)}%",//中央に表示するテキスト
            centerTextSize = 16f,//中央に表示するテキストサイズ
            centerTextColor = Color.WHITE,//中央に表示するテキストカラー
            holeRadius = 75f,//中央の穴の半径
            holeColor = Color.TRANSPARENT//中央の塗りつぶし色
        )
        //DataSetフォーマット
        val indoorDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.rgb(243, 201, 14), Color.GRAY)//円の色
        )
        val kitchenDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.rgb(128, 255, 0), Color.GRAY)//円の色
        )
        val shadeDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.rgb(185, 122, 87), Color.GRAY)//円の色
        )
        val sunnyDSFormat = PieDataSetFormat(
            drawValue = false,
            valueTextColor = null,
            valueTextSize = null,
            valueTextFormatter = null,
            axisDependency = null,
            colors = listOf(Color.RED, Color.GRAY)//円の色
        )

        //屋内湿度を円グラフ用データに整形
        val (dimensionsIndoorHumid, valuesIndoorHumid) = makePieDashboardData(
            indoorHumid.toFloat(),
            0f,
            100f
        )
        //キッチン湿度を円グラフ用に整形
        val (dimensionsKitchenHumid, valuesKitchenHumid) = makePieDashboardData(
            kitchenHumid.toFloat(),
            0f,
            100f
        )
        //日陰湿度を円グラフ用に整形
        val (dimensionsShadeHumid, valuesShadeHumid) = makePieDashboardData(
            shadeHumid.toFloat(),
            0f,
            100f
        )
        //日なた湿度を円グラフ用に整形
        val (dimensionsSunnyHumid, valuesSunnyHumid) = makePieDashboardData(
            sunnyHumid.toFloat(),
            0f,
            100f
        )

        //①場所ごとにEntryのリストを作成
        val indoorEntries = makePieChartEntries(dimensionsIndoorHumid, valuesIndoorHumid)
        val kitchenEntries = makePieChartEntries(dimensionsKitchenHumid, valuesKitchenHumid)
        val shadeEntries = makePieChartEntries(dimensionsShadeHumid, valuesShadeHumid)
        val sunnyEntries = makePieChartEntries(dimensionsSunnyHumid, valuesSunnyHumid)
        //②~⑦円グラフ描画
        setupPieChart(
            indoorEntries, view.piechartHumidIndoorHumid, "室内温度",
            indoorChartFormat, indoorDSFormat
        )
        setupPieChart(
            kitchenEntries, view.piechartHumidKitchen, "室外温度",
            kitchenChartFormat, kitchenDSFormat
        )
        setupPieChart(
            shadeEntries, view.piechartHumidOutdoorShade, "室内湿度",
            shadeChartFormat, shadeDSFormat
        )
        setupPieChart(
            sunnyEntries, view.piechartHumidOutdoorSunny, "室外湿度",
            sunnyChartFormat, sunnyDSFormat
        )
    }

    //湿度推移折れ線グラフの描画
    private fun refreshHumidTimeSeriesData(
        context: Context,
        humidSeriesData: Map<String, MutableList<Pair<Date, Double>>>,
        view: View
    ) {
        //Chartフォーマット
        val humidLineChartFormat = LineChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor = null,//凡例文字色
            description = null,//グラフ説明
            bgColor = Color.BLACK,//背景塗りつぶし色
            touch = true,//タッチ有効
            xAxisEnabled = true,//X軸有効
            xAxisTextColor = Color.WHITE,//X軸文字色
            xAxisDateFormat = SimpleDateFormat("M/d H:mm"),//X軸の日付軸フォーマット(日付軸でないときnull指定)
            yAxisLeftEnabled = true,//左Y軸有効
            yAxisLeftTextColor = Color.WHITE,//左Y軸文字色
            yAxisRightEnabled = false,//右Y軸有効
            zoomDirection = "xy",//ズームの方向(x, y, xy, null=ズーム無効)
            zoomPinch = false,//ズームのピンチ動作をXY同時にするか(trueなら同時、falseなら1軸に限定)
            toolTipDirection = "xy",//ツールチップに表示する軸内容(x, y, xy, null=表示なし)
            toolTipDateFormat = SimpleDateFormat("M/d H:mm"),//ツールチップX軸表示の日付フォーマット(日付軸以外ならnull)
            toolTipUnitX = "",//ツールチップのX軸内容表示に付加する単位
            toolTipUnitY = "%",//ツールチップのY軸内容表示に付加する単位
            timeAccuracy = false//時系列グラフのX軸を正確にプロットするか
        )
        //DataSetフォーマット(カテゴリ名のMapで作成)
        val humidLineDataSetFormat: Map<String, LineDataSetFormat> = mapOf(
            "indoor" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.rgb(243, 201, 14),
                2f,
                null,
                false,
                null,
                null
            ),
            "kitchen" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.rgb(128, 255, 0),
                2f,
                null,
                false,
                null,
                null
            ),
            "outdoor_shade" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.rgb(185, 122, 87),
                2f,
                null,
                false,
                null,
                null
            ),
            "outdoor_sunny" to LineDataSetFormat(
                false,
                null,
                null,
                null,
                null,
                Color.RED,
                2f,
                null,
                false,
                null,
                null
            )
        )
        //①場所ごとに必要期間のデータ抜き出してEntryのリストに入力
        val places = humidSeriesData.keys//場所のリスト
        val allLinesEntries: MutableMap<String, MutableList<Entry>> = mutableMapOf()
        for(pl in places){
            //要素数が0なら処理を終了
            if(humidSeriesData[pl]?.size == 0) return
            //Entryにデータ入力
            val x = humidSeriesData[pl]?.map { it.first }!!//X軸(日時データ)
            val y = humidSeriesData[pl]?.map { it.second.toFloat() }!!//Y軸(温度データ)
            allLinesEntries[pl] = makeDateLineChartData(x, y, humidLineChartFormat.timeAccuracy)//日時と温度をEntryのリストに変換
        }

        //②~⑦グラフの作成
        setupLineChart(allLinesEntries, view.lineChartHumidTimeSeries, humidLineChartFormat, humidLineDataSetFormat, context)
    }

    //屋外日陰の最高最低湿度推移ローソク足グラフの描画
    private fun refreshHumidStatsData(
        context: Context,
        humidSeriesData: Map<String, List<Pair<Date, Double>>>,
        view: View
    ){
        //要素数が0なら終了
        if(humidSeriesData["max"]?.size == 0) return

        //Chartフォーマット
        val candleChartFormat = CandleChartFormat(
            legendFormat = null,//凡例形状
            legentTextColor = null,//凡例文字色
            description = null,//グラフ説明
            bgColor = Color.BLACK,//背景塗りつぶし色
            touch = true,//タッチ有効
            xAxisEnabled = true,//X軸有効
            xAxisTextColor = Color.WHITE,//X軸文字色
            xAxisDateFormat = SimpleDateFormat("M/d"),//X軸の日付軸フォーマット(日付軸でないときnull指定)
            yAxisLeftEnabled = true,//左Y軸有効
            yAxisLeftTextColor = Color.WHITE,//左Y軸文字色
            yAxisRightEnabled = false,//右Y軸有効
            zoomDirection = "xy",//ズームの方向(x, y, xy, null=ズーム無効)
            zoomPinch = false,//ズームのピンチ動作をXY同時にするか(trueなら同時、falseなら1軸に限定)
            toolTipDirection = null,//ツールチップに表示する軸内容(x, y, xy, null=表示なし)
            toolTipDateFormat = SimpleDateFormat("M/d"),//ツールチップX軸表示の日付フォーマット(日付軸以外ならnull)
            toolTipUnitX = "",//ツールチップのX軸内容表示に付加する単位
            toolTipUnitY = ""//ツールチップのY軸内容表示に付加する単位
        )
        //DataSetフォーマット
        val candleDSFormat = CandleDataSetFormat(
            true,//値の表示有無
            Color.rgb(220, 120, 80),//値表示の文字色
            7f,//値表示の文字サイズ
            "%.0f",//値表示の文字書式
            YAxis.AxisDependency.LEFT,//使用する軸
            Color.rgb(220, 120, 80),//細線部分の色
            2.5f,//細線部分の太さ
            Color.rgb(255, 100, 50),//Open>Close時の太線部分の色
            Paint.Style.FILL,//Open>Close時の太線部分の塗りつぶし形式
            null,//Open<Close時の太線部分の色
            null//Open<Close時の太線部分の塗りつぶし形式
        )

        //①必要データをEntryのリストに入力
        val x = humidSeriesData["max"]?.map{it.first}!!
        val yHigh = humidSeriesData["max"]?.map{it.second.toFloat()}!!
        val yLow = humidSeriesData["min"]?.map{it.second.toFloat()}!!
        val yMidHigh = humidSeriesData["avg"]?.map{it.second.toFloat()+0.2f}!!
        val yMidLow = humidSeriesData["avg"]?.map{it.second.toFloat()-0.2f}!!
        val candleEntries = makeDateCandleChartData(x, yHigh, yLow, yMidHigh, yMidLow)

        //②~⑦グラフの作成
        setupCandleStickChart(
            candleEntries,
            view.candleStickChartHumidStats,
            candleChartFormat,
            candleDSFormat,
            context
        )
    }
}

各メソッドの内容は下記となります。
・refreshCurrentHumidData():現在の場所ごと湿度円グラフを作成(円グラフ作成法はこちらも参考)
・refreshHumidTimeSeriesData():場所ごと湿度の推移折れ線グラフを作成(折れ線グラフ作成法はこちらも参考)
・refreshHumidStatsData():屋外日陰の最高最低湿度推移ローソク足グラフの描画(ローソク足グラフ作成法はこちらも参考)

10-3. TabPagerAdapter.ktに引数追加

7-3で作成したTabPagerAdapter.ktを下記のように書き換え、HumidFragmentの変更に合わせた引数を追加します

TabPagerAdapter.kt
   
override fun getItem(position: Int): Fragment {
        when(position){
            0 -> { return SummaryFragment(newestData, airconData, watt) }
            1 -> { return TempFragment(newestData, placeTempData, tempStatsData) }
            else ->  { return HumidFragment(newestData, placeHumidData, humidStatsData) }//ここに引数を追加
   

以上で完成です!!
エミュレータを実行すると、下のような画面が表示されるはずです。
all.png
お疲れさまでした!

88
98
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
88
98

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?