仮想ビーコンに紐づく掲示板アプリを作った(Mist、Android、kintone、GAS)

この記事は [ProtoPedia Advent Calendar 2024](https://qiita.com/advent-calendar/2024/protopedia) の 16日目の記事です。

このプロダクトは [Virtual Beacon Hack（バーチャル・ビーコン・ハック）](https://mist-hack.connpass.com) で作成されました。

主催のジュニパーネットワークス社の開催レポートが公開されております。
イベントの詳細や様子などはこちらをご参照ください。

https://blogs.juniper.net/ja-jp/japanese-blog/juniper-mist-quintbridge-hackathon-jp

# 作ったモノ
「ビーコンBBS」
宝探し感覚で繋がるアプリです。イベント会場やコミュニケーションスペースに、掲示板として仮想ビーコンを設置し、利用者の交流を促進します。

https://protopedia.net/prototype/6350

「屋内版セカイカメラだね」というコメントが分かりやすかったです。

## 仮想ビーコン
ざっくり自分の理解で言うと
- 複数箇所に設置された各無線APがそれぞれ多数のビームを放出する
- ビームを受け取った端末は、無線APの場所やビーム強度から自己位置推定する
- 端末の位置と、定義したビーコンの位置・電波強度によってビーコン受信判定

情報感度の高いメイカーの方達でも、あまり馴染みのない概念かと思います。
自己位置推定する側はMistSDKとBluetoothを使う必要があります。

詳細は以下の記事を参照してください。
https://www.juniper.net/assets/jp/jp/local/pdf/additional-resources/mist-location-interop2020.pdf

# 概要
Androidのネイティブアプリとして作成しました
掲示板情報のDBにkintone、連携用APIはGASで作りました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535537/46ceef75-9fe7-acde-977a-9ef54b530778.png)

# 実装詳細
Androidアプリを作るのは初めてだったので、ChatGPT4oに大部分を手伝ってもらい、どうしても動かない部分や明らかにエラーだなとわかる部分を、AndroidStduioや書籍の指示に従って修正しました。

なので、コードの出来はとてもひどいと思います。
ただ、一応動くアプリなので、誰かの踏み台になれば幸いです。

開発は手持ちの古いAndroidで実施しました。

```kotlin:build.gradle.kts
plugins {
    alias(libs.plugins.androidApplication)
    alias(libs.plugins.jetbrainsKotlinAndroid)
    kotlin("plugin.serialization")
}

android {
    namespace = "your namespace"
    compileSdk = 34

    defaultConfig {
        applicationId = "your application id"
        minSdk = 30
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.1"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)
    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)
    implementation(libs.androidx.compose.material)
    implementation(libs.okhttp)
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0")
    implementation("com.mist:core-sdk:4.0.4")
}
```

処理の大部分は1ファイルに書いてます。
その方がChatGPTに投げるのが楽だったので...。
ライブラリとかデータ構造とか
```kotlin:MainActivity.kt
package com.example.beaconbbs2

import com.mist.android.*
import com.mist.android.IndoorLocationManager
import com.mist.android.IndoorLocationCallback
import com.mist.android.external.config.MistCallbacks
import com.mist.android.external.config.MistConfiguration
import com.mist.android.MistVirtualBeacon
import com.mist.android.VirtualBeaconCallback
import android.os.Bundle
import android.util.Log
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Build

import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.Text

import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.RequiresApi
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

import your Theme
import com.mist.android.external.config.LogLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import androidx.core.content.ContextCompat.startActivity
import java.io.File

data class UserLocation(var x: Double?, var y: Double?)

data class BeaconData(
    val uuid: String?,
    val xCoordinate: Double?,
    val yCoordinate: Double?,
    val distance: String?
)

@Serializable
data class JsonField<T>(
    val type: String,
    val value: T
)

@Serializable
data class BoardDataJson(
    val beacon_id: JsonField<String>,
    val title: JsonField<String>,
    val good: JsonField<Int>,
    val 作成日時: JsonField<String>,
    val content: JsonField<String>
)

@Serializable
data class BoardReplyDataJson(
    val beacon_id: JsonField<String>,
    val good: JsonField<Int>,
    val 作成日時: JsonField<String>,
    val content: JsonField<String>
)

@Serializable
data class BoardPost(
    val beacon_id: String,
    val title: String,
    val content: String,
    val x: Double,
    val y: Double
)

@Serializable
data class ReplyPost(
    val beacon_id: String,
    val content: String
)

@Serializable
data class BoardGoodPost(
    val beacon_id: String,
    val good: Int
)

data class BoardData(
    val title: String,
    val content: String,
    val postDate: Date,
    val beaconId: String,
    var goodCount: Int = 0
)

data class ReplyData(
    val content: String,
    val postDate: Date
)

enum class Screen {
    Dashboard,
    Create,
    Favorites,
    Discovered,
    BoardDetail,
}
```
MistSDKとか各画面とか。
本来だとMistのマップを取得してやるんですが、今回は画像データと人力スケールでやってます。時間がなかったので
```kotlin:MainActivity.kt
class MainActivity : ComponentActivity(), IndoorLocationCallback, VirtualBeaconCallback {
    private lateinit var indoorLocationManager: IndoorLocationManager
    private val beacons = mutableStateOf<List<BeaconData>>(listOf())
    private val knownBeaconIds = mutableStateOf<Set<String?>>(setOf())
    private val allBeaconIds = mutableStateOf<Set<String?>>(setOf())
    private val allBeacons = mutableStateOf<List<BeaconData>>(listOf())
    private val userLocationState = mutableStateOf(UserLocation(x = 0.0, y = 0.0))

    @RequiresApi(Build.VERSION_CODES.S)
    private val permissions = arrayOf(
        Manifest.permission.INTERNET,
        Manifest.permission.RECEIVE_BOOT_COMPLETED,
        Manifest.permission.ACCESS_BACKGROUND_LOCATION,
        Manifest.permission.FOREGROUND_SERVICE,
        Manifest.permission.ACCESS_FINE_LOCATION,
        Manifest.permission.ACCESS_COARSE_LOCATION,
        Manifest.permission.BLUETOOTH,
        Manifest.permission.BLUETOOTH_ADMIN,
        Manifest.permission.BLUETOOTH_SCAN,
        Manifest.permission.BLUETOOTH_CONNECT
    )

    private val requestCodePermissions = 1

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        checkAndRequestPermissions()
        initializeMistSDK()
        setContent {
            BeaconBBS2Theme {
                AppContent(beacons.value, userLocationState, allBeacons.value)
            }
        }
    }

    private fun checkAndRequestPermissions() {
        val permissionsNeeded = permissions.filter { permission ->
            ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED
        }

        if (permissionsNeeded.isNotEmpty()) {
            ActivityCompat.requestPermissions(
                this,
                permissionsNeeded.toTypedArray(),
                requestCodePermissions
            )
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == requestCodePermissions) {
            val deniedPermissions = permissions.zip(grantResults.toTypedArray())
                .filter { it.second != PackageManager.PERMISSION_GRANTED }
                .map { it.first }

            if (deniedPermissions.isNotEmpty()) {
                // 必要な権限が拒否された場合の処理
                // ユーザに再度権限を求めるか、アプリの動作を制限する処理を追加してください
            }
        }
    }

    private fun initializeMistSDK() {
        val mistCallbacks = MistCallbacks(
            indoorLocationCallback = this,
            virtualBeaconCallback = this
        )
        val mistConfiguration = MistConfiguration(
            context = this,
            token = "your mist token",
            enableLog = true,
            logLevel = LogLevel.VERBOSE
        )
        indoorLocationManager = IndoorLocationManager
        indoorLocationManager.mistCallbacks = mistCallbacks
        indoorLocationManager.start(mistConfiguration)
    }

    override fun onRelativeLocationUpdated(relativeLocation: MistPoint?) {
        Log.d("MistSDK", "relative location: $relativeLocation")
        userLocationState.value = UserLocation(
            x = relativeLocation?.x?.times(13.0),
            y = relativeLocation?.y?.times(13.0)
        )
    }

    override fun onMapUpdated(map: MistMap?) {
        Log.d("MistSDK", "map updated: $map")
    }

    override fun onVirtualBeaconListUpdated(virtualBeacons: Array<MistVirtualBeacon?>?) {
        Log.d("MistSDK", "Beacon sightings: $virtualBeacons")
    }

    override fun didRangeVirtualBeacon(mistVirtualBeacon: MistVirtualBeacon?) {
        Log.d("MistSDK", "Ranging beacon: ${mistVirtualBeacon.toString()}")
        Log.d(
            "MistSDK",
            "Position: ${mistVirtualBeacon?.position?.x} ${mistVirtualBeacon?.position?.y}"
        )
        mistVirtualBeacon?.let { beacon ->
            if (beacon.vbUUID != null && beacon.vbUUID !in allBeaconIds.value) {
                allBeaconIds.value += beacon.vbUUID
                allBeacons.value += BeaconData(
                    uuid = beacon.vbUUID,
                    xCoordinate = beacon.position?.x,
                    yCoordinate = beacon.position?.y,
                    distance = beacon.additionalInfo?.distance.toString()
                )
            }
            if (beacon.additionalInfo?.proximity in listOf(
                    "immediate",
                    "near",
                    //"far"
                ) && beacon.vbUUID != null
            ) {
                if (beacon.vbUUID !in knownBeaconIds.value) {
                    knownBeaconIds.value += beacon.vbUUID
                    Log.d("MistSDK", "knownBeaconIds.value: $knownBeaconIds.value ")
                    beacons.value += BeaconData(
                        uuid = beacon.vbUUID,
                        xCoordinate = beacon.position?.x,
                        yCoordinate = beacon.position?.y,
                        distance = beacon.additionalInfo?.distance.toString()
                    )
                }
            }
        }
    }
}

@Composable
fun AppContent(
    beacons: List<BeaconData>,
    userLocationState: MutableState<UserLocation>,
    allBeacons: List<BeaconData>
) {
    var selectedBoard by remember { mutableStateOf<BoardData?>(null) }
    val currentScreen = remember { mutableStateOf(Screen.Dashboard) }

    val context = LocalContext.current

    Scaffold(
        bottomBar = {
            BottomNavigationBar(
                currentScreen.value,
                onScreenSelected = { currentScreen.value = it })
        }
    ) { innerPadding ->
        Box(
            modifier = Modifier
                .padding(innerPadding)
                .fillMaxSize()
        ) {
            when (currentScreen.value) {
                Screen.Dashboard -> Column {
                    Dashboard(
                        onBoardSelected = { board ->
                            selectedBoard = board
                            currentScreen.value = Screen.BoardDetail
                        },
                        beacons = beacons,
                        userLocation = userLocationState.value
                    )
                }

                Screen.Create -> CreateBoardScreen(
                    onBoardCreated = { /* ボード作成後の処理 */ },
                    onBack = { currentScreen.value = Screen.Dashboard },
                    currentScreen = currentScreen,
                    userLocation = userLocationState.value
                )
                Screen.Favorites -> Column(
                    modifier = Modifier
                        .padding(16.dp)
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        text = "このアプリについて",
                        style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = """
                            このアプリは2024/2 - 2024/3 にかけてQUINTBRIDGEで開催された、Virtual Beacon HackのPoCアプリです。
                            """.trimIndent(),
                        style = MaterialTheme.typography.bodyLarge
                    )
                    Spacer(modifier = Modifier.height(16.dp))
                    Text(
                        text = "Virtual Beacon Hackとは？",
                        style = MaterialTheme.typography.headlineSmall
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = """        
                            仮想ビーコンであるMistBLEを活用し、QUINTBRIDGE利用者をはじめとした「共創」に関心のある学生や関西のスタートアップ・企業等からの参加者とともに、QUINTBRIDGEのコンセプトを体現しながら、QUINTBRIDGEをまるごとハックし、より盛り上げていくためのアイデア・プロトタイプを作り上げる長期型アイデアソン・ハッカソンイベントです。
                            """.trimIndent(),
                        style = MaterialTheme.typography.bodyLarge
                    )
                    Spacer(modifier = Modifier.height(16.dp))
                    Text(
                        text = "このアプリは？",
                        style = MaterialTheme.typography.headlineSmall
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = """ 
                            仮想ビーコンを掲示板に見立て、宝探し感覚で繋がるアプリです。
                            - 足跡として掲示板を設置する
                            - 近場の掲示板を見つけて返信する
                        """.trimIndent(),
                        style = MaterialTheme.typography.bodyLarge
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                }

                Screen.Discovered -> Column {
                    Text("全ての掲示板を表示します")
                    Dashboard(
                        onBoardSelected = { board ->
                            selectedBoard = board
                            currentScreen.value = Screen.BoardDetail
                        },
                        beacons = allBeacons,
                        userLocation = userLocationState.value
                    )
                }

                Screen.BoardDetail -> selectedBoard?.let { board ->
                    BoardDetailScreen(boardData = board, beacons = beacons)
                }
            }
        }
    }
}

@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewComponent(url: String) {
    val context = LocalContext.current
    AndroidView(factory = {
        WebView(context).apply {
            settings.javaScriptEnabled = true // JavaScriptを有効にする
            settings.domStorageEnabled = true // DOMストレージを有効にする
            settings.loadWithOverviewMode = true
            settings.useWideViewPort = true

            settings.cacheMode = WebSettings.LOAD_DEFAULT // キャッシュモードの設定
            settings.allowFileAccess = true // ファイルアクセスを有効にする
            settings.allowContentAccess = true // コンテンツアクセスを有効にする

            // WebViewのキャッシュディレクトリを内部ストレージに設定
            val cacheDir = File(context.cacheDir, "WebViewCache")
            if (!cacheDir.exists()) {
                cacheDir.mkdirs()
            }

            webChromeClient = WebChromeClient() // WebChromeClientを設定
            webViewClient = object : WebViewClient() {
                override fun shouldOverrideUrlLoading(
                    view: WebView?,
                    request: WebResourceRequest?
                ): Boolean {
                    return false // すべてのリンクをWebView内で開く
                }

            }
            loadUrl(url)
        }
    }, update = {
        it.loadUrl(url)
    })
}

@Composable
fun BottomNavigationBar(currentScreen: Screen, onScreenSelected: (Screen) -> Unit) {
    BottomNavigation {
        BottomNavigationItem(
            icon = { Icon(Icons.Filled.Home, contentDescription = "Home") },
            label = { Text("ホーム") },
            selected = currentScreen == Screen.Dashboard,
            onClick = { onScreenSelected(Screen.Dashboard) }
        )
        BottomNavigationItem(
            icon = { Icon(Icons.Filled.Add, contentDescription = "Create") },
            label = { Text("作成") },
            selected = currentScreen == Screen.Create,
            onClick = { onScreenSelected(Screen.Create) }
        )
        BottomNavigationItem(
            icon = { Icon(Icons.Filled.Info, contentDescription = "Info") },
            label = { Text("このアプリについて") },
            selected = currentScreen == Screen.Favorites,
            onClick = { onScreenSelected(Screen.Favorites) }
        )
        BottomNavigationItem(
            icon = { Icon(Icons.Filled.Search, contentDescription = "Discovered") },
            label = { Text("リスト") },
            selected = currentScreen == Screen.Discovered,
            onClick = { onScreenSelected(Screen.Discovered) }
        )
    }
}

@Composable
fun MapView(userLocation: UserLocation? = null, beacons: List<BeaconData>? = listOf()) {
    val mapWidth = 666f  // マップの幅
    val mapHeight = 493f // マップの高さ

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(200.dp)
    ) {
        Image(
            painter = painterResource(id = R.drawable.map),
            contentDescription = "Map",
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
        )

        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
        ) {
            val canvasWidth = size.width
            val canvasHeight = size.height

            if (userLocation?.x != 0.0 && userLocation?.y != 0.0) {
                userLocation?.let {
                    val x = (it.x?.div(mapWidth) ?: 0.0) * canvasWidth
                    val y = (it.y?.div(mapWidth) ?: 0.0) * canvasHeight
                    drawCircle(
                        color = Color.Red,
                        center = Offset(x.toFloat(), y.toFloat()),
                        radius = 20f
                    )
                }
            }

            if (beacons != null) {
                beacons.forEach { beacon ->
                    if (beacon.xCoordinate != 0.0 && beacon.yCoordinate != 0.0) {
                        val x = (beacon.xCoordinate?.div(mapWidth) ?: 0.0) * canvasWidth
                        val y = (beacon.yCoordinate?.div(mapHeight) ?: 0.0) * canvasHeight
                        drawCircle(
                            color = Color.Blue,
                            center = Offset(x.toFloat(), y.toFloat()),
                            radius = 10f
                        )
                    }
                }
            }
        }
    }
}
```
GASで作ったAPIとかを叩く部分
```kotlin:MainActivity.kt
suspend fun fetchBoardData(beaconId: String): List<BoardData> {
    Log.d("MistSDK", "Beacon fetch board data: $beaconId")
    val client = OkHttpClient()
    val request = Request.Builder()
           .url("your api?beacon_id=$beaconId")
        .build()

    return withContext(Dispatchers.IO) {
        try {
            val response = client.newCall(request).execute()
            if (response.isSuccessful) {
                val responseData = response.body?.string() ?: ""
                parseBoardData(responseData)
            } else {
                throw Exception("Server responded with error: ${response.code}")
            }
        } catch (e: Exception) {
            Log.e("FetchBoardData", "Failed to fetch data: ${e.message}")
            emptyList()
        }
    }
}

fun parseBoardData(responseData: String): List<BoardData> {
    val json = Json { ignoreUnknownKeys = true }
    val boardDataListJson = json.decodeFromString<List<BoardDataJson>>(responseData)

    return boardDataListJson.map { jsonBoard ->
        BoardData(
            title = jsonBoard.title.value,
            content = jsonBoard.content.value,
            postDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()).parse(
                jsonBoard.作成日時.value
            ) ?: Date(),
            beaconId = jsonBoard.beacon_id.value,
            goodCount = jsonBoard.good.value,
        )
    }
}

suspend fun postBoardData(
    beaconId: String,
    title: String,
    content: String,
    x: Double,
    y: Double
): Boolean {
    val client = OkHttpClient()
    val jsonMediaType = "application/json; charset=utf-8".toMediaType()

    val boardPost = BoardPost(beaconId, title, content, x, y)
    val jsonContent = Json.encodeToString(BoardPost.serializer(), boardPost)
    val body: RequestBody = jsonContent.toRequestBody(jsonMediaType)

    val request = Request.Builder()
     .url("your api") 
        .post(body)
        .build()

    return withContext(Dispatchers.IO) {
        try {
            val response = client.newCall(request).execute()
            Log.e("PostBoardData", "Response: ${response}")
            response.isSuccessful
        } catch (e: Exception) {
            Log.e("PostBoardData", "Failed to post board data: ${e.message}")
            false
        }
    }
}

@Composable
fun LoadingIndicator(isLoading: Boolean) {
    if (isLoading) {
        Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
            CircularProgressIndicator()
        }
    }
}

@Composable
fun GoodIconWithCount(goodCount: Int, onGoodClicked: () -> Unit) {

    Row(verticalAlignment = Alignment.CenterVertically) {
        IconButton(onClick = onGoodClicked) {
            Icon(Icons.Filled.ThumbUp, contentDescription = "Good")
        }
        Text(text = goodCount.toString(), style = MaterialTheme.typography.bodyMedium)
    }
}

suspend fun incrementGoodCount(beaconId: String, goodCount: Int): Boolean {
    val client = OkHttpClient()
    val jsonMediaType = "application/json; charset=utf-8".toMediaType()

    val data = BoardGoodPost(beaconId, goodCount)
    val jsonContent = Json.encodeToString(BoardGoodPost.serializer(), data)
    val body: RequestBody = jsonContent.toRequestBody(jsonMediaType)

    val request = Request.Builder()
.url("your api") 
        .post(body)
        .build()

    Log.d("PutBoardGoodData", "Request: $body")
    return withContext(Dispatchers.IO) {
        try {
            val response = client.newCall(request).execute()
            Log.d("PutBoardGoodData", "Response: $response")
            response.isSuccessful
        } catch (e: Exception) {
            Log.e("PutBoardGoodData", "Failed to post board data: ${e.message}")
            false
        }
    }
}

@Composable
fun Dashboard(
    onBoardSelected: (BoardData) -> Unit,
    beacons: List<BeaconData>,
    userLocation: UserLocation?
) {
    var boards by remember { mutableStateOf(listOf<BoardData>()) }
    val isLoading = remember { mutableStateOf(false) }
    val loadedBeacons = remember { mutableStateOf(0) }
    val scrollState = rememberScrollState()


    LaunchedEffect(key1 = beacons) {
        isLoading.value = true
        boards = listOf()
        beacons.forEach { beacon ->
            launch {
                beacon.uuid?.let {
                    fetchBoardData(it).forEach { boardData ->
                        boards = boards + boardData
                    }
                }
                loadedBeacons.value += 1
                if (loadedBeacons.value == beacons.size) {
                    isLoading.value = false
                }
            }
        }
    }

    Column(modifier = Modifier
        .padding(8.dp)
        .verticalScroll(scrollState)) {
        MapView(beacons = beacons, userLocation = userLocation)
        if (isLoading.value && boards.isEmpty()) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
        } else if (boards.isEmpty()) {
            Text("掲示板が見つかりませんでした", style = MaterialTheme.typography.bodyLarge)
        } else {
            boards.forEach { board ->
                BoardCard(board, onBoardSelected)
            }
        }

    }
}

@Composable
fun BoardCard(board: BoardData, onBoardSelected: (BoardData) -> Unit) {
    val scope = rememberCoroutineScope()
    var goodCount by remember { mutableStateOf(board.goodCount) }

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
            .clickable { onBoardSelected(board) },
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(modifier = Modifier.padding(8.dp)) {
            Text(board.title, style = MaterialTheme.typography.titleLarge)
            Text(
                if (board.content.length > 100) "${
                    board.content.substring(
                        0,
                        100
                    )
                }..." else board.content,
                style = MaterialTheme.typography.bodyMedium
            )
            Text("Date: ${board.postDate}", style = MaterialTheme.typography.bodySmall)
        }
        GoodIconWithCount(goodCount = goodCount, onGoodClicked = {
            scope.launch {
                goodCount += 1
                incrementGoodCount(board.beaconId, board.goodCount + 1)
            }
        })
    }
}

@Composable
fun CreateBoardScreen(
    onBoardCreated: (BoardData) -> Unit,
    onBack: () -> Unit,
    currentScreen: MutableState<Screen>,
    userLocation: UserLocation?
) {
    var title by remember { mutableStateOf("") }
    var content by remember { mutableStateOf("") }
    val isLoading = remember { mutableStateOf(false) }
    val scope = rememberCoroutineScope()

    Column(modifier = Modifier.padding(16.dp)) {
        LoadingIndicator(isLoading = isLoading.value)
        MapView(userLocation = userLocation)

        Text("現在地にスレッドを作成", style = MaterialTheme.typography.headlineMedium)

        OutlinedTextField(
            value = title,
            onValueChange = { title = it },
            label = { Text("Title") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 16.dp)
        )

        OutlinedTextField(
            value = content,
            onValueChange = { content = it },
            label = { Text("Content") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 16.dp),
            maxLines = 5
        )

        Row(modifier = Modifier.padding(top = 16.dp)) {
            Button(
                onClick = {
                    if (title.isNotEmpty() && content.isNotEmpty()) {
                        scope.launch {
                            isLoading.value = true

                            val newBeaconId = UUID.randomUUID().toString()
                            val postSuccess = postBoardData(
                                newBeaconId,
                                title,
                                content,
                                userLocation?.x!!,
                                userLocation?.y!!
                            )
                            if (postSuccess) {
                                onBoardCreated(
                                    BoardData(
                                        title,
                                        content,
                                        Date(),
                                        newBeaconId
                                    )
                                )
                                currentScreen.value = Screen.Dashboard
                            }
                            isLoading.value = false  // ローディング終了
                        }
                    }
                },
                modifier = Modifier.weight(1f)
            ) {
                Text("作成する")
            }

            Spacer(Modifier.width(8.dp))

            Button(
                onClick = onBack,
                modifier = Modifier.weight(1f)
            ) {
                Text("戻る")
            }
        }
    }
}

suspend fun fetchBoardReplyData(boardId: String): List<ReplyData> {
    val client = OkHttpClient()
    Log.d("FetchBoardReplies", "boardId: ${boardId}")
    val request = Request.Builder()
.url("your api?beacon_id=$boardId")
        .build()

    return withContext(Dispatchers.IO) {
        try {
            val response = client.newCall(request).execute()
            val responseData = response.body?.string() ?: ""
            Log.d("FetchBoardReplies", "Response: $responseData")
            if (response.isSuccessful) {
                parseReplyData(responseData)
            } else {
                Log.e("FetchBoardReplies", "Server responded with error: ${response.code}")
                throw Exception("Server responded with error: ${response.code}")
            }
        } catch (e: Exception) {
            Log.e("FetchBoardReplies", "Failed to fetch data: ${e.message}")
            emptyList()
        }
    }
}

fun parseReplyData(responseData: String): List<ReplyData> {
    val json = Json { ignoreUnknownKeys = true }
    val replyDataListJson = json.decodeFromString<List<BoardReplyDataJson>>(responseData)

    return replyDataListJson.map { jsonReply ->
        ReplyData(
            content = jsonReply.content.value,
            postDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()).parse(
                jsonReply.作成日時.value
            ) ?: Date()
        )
    }
}

suspend fun postReply(beaconId: String, content: String): Boolean {
    val client = OkHttpClient()
    val jsonMediaType = "application/json; charset=utf-8".toMediaType()

    val replyPost = ReplyPost(beaconId, content)
    val jsonContent = Json.encodeToString(ReplyPost.serializer(), replyPost)
    val body: RequestBody = jsonContent.toRequestBody(jsonMediaType)

    val request = Request.Builder()
.url("your api") 
        .post(body)
        .build()

    return withContext(Dispatchers.IO) {
        try {
            val response = client.newCall(request).execute()
            response.isSuccessful
        } catch (e: Exception) {
            Log.e("PostReply", "Failed to post reply: ${e.message}")
            false
        }
    }
}

@Composable
fun BoardDetailScreen(boardData: BoardData, beacons: List<BeaconData>) {
    var replyText by remember { mutableStateOf(TextFieldValue()) }
    val scrollState = rememberScrollState()
    var replies by remember { mutableStateOf(listOf<ReplyData>()) }
    val scope = rememberCoroutineScope()
    val isLoading = remember { mutableStateOf(false) }

    val beacon = beacons.filter { e: BeaconData -> boardData.beaconId == e.uuid }

    LaunchedEffect(key1 = boardData.beaconId) {
        isLoading.value = true
        try {
            replies = fetchBoardReplyData(boardData.beaconId)
        } catch (e: Exception) {
            Log.e("Dashboard", "Failed to update boards: ${e.message}")
        }
        isLoading.value = false
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(8.dp)
            .verticalScroll(scrollState)
    ) {

        MapView(beacons = beacon)

        Text(
            text = boardData.title,
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier.padding(bottom = 4.dp)
        )

        Text(
            text = boardData.content,
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.padding(bottom = 16.dp)
        )

        Divider()

        Text(
            text = "Replies",
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier.padding(vertical = 8.dp)
        )
        LoadingIndicator(isLoading = isLoading.value)
        replies.forEach { reply ->
            Text(
                text = "Reply: ${reply.content}",
                style = MaterialTheme.typography.bodySmall,
                modifier = Modifier.padding(bottom = 4.dp)
            )
            Text(
                text = "Posted on: ${reply.postDate}",
                style = MaterialTheme.typography.labelSmall
            )
        }

        OutlinedTextField(
            value = replyText,
            onValueChange = { replyText = it },
            label = { Text("Write a reply...") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 8.dp),
            maxLines = 2
        )

        Button(
            onClick = {
                if (replyText.text.isNotEmpty()) {
                    scope.launch {
                        isLoading.value = true
                        val postSuccess = postReply(boardData.beaconId, replyText.text)
                        if (postSuccess) {
                            replyText = TextFieldValue()
                            replies = fetchBoardReplyData(boardData.beaconId)
                        }
                        isLoading.value = false
                    }
                }
            },
            modifier = Modifier.padding(top = 8.dp)
        ) {
            Text("返信する")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewDashboard() {
    BeaconBBS2Theme {
        Dashboard({}, beacons = listOf(), userLocation = null)
    }
}

```

GAS
掲示板、返信、お気に入りのデータを保存するkintoneアプリを作り、データの出し入れを行います。
また、MistAPIでビーコンの制御を実施します。
掲示板の部分だけ載せておきます。
```js
function doGet(e) {
  var response = getKintoneRecords(e.parameter.beacon_id);
  return ContentService.createTextOutput(JSON.stringify(response))
    .setMimeType(ContentService.MimeType.JSON);
}

function getKintoneRecords(beaconId) {
  var kintoneUrl = 'https://your domain.cybozu.com/k/v1/records.json';
  var apiToken = 'your token'; // APIトークンを設定

  var query = 'beacon_id="' + beaconId + '"';

  var options = {
    'method': 'get',
    'headers': {
      'X-Cybozu-API-Token': apiToken,
    }
  };

  var response = UrlFetchApp.fetch(kintoneUrl + '?app=2&query=' + encodeURIComponent(query), options);
  var json = JSON.parse(response.getContentText());
  console.log(json)
  return json.records;
}

function doPost(e) {
  // JSON形式のデータを受け取る
  var data = JSON.parse(e.postData.contents);
  var beacon_id = data.beacon_id;

  // Kintoneにデータを送信する関数を呼び出し
  postMistBeacon(beacon_id, data.x, data.y);

  // Kintoneにデータを送信する関数を呼び出し
  var response = postKintoneRecord(beacon_id, data.title, data.content);

  return ContentService.createTextOutput(JSON.stringify(response))
    .setMimeType(ContentService.MimeType.JSON);
}

function postKintoneRecord(beacon_id, title, content) {
  var kintoneUrl = 'https://your domain.cybozu.com/k/v1/record.json';
  var appID = 'your app id'; // kintoneのアプリIDを設定
  var apiToken = 'your token'; // APIトークンを設定

  var recordData = {
    "app": appID,
    "record": {
      "beacon_id": {
        "value": beacon_id
      },
      "title": {
        "value": title
      },
      "content": {
        "value": content
      }
    }
  };

  var options = {
    'method': 'post',
    'headers': {
      'X-Cybozu-API-Token': apiToken,
      'Content-Type': 'application/json'
    },
    'payload': JSON.stringify(recordData)
  };

  var response = UrlFetchApp.fetch(kintoneUrl, options);
  var json = JSON.parse(response.getContentText());
  return json; // 成功時のレスポンスを返す
}

function postMistBeacon(beacon_id, x, y) {
  var url = 'https://api.ac2.mist.com/api/v1/sites/your site id/vbeacons';
  var apiToken = 'your api token';


  var data = {
    "uuid": beacon_id,
    "power_mode": "custom",
    "power": 4,
    "name": "BBS",
    "map_id": "your map id",
    "x": x,
    "y": y
  };

  var options = {
    'method': 'post',
    'headers': {
      'Authorization': "Token " + apiToken,
      'Content-Type': 'application/json'
    },
    'payload': JSON.stringify(data)
  };

  var response = UrlFetchApp.fetch(url, options);
  var json = JSON.parse(response.getContentText());
  return json; // 成功時のレスポンスを返す
}

```

# 作ってみて
やっぱりネイティブアプリ開発はしんどいです。
加えてAP設置場所に行かないと動作確認できないので時間がかかりました。
貸出機で動作確認する場合、APIの動作確認などは楽になります。
ただ、自己位置推定は設置環境の制約ゆえ難しいです。

仮想ビーコンという概念にはとてもワクワクします。
メイカーの皆さんなら、きっといろんな実装をしてくれるんじゃないでしょうか。