diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 594d998..b2aa700 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -120,6 +120,8 @@ android {
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
+ optIn.add("kotlinx.serialization.ExperimentalSerializationApi")
+ freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
@@ -131,6 +133,7 @@ dependencies {
implementation(libs.dagger.hilt.android)
ksp(libs.dagger.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
+ implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.material3)
@@ -187,7 +190,6 @@ openApiGenerate {
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath)
apiPackage.set("info.nukoneko.cuc.android.kidspos.api.generated")
modelPackage.set("info.nukoneko.cuc.android.kidspos.api.generated.model")
- invokerPackage.set("info.nukoneko.cuc.android.kidspos.api.generated.invoker")
configOptions.set(
mapOf(
"library" to "jvm-retrofit2",
diff --git a/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/api/DemoDangerZoneService.kt b/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/api/DemoDangerZoneService.kt
deleted file mode 100644
index 54b73b5..0000000
--- a/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/api/DemoDangerZoneService.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.api
-
-import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
-
-class DemoDangerZoneService : DangerZoneService {
- override suspend fun isPasswordConfigured(): Boolean = false
-
- override suspend fun verifyPassword(password: String): DangerZoneVerification =
- DangerZoneVerification(
- valid = false,
- configured = false,
- message = "デモモードではパスワードは設定されていません"
- )
-}
diff --git a/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt b/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt
index d968b89..c51edc0 100644
--- a/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt
+++ b/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt
@@ -6,10 +6,8 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import info.nukoneko.cuc.android.kidspos.api.APIService
import info.nukoneko.cuc.android.kidspos.api.AppUpdateService
-import info.nukoneko.cuc.android.kidspos.api.DangerZoneService
import info.nukoneko.cuc.android.kidspos.api.DemoAPIService
import info.nukoneko.cuc.android.kidspos.api.DemoAppUpdateService
-import info.nukoneko.cuc.android.kidspos.api.DemoDangerZoneService
import javax.inject.Singleton
@Module
@@ -23,8 +21,4 @@ object ApiModule {
@Provides
@Singleton
fun provideAppUpdateService(): AppUpdateService = DemoAppUpdateService()
-
- @Provides
- @Singleton
- fun provideDangerZoneService(): DangerZoneService = DemoDangerZoneService()
}
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneRateLimitedException.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneRateLimitedException.kt
deleted file mode 100644
index b988fe6..0000000
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneRateLimitedException.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.api
-
-class DangerZoneRateLimitedException(
- val retryAfterSeconds: Long?
-) : Exception("Danger zone verification is rate limited")
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneService.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneService.kt
deleted file mode 100644
index 928305b..0000000
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneService.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.api
-
-import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
-
-interface DangerZoneService {
- suspend fun isPasswordConfigured(): Boolean
-
- suspend fun verifyPassword(password: String): DangerZoneVerification
-}
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneService.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneService.kt
deleted file mode 100644
index a82d45f..0000000
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneService.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.api
-
-import info.nukoneko.cuc.android.kidspos.api.generated.DangerZoneApi
-import info.nukoneko.cuc.android.kidspos.api.generated.model.VerifyDangerZonePasswordRequest
-import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
-
-class OpenApiDangerZoneService(
- private val dangerZoneApi: DangerZoneApi
-) : DangerZoneService {
-
- override suspend fun isPasswordConfigured(): Boolean {
- val response = dangerZoneApi.getDangerZoneStatus()
- if (!response.isSuccessful) {
- throw Exception("Failed to get danger zone status: ${response.code()}")
- }
- return response.body()!!.configured
- }
-
- override suspend fun verifyPassword(password: String): DangerZoneVerification {
- val response = dangerZoneApi.verifyDangerZonePassword(
- VerifyDangerZonePasswordRequest(password = password)
- )
- if (response.code() == HTTP_TOO_MANY_REQUESTS) {
- throw DangerZoneRateLimitedException(
- response.headers()[RETRY_AFTER_HEADER]?.trim()?.toLongOrNull()
- )
- }
- if (!response.isSuccessful) {
- throw Exception("Failed to verify danger zone password: ${response.code()}")
- }
- val body = response.body()!!
- return DangerZoneVerification(
- valid = body.valid,
- configured = body.configured,
- message = body.message
- )
- }
-
- private companion object {
- const val HTTP_TOO_MANY_REQUESTS = 429
- const val RETRY_AFTER_HEADER = "Retry-After"
- }
-}
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/DangerZoneRepository.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/DangerZoneRepository.kt
deleted file mode 100644
index 5449226..0000000
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/data/repository/DangerZoneRepository.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.data.repository
-
-import info.nukoneko.cuc.android.kidspos.api.DangerZoneService
-import info.nukoneko.cuc.android.kidspos.di.hilt.IoDispatcher
-import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.withContext
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class DangerZoneRepository @Inject constructor(
- private val dangerZoneService: DangerZoneService,
- @IoDispatcher private val dispatcher: CoroutineDispatcher
-) {
- suspend fun isPasswordConfigured(): Boolean = withContext(dispatcher) {
- dangerZoneService.isPasswordConfigured()
- }
-
- suspend fun verifyPassword(password: String): DangerZoneVerification = withContext(dispatcher) {
- dangerZoneService.verifyPassword(password)
- }
-}
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/entity/DangerZoneVerification.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/entity/DangerZoneVerification.kt
deleted file mode 100644
index ee7d958..0000000
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/entity/DangerZoneVerification.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.entity
-
-data class DangerZoneVerification(
- val valid: Boolean,
- val configured: Boolean,
- val message: String
-)
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt
index 7fb9a62..d1e1b63 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt
@@ -63,7 +63,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
-import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import info.nukoneko.cuc.android.kidspos.R
import info.nukoneko.cuc.android.kidspos.entity.Item
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt
index 6b92856..ff12b3e 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt
@@ -6,42 +6,50 @@ import android.os.Build
import android.provider.Settings
import android.webkit.URLUtil
import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import info.nukoneko.cuc.android.kidspos.R
import info.nukoneko.cuc.android.kidspos.update.UpdateFailure
import info.nukoneko.cuc.android.kidspos.update.UpdateStatus
+import info.nukoneko.cuc.android.kidspos.util.Mode
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@@ -74,60 +82,30 @@ fun SettingsScreen(
)
}
) { innerPadding ->
- Column(
+ Row(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
- .padding(16.dp)
+ .padding(24.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.Top
) {
- Text(stringResource(R.string.current_connetion_target))
- OutlinedTextField(
- value = state.serverAddress,
- onValueChange = viewModel::onServerAddressChange,
- readOnly = true,
- modifier = Modifier.fillMaxWidth()
+ ConnectionCard(
+ serverAddress = state.serverAddress,
+ onLoadSetting = { scanLauncher.launch(ScanOptions()) },
+ modifier = Modifier.weight(1f)
)
- Spacer(modifier = Modifier.padding(16.dp))
- Text(stringResource(R.string.current_mode_format, state.mode.modeName))
- Spacer(modifier = Modifier.padding(16.dp))
- Text(
- stringResource(
- R.string.current_version_format,
- state.currentVersionName,
- state.currentVersionCode
- )
+ ModeCard(
+ currentMode = state.mode,
+ onToggleMode = viewModel::onToggleMode,
+ modifier = Modifier.weight(1f)
)
- Spacer(modifier = Modifier.padding(16.dp))
- HorizontalDivider()
- Spacer(modifier = Modifier.padding(16.dp))
- DangerZoneSection(
+ AppUpdateCard(
state = state,
- onPasswordChange = viewModel::onDangerZonePasswordChange,
- onUnlock = viewModel::onUnlockDangerZone,
- onLock = viewModel::onLockDangerZone
+ onCheckUpdate = viewModel::onCheckUpdate,
+ modifier = Modifier.weight(1f)
)
- if (state.dangerZoneUnlocked) {
- Spacer(modifier = Modifier.padding(16.dp))
- Button(
- onClick = { scanLauncher.launch(ScanOptions()) },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(stringResource(R.string.load_setting))
- }
- Spacer(modifier = Modifier.padding(8.dp))
- Button(
- onClick = viewModel::onToggleMode,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(stringResource(R.string.switch_mode_format, state.mode.toggle().modeName))
- }
- Spacer(modifier = Modifier.padding(16.dp))
- AppUpdateSection(
- state = state,
- onCheckUpdate = viewModel::onCheckUpdate
- )
- }
}
}
@@ -142,86 +120,95 @@ fun SettingsScreen(
}
@Composable
-private fun DangerZoneSection(
- state: SettingsUiState,
- onPasswordChange: (String) -> Unit,
- onUnlock: () -> Unit,
- onLock: () -> Unit
+private fun SettingsCard(
+ title: String,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit
) {
- Text(stringResource(R.string.danger_zone))
- Spacer(modifier = Modifier.padding(4.dp))
- Text(stringResource(R.string.danger_zone_description))
-
- when (val status = state.dangerZoneStatus) {
- is DangerZoneStatus.Checking -> {
- Spacer(modifier = Modifier.padding(8.dp))
- Text(stringResource(R.string.danger_zone_checking))
+ Card(
+ modifier = modifier,
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(text = title, style = MaterialTheme.typography.titleLarge)
+ content()
}
+ }
+}
- is DangerZoneStatus.Locked -> {
- Spacer(modifier = Modifier.padding(8.dp))
- OutlinedTextField(
- value = state.dangerZonePassword,
- onValueChange = onPasswordChange,
- label = { Text(stringResource(R.string.danger_zone_password)) },
- singleLine = true,
- visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
- modifier = Modifier.fillMaxWidth()
- )
- Spacer(modifier = Modifier.padding(4.dp))
- Button(
- onClick = onUnlock,
- enabled = state.dangerZonePassword.isNotEmpty() && !state.dangerZoneVerifying,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(stringResource(R.string.danger_zone_unlock))
- }
- if (state.dangerZoneVerifying) {
- Spacer(modifier = Modifier.padding(4.dp))
- Text(stringResource(R.string.danger_zone_unlocking))
- }
- when (val error = status.error) {
- null -> Unit
- is DangerZoneError.Rejected -> {
- Spacer(modifier = Modifier.padding(4.dp))
- Text(error.message)
- }
+@Composable
+private fun ActionButton(
+ text: String,
+ onClick: () -> Unit,
+ enabled: Boolean = true
+) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier.heightIn(min = 56.dp),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)
+ ) {
+ Text(text = text, style = MaterialTheme.typography.titleMedium)
+ }
+}
- is DangerZoneError.RateLimited -> {
- Spacer(modifier = Modifier.padding(4.dp))
- val seconds = error.retryAfterSeconds
- Text(
- if (seconds != null) {
- stringResource(R.string.danger_zone_rate_limited, seconds)
- } else {
- stringResource(R.string.danger_zone_rate_limited_unknown)
- }
- )
- }
+@Composable
+private fun ConnectionCard(
+ serverAddress: String,
+ onLoadSetting: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ SettingsCard(
+ title = stringResource(R.string.current_connetion_target),
+ modifier = modifier
+ ) {
+ Text(text = serverAddress, style = MaterialTheme.typography.bodyLarge)
+ ActionButton(text = stringResource(R.string.load_setting), onClick = onLoadSetting)
+ }
+}
- is DangerZoneError.Unreachable -> {
- Spacer(modifier = Modifier.padding(4.dp))
- Text(stringResource(R.string.danger_zone_verify_failed))
- }
+@Composable
+private fun ModeCard(
+ currentMode: Mode,
+ onToggleMode: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ SettingsCard(
+ title = stringResource(R.string.current_mode),
+ modifier = modifier
+ ) {
+ val modes = listOf(Mode.PRACTICE, Mode.PRODUCTION)
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ modes.forEachIndexed { index, mode ->
+ SegmentedButton(
+ selected = currentMode == mode,
+ onClick = { if (currentMode != mode) onToggleMode() },
+ shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size),
+ modifier = Modifier.heightIn(min = 56.dp),
+ label = { Text(mode.modeName) }
+ )
}
}
+ }
+}
- is DangerZoneStatus.Unlocked -> {
- val message = when (status.reason) {
- DangerZoneReason.NOT_CONFIGURED -> R.string.danger_zone_not_configured
- DangerZoneReason.STATUS_UNAVAILABLE -> R.string.danger_zone_status_failed
- DangerZoneReason.VERIFIED -> R.string.danger_zone_unlocked
- }
- Spacer(modifier = Modifier.padding(8.dp))
- Text(stringResource(message))
- if (status.reason == DangerZoneReason.VERIFIED) {
- Spacer(modifier = Modifier.padding(4.dp))
- TextButton(onClick = onLock) {
- Text(stringResource(R.string.danger_zone_lock))
- }
- }
- }
+@Composable
+private fun AppUpdateCard(
+ state: SettingsUiState,
+ onCheckUpdate: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ SettingsCard(
+ title = stringResource(R.string.app_update),
+ modifier = modifier
+ ) {
+ AppUpdateSection(state = state, onCheckUpdate = onCheckUpdate)
}
}
@@ -233,33 +220,34 @@ private fun AppUpdateSection(
val context = LocalContext.current
val status = state.updateStatus
- Text(stringResource(R.string.app_update))
- Spacer(modifier = Modifier.padding(8.dp))
- Button(
+ Text(
+ text = stringResource(
+ R.string.current_version_format,
+ state.currentVersionName,
+ state.currentVersionCode
+ ),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ ActionButton(
+ text = stringResource(R.string.check_update),
onClick = onCheckUpdate,
enabled = status !is UpdateStatus.Checking &&
status !is UpdateStatus.Downloading &&
- status !is UpdateStatus.Installing,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(stringResource(R.string.check_update))
- }
+ status !is UpdateStatus.Installing
+ )
when (status) {
is UpdateStatus.Idle, is UpdateStatus.Available -> Unit
is UpdateStatus.Checking -> {
- Spacer(modifier = Modifier.padding(8.dp))
Text(stringResource(R.string.checking_update))
}
is UpdateStatus.UpToDate -> {
- Spacer(modifier = Modifier.padding(8.dp))
Text(stringResource(R.string.app_is_up_to_date))
}
is UpdateStatus.Downloading -> {
- Spacer(modifier = Modifier.padding(8.dp))
Text(
stringResource(
R.string.downloading_update_format,
@@ -274,15 +262,13 @@ private fun AppUpdateSection(
}
is UpdateStatus.Installing -> {
- Spacer(modifier = Modifier.padding(8.dp))
Text(stringResource(R.string.installing_update))
}
is UpdateStatus.InstallNotPermitted -> {
- Spacer(modifier = Modifier.padding(8.dp))
Text(stringResource(R.string.install_permission_required))
- Spacer(modifier = Modifier.padding(4.dp))
- Button(
+ ActionButton(
+ text = stringResource(R.string.open_install_permission_setting),
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startActivity(
@@ -292,15 +278,11 @@ private fun AppUpdateSection(
)
)
}
- },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(stringResource(R.string.open_install_permission_setting))
- }
+ }
+ )
}
is UpdateStatus.Failed -> {
- Spacer(modifier = Modifier.padding(8.dp))
Text(
when (status.cause) {
UpdateFailure.CHECK -> stringResource(R.string.update_check_failed)
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt
index d2f6272..3f761f6 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import info.nukoneko.cuc.android.kidspos.BuildConfig
-import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
-import info.nukoneko.cuc.android.kidspos.data.repository.DangerZoneRepository
import info.nukoneko.cuc.android.kidspos.data.settings.SettingsRepository
import info.nukoneko.cuc.android.kidspos.update.AppUpdateManager
import info.nukoneko.cuc.android.kidspos.update.UpdateStatus
@@ -16,44 +14,19 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import timber.log.Timber
import javax.inject.Inject
-enum class DangerZoneReason {
- NOT_CONFIGURED,
- STATUS_UNAVAILABLE,
- VERIFIED
-}
-
-sealed interface DangerZoneStatus {
- data object Checking : DangerZoneStatus
- data class Locked(val error: DangerZoneError? = null) : DangerZoneStatus
- data class Unlocked(val reason: DangerZoneReason) : DangerZoneStatus
-}
-
-sealed interface DangerZoneError {
- data class Rejected(val message: String) : DangerZoneError
- data class RateLimited(val retryAfterSeconds: Long?) : DangerZoneError
- data object Unreachable : DangerZoneError
-}
-
data class SettingsUiState(
val serverAddress: String = "",
val mode: Mode = Mode.PRACTICE,
val currentVersionName: String = BuildConfig.VERSION_NAME,
val currentVersionCode: Int = BuildConfig.VERSION_CODE,
- val updateStatus: UpdateStatus = UpdateStatus.Idle,
- val dangerZoneStatus: DangerZoneStatus = DangerZoneStatus.Checking,
- val dangerZonePassword: String = "",
- val dangerZoneVerifying: Boolean = false
-) {
- val dangerZoneUnlocked: Boolean get() = dangerZoneStatus is DangerZoneStatus.Unlocked
-}
+ val updateStatus: UpdateStatus = UpdateStatus.Idle
+)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
- private val dangerZoneRepository: DangerZoneRepository,
private val appUpdateManager: AppUpdateManager,
private val applicationScope: CoroutineScope
) : ViewModel() {
@@ -79,69 +52,6 @@ class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(updateStatus = status) }
}
}
- checkDangerZoneStatus()
- }
-
- private fun checkDangerZoneStatus() {
- viewModelScope.launch {
- val status = try {
- if (dangerZoneRepository.isPasswordConfigured()) {
- DangerZoneStatus.Locked()
- } else {
- DangerZoneStatus.Unlocked(DangerZoneReason.NOT_CONFIGURED)
- }
- } catch (e: Exception) {
- Timber.w(e, "Failed to get danger zone status")
- DangerZoneStatus.Unlocked(DangerZoneReason.STATUS_UNAVAILABLE)
- }
- _uiState.update { it.copy(dangerZoneStatus = status) }
- }
- }
-
- fun onDangerZonePasswordChange(value: String) {
- _uiState.update {
- it.copy(dangerZonePassword = value, dangerZoneStatus = DangerZoneStatus.Locked())
- }
- }
-
- fun onUnlockDangerZone() {
- val password = _uiState.value.dangerZonePassword
- if (password.isEmpty() || _uiState.value.dangerZoneVerifying) return
- _uiState.update { it.copy(dangerZoneVerifying = true) }
- viewModelScope.launch {
- val status = try {
- val result = dangerZoneRepository.verifyPassword(password)
- when {
- result.valid -> DangerZoneStatus.Unlocked(DangerZoneReason.VERIFIED)
- !result.configured -> DangerZoneStatus.Unlocked(DangerZoneReason.NOT_CONFIGURED)
- else -> DangerZoneStatus.Locked(DangerZoneError.Rejected(result.message))
- }
- } catch (e: DangerZoneRateLimitedException) {
- Timber.w(e, "Danger zone verification is rate limited")
- DangerZoneStatus.Locked(DangerZoneError.RateLimited(e.retryAfterSeconds))
- } catch (e: Exception) {
- Timber.w(e, "Failed to verify danger zone password")
- DangerZoneStatus.Locked(DangerZoneError.Unreachable)
- }
- _uiState.update {
- it.copy(
- dangerZoneStatus = status,
- dangerZoneVerifying = false,
- dangerZonePassword = if (status is DangerZoneStatus.Unlocked) {
- ""
- } else {
- it.dangerZonePassword
- }
- )
- }
- }
- }
-
- fun onLockDangerZone() {
- _uiState.update {
- it.copy(dangerZoneStatus = DangerZoneStatus.Checking, dangerZonePassword = "")
- }
- checkDangerZoneStatus()
}
// 書き込み直後に画面を離れると viewModelScope が閉じて保存が取り消されるため、
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Color.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Color.kt
index 26bec81..f75d42b 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Color.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Color.kt
@@ -6,9 +6,16 @@ val Primary = Color(0xFFD32F2F)
val OnPrimary = Color(0xFFFFFFFF)
val Secondary = Color(0xFF455A64)
val OnSecondary = Color(0xFFFFFFFF)
+val SecondaryContainer = Color(0xFFE0E0E0)
+val OnSecondaryContainer = Color(0xFF1C1C1C)
val Background = Color(0xFFFAFAFA)
val OnBackground = Color(0xFF1C1C1C)
val Surface = Color(0xFFFFFFFF)
val OnSurface = Color(0xFF1C1C1C)
val Error = Color(0xFFB00020)
val OnError = Color(0xFFFFFFFF)
+val SurfaceContainerLowest = Color(0xFFFFFFFF)
+val SurfaceContainerLow = Color(0xFFF5F5F5)
+val SurfaceContainer = Color(0xFFEEEEEE)
+val SurfaceContainerHigh = Color(0xFFE8E8E8)
+val SurfaceContainerHighest = Color(0xFFE0E0E0)
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Theme.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Theme.kt
index 6ac26e1..e468a03 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Theme.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/theme/Theme.kt
@@ -9,12 +9,19 @@ private val LightColors = lightColorScheme(
onPrimary = OnPrimary,
secondary = Secondary,
onSecondary = OnSecondary,
+ secondaryContainer = SecondaryContainer,
+ onSecondaryContainer = OnSecondaryContainer,
background = Background,
onBackground = OnBackground,
surface = Surface,
onSurface = OnSurface,
error = Error,
- onError = OnError
+ onError = OnError,
+ surfaceContainerLowest = SurfaceContainerLowest,
+ surfaceContainerLow = SurfaceContainerLow,
+ surfaceContainer = SurfaceContainer,
+ surfaceContainerHigh = SurfaceContainerHigh,
+ surfaceContainerHighest = SurfaceContainerHighest
)
@Composable
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/ApkInstallResultBus.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/ApkInstallResultBus.kt
index 6c58244..76d1552 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/ApkInstallResultBus.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/update/ApkInstallResultBus.kt
@@ -1,5 +1,6 @@
package info.nukoneko.cuc.android.kidspos.update
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -21,6 +22,7 @@ class ApkInstallResultBus @Inject constructor() {
_results.tryEmit(result)
}
+ @OptIn(ExperimentalCoroutinesApi::class)
fun clear() {
_results.resetReplayCache()
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e2500dc..311af0d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -46,8 +46,6 @@
[テストモード]
練習モードのためレシートは出ません
通信エラーが発生しました。
- 現在は %s モードです
- %s に切り替える
アプリの更新
@@ -67,18 +65,4 @@
アプリを更新するには、不明なアプリのインストールを許可してください
設定を開く
-
- Danger Zone
- 接続先の変更・モードの切り替え・アプリの更新は、パスワードを入れてから操作できます
- パスワードの設定状態を確認しています…
- パスワード
- ロックを解除する
- 確認しています…
- もう一度ロックする
- ロックを解除しました
- サーバーにパスワードが設定されていないため、そのまま操作できます
- サーバーに接続できないため、そのまま操作できます
- サーバーに接続できませんでした
- 試行回数が多すぎます。%1$d秒後にもう一度お試しください
- 試行回数が多すぎます。しばらく待ってからもう一度お試しください
diff --git a/app/src/prod/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt b/app/src/prod/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt
index 46a5246..256f5f1 100644
--- a/app/src/prod/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt
+++ b/app/src/prod/kotlin/info/nukoneko/cuc/android/kidspos/di/hilt/ApiModule.kt
@@ -6,12 +6,9 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import info.nukoneko.cuc.android.kidspos.api.APIService
import info.nukoneko.cuc.android.kidspos.api.AppUpdateService
-import info.nukoneko.cuc.android.kidspos.api.DangerZoneService
import info.nukoneko.cuc.android.kidspos.api.OpenApiAPIService
import info.nukoneko.cuc.android.kidspos.api.OpenApiAppUpdateService
-import info.nukoneko.cuc.android.kidspos.api.OpenApiDangerZoneService
import info.nukoneko.cuc.android.kidspos.api.generated.ApkApi
-import info.nukoneko.cuc.android.kidspos.api.generated.DangerZoneApi
import info.nukoneko.cuc.android.kidspos.api.generated.ItemsApi
import info.nukoneko.cuc.android.kidspos.api.generated.SalesApi
import info.nukoneko.cuc.android.kidspos.api.generated.StatusApi
@@ -28,11 +25,6 @@ object ApiModule {
fun provideApkApi(retrofit: Retrofit): ApkApi =
retrofit.create(ApkApi::class.java)
- @Provides
- @Singleton
- fun provideDangerZoneApi(retrofit: Retrofit): DangerZoneApi =
- retrofit.create(DangerZoneApi::class.java)
-
@Provides
@Singleton
fun provideItemsApi(retrofit: Retrofit): ItemsApi =
@@ -71,9 +63,4 @@ object ApiModule {
@Singleton
fun provideAppUpdateService(apkApi: ApkApi): AppUpdateService =
OpenApiAppUpdateService(apkApi)
-
- @Provides
- @Singleton
- fun provideDangerZoneService(dangerZoneApi: DangerZoneApi): DangerZoneService =
- OpenApiDangerZoneService(dangerZoneApi)
}
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneServiceTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneServiceTest.kt
deleted file mode 100644
index 34a861f..0000000
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneServiceTest.kt
+++ /dev/null
@@ -1,131 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.api
-
-import info.nukoneko.cuc.android.kidspos.api.generated.DangerZoneApi
-import info.nukoneko.cuc.android.kidspos.api.generated.model.ChangeDangerZonePasswordRequest
-import info.nukoneko.cuc.android.kidspos.api.generated.model.ClearDangerZonePasswordRequest
-import info.nukoneko.cuc.android.kidspos.api.generated.model.DangerZonePasswordResponse
-import info.nukoneko.cuc.android.kidspos.api.generated.model.DangerZoneStatusResponse
-import info.nukoneko.cuc.android.kidspos.api.generated.model.DangerZoneVerifyResponse
-import info.nukoneko.cuc.android.kidspos.api.generated.model.VerifyDangerZonePasswordRequest
-import kotlinx.coroutines.test.runTest
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.Protocol
-import okhttp3.Request
-import okhttp3.ResponseBody.Companion.toResponseBody
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import retrofit2.Response
-
-class OpenApiDangerZoneServiceTest {
-
- private class FakeDangerZoneApi(
- private val verifyResponse: Response
- ) : DangerZoneApi {
- override suspend fun getDangerZoneStatus(): Response =
- Response.success(DangerZoneStatusResponse(configured = true))
-
- override suspend fun verifyDangerZonePassword(
- verifyDangerZonePasswordRequest: VerifyDangerZonePasswordRequest
- ): Response = verifyResponse
-
- override suspend fun changeDangerZonePassword(
- changeDangerZonePasswordRequest: ChangeDangerZonePasswordRequest
- ): Response =
- Response.success(
- DangerZonePasswordResponse(
- success = true,
- message = "パスワードを保存しました",
- configured = true
- )
- )
-
- override suspend fun clearDangerZonePassword(
- clearDangerZonePasswordRequest: ClearDangerZonePasswordRequest
- ): Response =
- Response.success(
- DangerZonePasswordResponse(
- success = true,
- message = "パスワードを解除しました",
- configured = false
- )
- )
- }
-
- private fun tooManyRequests(retryAfter: String?): Response {
- val raw = okhttp3.Response.Builder()
- .request(Request.Builder().url("http://localhost/api/setting/danger-zone/verify").build())
- .protocol(Protocol.HTTP_1_1)
- .code(429)
- .message("Too Many Requests")
- .apply { retryAfter?.let { header("Retry-After", it) } }
- .build()
- return Response.error("{}".toResponseBody("application/json".toMediaType()), raw)
- }
-
- private fun serviceWith(response: Response) =
- OpenApiDangerZoneService(FakeDangerZoneApi(response))
-
- @Test
- fun successfulVerifyIsMappedToVerification() = runTest {
- val service = serviceWith(
- Response.success(
- DangerZoneVerifyResponse(valid = true, configured = true, message = "認証しました")
- )
- )
-
- val result = service.verifyPassword("secret")
-
- assertEquals(true, result.valid)
- assertEquals(true, result.configured)
- assertEquals("認証しました", result.message)
- }
-
- @Test
- fun tooManyRequestsRaisesRateLimitedWithRetryAfter() = runTest {
- val service = serviceWith(tooManyRequests("45"))
-
- val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
-
- assertTrue(error is DangerZoneRateLimitedException)
- assertEquals(45L, (error as DangerZoneRateLimitedException).retryAfterSeconds)
- }
-
- @Test
- fun tooManyRequestsWithoutRetryAfterRaisesRateLimitedWithoutSeconds() = runTest {
- val service = serviceWith(tooManyRequests(null))
-
- val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
-
- assertTrue(error is DangerZoneRateLimitedException)
- assertNull((error as DangerZoneRateLimitedException).retryAfterSeconds)
- }
-
- @Test
- fun tooManyRequestsWithHttpDateRetryAfterRaisesRateLimitedWithoutSeconds() = runTest {
- val service = serviceWith(tooManyRequests("Wed, 21 Oct 2015 07:28:00 GMT"))
-
- val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
-
- assertTrue(error is DangerZoneRateLimitedException)
- assertNull((error as DangerZoneRateLimitedException).retryAfterSeconds)
- }
-
- @Test
- fun otherErrorStatusRaisesGenericFailure() = runTest {
- val raw = okhttp3.Response.Builder()
- .request(Request.Builder().url("http://localhost/api/setting/danger-zone/verify").build())
- .protocol(Protocol.HTTP_1_1)
- .code(500)
- .message("Internal Server Error")
- .build()
- val service = serviceWith(
- Response.error("{}".toResponseBody("application/json".toMediaType()), raw)
- )
-
- val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
-
- assertTrue(error != null && error !is DangerZoneRateLimitedException)
- }
-}
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/FakeDangerZoneService.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/FakeDangerZoneService.kt
deleted file mode 100644
index 943e6ba..0000000
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/FakeDangerZoneService.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package info.nukoneko.cuc.android.kidspos.testutil
-
-import info.nukoneko.cuc.android.kidspos.api.DangerZoneService
-import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
-
-class FakeDangerZoneService : DangerZoneService {
- val verifiedPasswords = mutableListOf()
-
- var isPasswordConfiguredHandler: suspend () -> Boolean = { false }
-
- var verifyPasswordHandler: suspend (String) -> DangerZoneVerification = {
- DangerZoneVerification(valid = false, configured = true, message = "パスワードが違います")
- }
-
- override suspend fun isPasswordConfigured(): Boolean = isPasswordConfiguredHandler()
-
- override suspend fun verifyPassword(password: String): DangerZoneVerification {
- verifiedPasswords += password
- return verifyPasswordHandler(password)
- }
-}
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/MainDispatcherRule.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/MainDispatcherRule.kt
index 32bcd49..b9abd4d 100644
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/MainDispatcherRule.kt
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/MainDispatcherRule.kt
@@ -1,6 +1,7 @@
package info.nukoneko.cuc.android.kidspos.testutil
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
@@ -8,6 +9,7 @@ import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
+@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/TestFactories.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/TestFactories.kt
index 6d65cd3..7705ce8 100644
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/TestFactories.kt
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/testutil/TestFactories.kt
@@ -4,7 +4,6 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import info.nukoneko.cuc.android.kidspos.api.APIService
import info.nukoneko.cuc.android.kidspos.data.repository.AppUpdateRepository
-import info.nukoneko.cuc.android.kidspos.data.repository.DangerZoneRepository
import info.nukoneko.cuc.android.kidspos.data.repository.ItemRepository
import info.nukoneko.cuc.android.kidspos.data.repository.SaleRepository
import info.nukoneko.cuc.android.kidspos.data.repository.ServerStatusRepository
@@ -66,7 +65,6 @@ fun createSettingsViewModel(
apkDownloader: FakeApkDownloader = FakeApkDownloader(),
apkInstaller: FakeApkInstaller = FakeApkInstaller(),
apkInstallResultBus: ApkInstallResultBus = ApkInstallResultBus(),
- dangerZoneService: FakeDangerZoneService = FakeDangerZoneService(),
dispatcher: CoroutineDispatcher = Dispatchers.Unconfined,
appUpdateManager: AppUpdateManager = createAppUpdateManager(
appUpdateService,
@@ -78,7 +76,6 @@ fun createSettingsViewModel(
applicationScope: CoroutineScope = CoroutineScope(dispatcher)
): SettingsViewModel = SettingsViewModel(
settingsRepository,
- DangerZoneRepository(dangerZoneService, dispatcher),
appUpdateManager,
applicationScope
)
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt
index bcee1b6..51cb8f2 100644
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureScreenRoboImage
import info.nukoneko.cuc.android.kidspos.R
@@ -16,7 +17,6 @@ import info.nukoneko.cuc.android.kidspos.entity.Store
import info.nukoneko.cuc.android.kidspos.testutil.FakeAPIService
import info.nukoneko.cuc.android.kidspos.testutil.FakeApkInstaller
import info.nukoneko.cuc.android.kidspos.testutil.FakeAppUpdateService
-import info.nukoneko.cuc.android.kidspos.testutil.FakeDangerZoneService
import info.nukoneko.cuc.android.kidspos.testutil.MainDispatcherRule
import info.nukoneko.cuc.android.kidspos.testutil.createMainViewModel
import info.nukoneko.cuc.android.kidspos.testutil.createSettingsViewModel
@@ -44,6 +44,7 @@ import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = RobolectricDeviceQualifiers.MediumTablet)
+@OptIn(ExperimentalRoborazziApi::class)
class ScreenshotTest {
private val mainDispatcherRule = MainDispatcherRule()
private val composeRule = createComposeRule()
@@ -272,21 +273,6 @@ class ScreenshotTest {
captureScreenRoboImage("screenshots/settings_screen.png")
}
- @Test
- fun settingsScreenDangerZoneLocked() {
- runBlocking { settingsRepository.setServerAddress("http://192.168.1.10:8080") }
- val dangerZoneService = FakeDangerZoneService().apply {
- isPasswordConfiguredHandler = { true }
- }
- setSettingsContent(
- createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
- )
- captureScreenRoboImage("screenshots/settings_screen_danger_zone_locked.png")
- }
-
@Test
fun settingsScreenUpdateAvailable() {
val viewModel = createSettingsViewModel(
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt
index 6b554fe..3468cc3 100644
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt
@@ -9,15 +9,12 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
-import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import info.nukoneko.cuc.android.kidspos.R
-import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.entity.AppUpdate
-import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
import info.nukoneko.cuc.android.kidspos.testutil.FakeAppUpdateService
-import info.nukoneko.cuc.android.kidspos.testutil.FakeDangerZoneService
import info.nukoneko.cuc.android.kidspos.testutil.MainDispatcherRule
import info.nukoneko.cuc.android.kidspos.testutil.createSettingsViewModel
import info.nukoneko.cuc.android.kidspos.testutil.fakeSettingsRepository
@@ -32,7 +29,7 @@ import org.robolectric.annotation.Config
// Robolectric の SDK 36 実行は JDK 21 が必要なため、CI の JDK 17 で動く SDK 35 に固定する
@RunWith(AndroidJUnit4::class)
-@Config(sdk = [35])
+@Config(sdk = [35], qualifiers = RobolectricDeviceQualifiers.MediumTablet)
class SettingsScreenTest {
private val mainDispatcherRule = MainDispatcherRule()
private val composeRule = createComposeRule()
@@ -45,7 +42,7 @@ class SettingsScreenTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
- fun currentServerAddressIsShownInTextField() {
+ fun currentServerAddressIsShown() {
val viewModel = createSettingsViewModel(settingsRepository)
composeRule.setContent {
SettingsScreen(onNavigateBack = {}, viewModel = viewModel)
@@ -65,20 +62,31 @@ class SettingsScreenTest {
}
@Test
- fun toggleModeButtonSwitchesToProduction() {
+ fun selectingProductionSegmentSwitchesMode() {
val viewModel = createSettingsViewModel(settingsRepository)
composeRule.setContent {
SettingsScreen(onNavigateBack = {}, viewModel = viewModel)
}
- composeRule.onNodeWithText(
- context.getString(R.string.switch_mode_format, Mode.PRODUCTION.modeName)
- ).performScrollTo().performClick()
+ composeRule.onNodeWithText(Mode.PRODUCTION.modeName).performScrollTo().performClick()
composeRule.waitForIdle()
assertEquals(Mode.PRODUCTION, viewModel.uiState.value.mode)
}
+ @Test
+ fun selectingCurrentModeSegmentDoesNothing() {
+ val viewModel = createSettingsViewModel(settingsRepository)
+ composeRule.setContent {
+ SettingsScreen(onNavigateBack = {}, viewModel = viewModel)
+ }
+
+ composeRule.onNodeWithText(Mode.PRACTICE.modeName).performScrollTo().performClick()
+ composeRule.waitForIdle()
+
+ assertEquals(Mode.PRACTICE, viewModel.uiState.value.mode)
+ }
+
@Test
fun backButtonInvokesNavigateBack() {
var navigatedBack = false
@@ -111,138 +119,6 @@ class SettingsScreenTest {
composeRule.onNodeWithText(context.getString(R.string.app_is_up_to_date)).assertExists()
}
- @Test
- fun lockedDangerZoneHidesDangerousActions() {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- composeRule.setContent {
- SettingsScreen(
- onNavigateBack = {},
- viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
- )
- }
-
- composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
- .performScrollTo()
- .assertIsDisplayed()
- composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertDoesNotExist()
- composeRule.onNodeWithText(context.getString(R.string.check_update)).assertDoesNotExist()
- composeRule.onNodeWithText(
- context.getString(R.string.switch_mode_format, Mode.PRODUCTION.modeName)
- ).assertDoesNotExist()
- }
-
- @Test
- fun correctPasswordRevealsDangerousActions() {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = {
- DangerZoneVerification(valid = true, configured = true, message = "OK")
- }
- composeRule.setContent {
- SettingsScreen(
- onNavigateBack = {},
- viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
- )
- }
-
- composeRule.onNode(hasSetTextAction()).performTextInput("secret")
- composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
- .performScrollTo()
- .performClick()
- composeRule.waitForIdle()
-
- assertEquals(listOf("secret"), dangerZoneService.verifiedPasswords)
- composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertExists()
- composeRule.onNodeWithText(context.getString(R.string.check_update)).assertExists()
- }
-
- @Test
- fun wrongPasswordShowsServerMessage() {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = {
- DangerZoneVerification(valid = false, configured = true, message = "パスワードが違います")
- }
- composeRule.setContent {
- SettingsScreen(
- onNavigateBack = {},
- viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
- )
- }
-
- composeRule.onNode(hasSetTextAction()).performTextInput("wrong")
- composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
- .performScrollTo()
- .performClick()
- composeRule.waitForIdle()
-
- composeRule.onNodeWithText("パスワードが違います").assertExists()
- composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertDoesNotExist()
- }
-
- @Test
- fun rateLimitedVerifyShowsRetryAfterMessage() {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(45) }
- composeRule.setContent {
- SettingsScreen(
- onNavigateBack = {},
- viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
- )
- }
-
- composeRule.onNode(hasSetTextAction()).performTextInput("wrong")
- composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
- .performScrollTo()
- .performClick()
- composeRule.waitForIdle()
-
- composeRule.onNodeWithText(
- context.getString(R.string.danger_zone_rate_limited, 45L)
- ).assertExists()
- composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertDoesNotExist()
- }
-
- @Test
- fun rateLimitedVerifyWithoutRetryAfterShowsGenericMessage() {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(null) }
- composeRule.setContent {
- SettingsScreen(
- onNavigateBack = {},
- viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
- )
- }
-
- composeRule.onNode(hasSetTextAction()).performTextInput("wrong")
- composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
- .performScrollTo()
- .performClick()
- composeRule.waitForIdle()
-
- composeRule.onNodeWithText(
- context.getString(R.string.danger_zone_rate_limited_unknown)
- ).assertExists()
- }
-
@Test
fun availableUpdateShowsConfirmDialog() {
val updateService = FakeAppUpdateService()
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt
index 19ebcbe..4c463fd 100644
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt
@@ -1,13 +1,10 @@
package info.nukoneko.cuc.android.kidspos.ui.settings
import androidx.lifecycle.viewModelScope
-import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.entity.AppUpdate
-import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
import info.nukoneko.cuc.android.kidspos.testutil.FakeApkDownloader
import info.nukoneko.cuc.android.kidspos.testutil.FakeApkInstaller
import info.nukoneko.cuc.android.kidspos.testutil.FakeAppUpdateService
-import info.nukoneko.cuc.android.kidspos.testutil.FakeDangerZoneService
import info.nukoneko.cuc.android.kidspos.testutil.MainDispatcherRule
import info.nukoneko.cuc.android.kidspos.testutil.createAppUpdateManager
import info.nukoneko.cuc.android.kidspos.testutil.createSettingsViewModel
@@ -200,198 +197,4 @@ class SettingsViewModelTest {
assertEquals(1, installer.installedApks.size)
assertEquals(UpdateStatus.Installing, secondViewModel.uiState.value.updateStatus)
}
-
- @Test
- fun unconfiguredPasswordLeavesDangerZoneUnlocked() = runTest {
- val viewModel = createSettingsViewModel(settingsRepository)
-
- assertEquals(
- DangerZoneStatus.Unlocked(DangerZoneReason.NOT_CONFIGURED),
- viewModel.uiState.value.dangerZoneStatus
- )
- }
-
- @Test
- fun configuredPasswordLocksDangerZone() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- assertEquals(DangerZoneStatus.Locked(), viewModel.uiState.value.dangerZoneStatus)
- }
-
- @Test
- fun statusFailureLeavesDangerZoneUnlocked() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { throw Exception("boom") }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- assertEquals(
- DangerZoneStatus.Unlocked(DangerZoneReason.STATUS_UNAVAILABLE),
- viewModel.uiState.value.dangerZoneStatus
- )
- }
-
- @Test
- fun correctPasswordUnlocksDangerZone() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = {
- DangerZoneVerification(valid = true, configured = true, message = "OK")
- }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onDangerZonePasswordChange("secret")
- viewModel.onUnlockDangerZone()
-
- assertEquals(
- DangerZoneStatus.Unlocked(DangerZoneReason.VERIFIED),
- viewModel.uiState.value.dangerZoneStatus
- )
- assertEquals(listOf("secret"), dangerZoneService.verifiedPasswords)
- assertEquals("", viewModel.uiState.value.dangerZonePassword)
- }
-
- @Test
- fun wrongPasswordKeepsDangerZoneLockedWithMessage() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onDangerZonePasswordChange("wrong")
- viewModel.onUnlockDangerZone()
-
- assertEquals(
- DangerZoneStatus.Locked(DangerZoneError.Rejected("パスワードが違います")),
- viewModel.uiState.value.dangerZoneStatus
- )
- assertEquals("wrong", viewModel.uiState.value.dangerZonePassword)
- }
-
- @Test
- fun verifyFailureKeepsDangerZoneLocked() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = { throw Exception("boom") }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onDangerZonePasswordChange("secret")
- viewModel.onUnlockDangerZone()
-
- assertEquals(
- DangerZoneStatus.Locked(DangerZoneError.Unreachable),
- viewModel.uiState.value.dangerZoneStatus
- )
- }
-
- @Test
- fun rateLimitedVerifyKeepsDangerZoneLockedWithRetryAfter() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(45) }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onDangerZonePasswordChange("wrong")
- viewModel.onUnlockDangerZone()
-
- assertEquals(
- DangerZoneStatus.Locked(DangerZoneError.RateLimited(45)),
- viewModel.uiState.value.dangerZoneStatus
- )
- assertEquals("wrong", viewModel.uiState.value.dangerZonePassword)
- }
-
- @Test
- fun rateLimitedVerifyWithoutRetryAfterKeepsDangerZoneLocked() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(null) }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onDangerZonePasswordChange("wrong")
- viewModel.onUnlockDangerZone()
-
- assertEquals(
- DangerZoneStatus.Locked(DangerZoneError.RateLimited(null)),
- viewModel.uiState.value.dangerZoneStatus
- )
- }
-
- @Test
- fun passwordClearedOnServerUnlocksDangerZone() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = {
- DangerZoneVerification(valid = false, configured = false, message = "未設定です")
- }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onDangerZonePasswordChange("secret")
- viewModel.onUnlockDangerZone()
-
- assertEquals(
- DangerZoneStatus.Unlocked(DangerZoneReason.NOT_CONFIGURED),
- viewModel.uiState.value.dangerZoneStatus
- )
- }
-
- @Test
- fun emptyPasswordDoesNotCallServer() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onUnlockDangerZone()
-
- assertTrue(dangerZoneService.verifiedPasswords.isEmpty())
- assertEquals(DangerZoneStatus.Locked(), viewModel.uiState.value.dangerZoneStatus)
- }
-
- @Test
- fun lockDangerZoneRechecksServerStatus() = runTest {
- val dangerZoneService = FakeDangerZoneService()
- dangerZoneService.isPasswordConfiguredHandler = { true }
- dangerZoneService.verifyPasswordHandler = {
- DangerZoneVerification(valid = true, configured = true, message = "OK")
- }
- val viewModel = createSettingsViewModel(
- settingsRepository,
- dangerZoneService = dangerZoneService
- )
-
- viewModel.onDangerZonePasswordChange("secret")
- viewModel.onUnlockDangerZone()
- assertTrue(viewModel.uiState.value.dangerZoneUnlocked)
-
- viewModel.onLockDangerZone()
-
- assertEquals(DangerZoneStatus.Locked(), viewModel.uiState.value.dangerZoneStatus)
- }
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b08eef4..ce4042e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -65,6 +65,7 @@ android-material = { module = "com.google.android.material:material", version.re
dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
dagger-hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }
+androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
okhttp-client = { module = "com.squareup.okhttp3:okhttp" }