目次

95.設定画面の実装

仮に作成していた設定画面(:feature:settings)を実装します。

SiteUrlDAOへの追記

設定画面では、SiteUrlを全件取得するため、不足しているDAOを追記します。

   :
interface SiteUrlDao {
   :
  @Query("SELECT * FROM site_urls")
  suspend fun getAllSiteUrls(): List<SiteUrl>
   :
}

4行目~5行目
レコードを全件取得します。

データモジュールの参照

設定画面で:core:dataモジュールを参照するように:feature:settingsbuild.gradle.ktsを設定します。

   :
dependencies {
  implementation(project(":core:data"))
}

3行目
:core:dataモジュールを参照します。

追記後、『同期アイコン』で内容をプロジェクトに反映させます。

リポジトリ層の作成

リポジトリ層は、:feature:setteingsモジュールに閉じられていますので、:feature:setteings内のrepositoryに作成します。

:feature:setteingsで使用するリポジトリ定義は

だけで、追加と更新は:core:data内にインターフェース定義があり、全件取得は直接DAOを操作しても保守性が損なわれることはなさそうですので、リポジトリ層のインターフェース定義は省き、実装のみを作成します。

ファイル名は、SiteUrlRepositoryImpl.ktにしています。

package jp.co.example.android01.feature.settings.repository
import jp.co.example.android01.core.data.SiteUrl
import jp.co.example.android01.core.data.repository.SiteUrlRepository as CoreSiteUrlRepository
import jp.co.example.android01.core.data.SiteUrlDao
import javax.inject.Inject
import javax.inject.Singleton

/**
 * 設定画面専用のSiteUrlリポジトリ実装
 * 設定画面固有のビジネスロジックを提供
 */
@Singleton
class SiteUrlRepositoryImpl @Inject constructor(
  private val coreSiteUrlRepository: CoreSiteUrlRepository,
  private val siteUrlDao: SiteUrlDao
) {

  /**
   * すべてのSiteUrlを取得
   * feature:settingsでのみ使用
   */
  suspend fun getAllSiteUrls(): List<SiteUrl> {
    return siteUrlDao.getAllSiteUrls()
  }

  /**
   * SiteUrlを更新
   */
  suspend fun updateSiteUrl(siteUrl: SiteUrl): Int {
    return coreSiteUrlRepository.updateSiteUrl(siteUrl)
  }

  /**
   * SiteUrlを挿入
   */
  suspend fun insertSiteUrl(siteUrl: SiteUrl): Long {
    return coreSiteUrlRepository.insertSiteUrl(siteUrl)
  }
}

3行目
:core:dataSiteUrlRepositoryであることを明確に区別するため、別名を付けています。
12行目~13行目
DI(Hilt)の注入対象であることを定義しています。
18行目~38行目
実装を定義しています。

DI(Hilt)モジュールの作成

リポジトリを作成したので、DI(Hilt)モジュールを作成します。

DI(Hilt)モジュールは、:feature:setteingsモジュールのdiディレクトリ内に作成することにします。

package jp.co.example.android01.feature.settings.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import jp.co.example.android01.feature.settings.repository.SiteUrlRepositoryImpl
import jp.co.example.android01.core.data.repository.SiteUrlRepository as CoreSiteUrlRepository
import jp.co.example.android01.core.data.SiteUrlDao
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object HiltModule {

  @Provides
  @Singleton
  fun provideSettingsSiteUrlRepository(
    coreSiteUrlRepository: CoreSiteUrlRepository,
    siteUrlDao: SiteUrlDao
  ): SiteUrlRepositoryImpl {
    return SiteUrlRepositoryImpl(coreSiteUrlRepository, siteUrlDao)
  }
}

12行目~14行目
DI(Hilt)モジュールであることを定義しています。
16行目~23行目
SiteUrlRepositoryImplの注入定義をしています。

ViewModelの作成

設定画面に表示するデータや状態を保持・管理するためViewModelを作成します。

package jp.co.example.android01.feature.settings

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import jp.co.example.android01.core.data.SiteUrl
import jp.co.example.android01.feature.settings.repository.SiteUrlRepositoryImpl

@HiltViewModel
class SettingsViewModel @Inject constructor(
  private val siteUrlRepository: SiteUrlRepositoryImpl
) : ViewModel() {
  private val _siteUrls = MutableStateFlow<List<SiteUrl>>(emptyList())
  val siteUrls: StateFlow<List<SiteUrl>> = _siteUrls.asStateFlow()
  private val _isLoading = MutableStateFlow(false)
  val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
  // URLテキストボックスの状態を管理(id -> url)
  private val _urlFieldValues = MutableStateFlow<Map<String, String>>(emptyMap())
  val urlFieldValues: StateFlow<Map<String, String>> = _urlFieldValues.asStateFlow()
  // 元のURLテキストボックス値を保存(キャンセル時に使用)
  private val _origUrlValues = MutableStateFlow<Map<String, String>>(emptyMap())
  val origUrlValues: StateFlow<Map<String, String>> = _origUrlValues.asStateFlow()
  // 動的サイトチェックボックスの状態を管理(id -> isDynamic)
  private val _isDynamicValues = MutableStateFlow<Map<String, Boolean>>(emptyMap())
  val isDynamicValues: StateFlow<Map<String, Boolean>> = _isDynamicValues.asStateFlow()
  // 元の動的サイトチェックボックス値を保存(キャンセル時に使用)
  private val _origIsDynamicValues = MutableStateFlow<Map<String, Boolean>>(emptyMap())
  val origIsDynamicValues: StateFlow<Map<String, Boolean>> = _origIsDynamicValues.asStateFlow()

  init {
    loadAllSiteUrls()
  }

  fun loadAllSiteUrls() {
    viewModelScope.launch {
      _isLoading.value = true
      val urls = siteUrlRepository.getAllSiteUrls()
      _siteUrls.value = urls
      // データベースから取得したurlをURLテキストボックスの状態に設定
      val urlFieldMap = urls.associate { it.id to it.url }
      _urlFieldValues.value = urlFieldMap
      // 元のURLテキストボックス値も保存
      _origUrlValues.value = urlFieldMap
      // データベースから取得したisDynamicフラグを動的サイトチェックボックスの状態に設定
      val isDynamicMap = urls.associate { it.id to it.isDynamic }
      _isDynamicValues.value = isDynamicMap
      // 元の動的サイトチェックボックス値も保存
      _origIsDynamicValues.value = isDynamicMap
      _isLoading.value = false
    }
  }

  fun updateUrlFieldValue(id: String, value: String) {
    val currentValues = _urlFieldValues.value.toMutableMap()
    currentValues[id] = value
    _urlFieldValues.value = currentValues
  }

  fun updateIsDynamicValue(id: String, value: Boolean) {
    val currentValues = _isDynamicValues.value.toMutableMap()
    currentValues[id] = value
    _isDynamicValues.value = currentValues
  }

  /**
   * キャンセル処理:テキストボックスの値を元の値に戻す
   */
  fun cancelChanges() {
    _urlFieldValues.value = _origUrlValues.value
    _isDynamicValues.value = _origIsDynamicValues.value
  }

  /**
   * 保存処理:変更があった場合のみデータベースに保存
   */
  fun saveChanges() {
    viewModelScope.launch {
      val currentUrlValues = _urlFieldValues.value
      val origUrlValues = _origUrlValues.value
      val currentIsDynamicValues = _isDynamicValues.value
      val origIsDynamicValues = _origIsDynamicValues.value

      // 変更があった項目のみ保存
      for ((id, currentUrlValue) in currentUrlValues) {
        val origUrlValue = origUrlValues[id] ?: ""
        val currentIsDynamicValue = currentIsDynamicValues[id] ?: false
        val origIsDynamicValue = origIsDynamicValues[id] ?: false
        val _siteUrl = SiteUrl(
          id = id,
          url = currentUrlValue,
          isDynamic = currentIsDynamicValue,
          settingAt = getCurrentJSTDate(),
          gettingAt = ""
        )
        // 元の値が存在せず、入力がある場合は新規追加
        if (origUrlValue.isEmpty() && currentUrlValue.isNotEmpty()) {
          siteUrlRepository.insertSiteUrl(_siteUrl)
        }
        // 元の値が存在し、変更があった場合は更新
        else if (origUrlValue.isNotEmpty()
          && (currentUrlValue != origUrlValue || currentIsDynamicValue != origIsDynamicValue)) {
          siteUrlRepository.updateSiteUrl(_siteUrl)
        }
      }
      // 保存後、データを再読み込みして元の値を更新
      loadAllSiteUrls()
    }
  }

  /**
   * 現在のJST日付を取得(YYYY-MM-DD形式)
   */
  private fun getCurrentJSTDate(): String {
    val jstZone = ZoneId.of("Asia/Tokyo")
    val currentDate = LocalDate.now(jstZone)
    val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
    return currentDate.format(formatter)
  }
}

17行目~18行目
DI(Hilt)の注入対象ViewModelであることを定義しています。
21行目
更新可能な非同期(MutableStateFlow)のデータストリームを定義しています。
22行目
外部に対して読み取り専用(StateFlow)のデータストリームを提供しています。
61行目~65行目
Mutableデータストリーム上のidに該当するurlを更新します。
67行目~71行目
Mutableデータストリーム上のidに該当するisDynamicフラグを更新します。

設定画面の作成

一覧画面で使用するURLを定義するテキストボックスを設定画面に準備します。
※仮作成していた内容はすべて削除します

package jp.co.example.android01.feature.settings

import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun SettingsScreen(
  viewModel: SettingsViewModel = viewModel()
  ) {
  val urlFieldValues   by viewModel.urlFieldValues.collectAsStateWithLifecycle()
  val isDynamicValues  by viewModel.isDynamicValues.collectAsStateWithLifecycle()
  var showCancelDialog by remember { mutableStateOf(false) }
  var showSaveDialog   by remember { mutableStateOf(false) }

  Column(
    modifier = Modifier.fillMaxSize()
  ) {
    // メインコンテンツエリア
    Box(
      modifier = Modifier
        .weight(1f)
        .fillMaxSize(),
      contentAlignment = Alignment.Center
    ) {
      Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.padding(16.dp)
      ) {
        // site01
        Column(
          modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
          verticalArrangement = Arrangement.spacedBy((-8).dp)
        ) {
          // URLテキストボックス
          TextField(
            value = urlFieldValues["site01"] ?: "",
            onValueChange = { viewModel.updateUrlFieldValue("site01", it) },
            label = { Text("site01URL") },
            maxLines = 1,
            keyboardOptions = KeyboardOptions(
              keyboardType = KeyboardType.Ascii,
              imeAction = ImeAction.Done,
              capitalization = KeyboardCapitalization.None
            ),
            modifier = Modifier.fillMaxWidth()
          )
          // 動的サイトチェックボックス
          Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy((-6).dp)
          ) {
            Checkbox(
              checked = isDynamicValues["site01"] ?: false,
              onCheckedChange = { viewModel.updateIsDynamicValue("site01", it) }
            )
            Text(
              text = "動的サイト(JavaScript生成コンテンツ)"
            )
          }
        }

        // site02
        Column(
          modifier = Modifier.fillMaxWidth(),
          verticalArrangement = Arrangement.spacedBy((-8).dp)
        ) {
          // URLテキストボックス
          TextField(
            value = urlFieldValues["site02"] ?: "",
            onValueChange = { viewModel.updateUrlFieldValue("site02", it) },
            label = { Text("site02URL") },
            maxLines = 1,
            keyboardOptions = KeyboardOptions(
              keyboardType = KeyboardType.Ascii,
              imeAction = ImeAction.Done,
              capitalization = KeyboardCapitalization.None
            ),
            modifier = Modifier.fillMaxWidth()
          )
          // 動的サイトチェックボックス
          Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy((-6).dp)
          ) {
            Checkbox(
              checked = isDynamicValues["site02"] ?: false,
              onCheckedChange = { viewModel.updateIsDynamicValue("site02", it) }
            )
            Text(
              text = "動的サイト(JavaScript生成コンテンツ)"
            )
          }
        }
      }
    }

    // 最下部固定のボタンエリア
    Row(
      modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp),
      horizontalArrangement = Arrangement.SpaceEvenly
    ) {
      // キャンセルボタン
      Button(
        onClick = { showCancelDialog = true },
        modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
        colors = androidx.compose.material3.ButtonDefaults.buttonColors(
          containerColor = androidx.compose.material3.MaterialTheme.colorScheme.tertiary
        )
      ) {
        Text("キャンセル")
      }

      // 保存ボタン
      Button(
        onClick = { showSaveDialog = true },
        modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
        colors = androidx.compose.material3.ButtonDefaults.buttonColors(
          containerColor = androidx.compose.material3.MaterialTheme.colorScheme.primaryContainer,
          contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onPrimaryContainer
          )
      ) {
        Text("保存")
      }
    }
  }

  // キャンセル確認ダイアログ
  if (showCancelDialog) {
    AlertDialog(
      onDismissRequest = { showCancelDialog = false },
      title = { Text("キャンセル確認") },
      text = { Text("入力内容を元の値に戻しますか?") },
      confirmButton = {
        Button(
          onClick = {
            viewModel.cancelChanges()
            showCancelDialog = false
          }
        ) {
          Text("はい")
        }
      },
      dismissButton = {
        Button(
          onClick = { showCancelDialog = false }
        ) {
          Text("いいえ")
        }
      }
    )
  }

  // 保存確認ダイアログ
  if (showSaveDialog) {
    AlertDialog(
      onDismissRequest = { showSaveDialog = false },
      title = { Text("保存確認") },
      text = { Text("入力内容を保存しますか?") },
      confirmButton = {
        Button(
          onClick = {
            viewModel.saveChanges()
            showSaveDialog = false
          }
        ) {
          Text("はい")
        }
      },
      dismissButton = {
        Button(
          onClick = { showSaveDialog = false }
        ) {
          Text("いいえ")
        }
      }
    )
  }
}

34行目~35行目
ViewModelStateFlowデータを画面の状態(Lifecycle)に合わせて取得/解放しています。
36行目~37行目
mutableStateOfで値が変わるとUIを再コンポーズする変数を定義しています。
132行目、143行目
mutableStateOfの変数を変更していますので、UIが再コンポーズされます。
再コンポーズされることにより、キャンセル確認ダイアログや保存確認ダイアログが表示されます。

エミュレーターで設定画面の動作を確認します。