仮に作成していた設定画面(:feature:settings)を実装します。
設定画面では、SiteUrlを全件取得するため、不足しているDAOを追記します。
:
interface SiteUrlDao {
:
@Query("SELECT * FROM site_urls")
suspend fun getAllSiteUrls(): List<SiteUrl>
:
}
設定画面で:core:dataモジュールを参照するように:feature:settingsのbuild.gradle.ktsを設定します。
:
dependencies {
implementation(project(":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)
}
}
リポジトリを作成したので、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)
}
}
設定画面に表示するデータや状態を保持・管理するため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)
}
}
一覧画面で使用する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("いいえ")
}
}
)
}
}
エミュレーターで設定画面の動作を確認します。