diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 80d6119de46..78fe161e414 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -28,6 +28,21 @@ android:theme="@android:style/Theme.Material.Light.NoActionBar" android:name="io.homeassistant.companion.android.HiltComponentActivity" /> + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e52257f3228..6b2907fb176 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -469,7 +469,12 @@ + android:launchMode="singleTask" + android:taskAffinity="io.homeassistant.companion.android.tag.reader" + android:autoRemoveFromRecents="true" + android:showWhenLocked="true" + android:exported="true" + android:theme="@style/Theme.HomeAssistant.TranslucentOverlay"> @@ -519,7 +524,7 @@ android:taskAffinity="io.homeassistant.companion.android.assist" android:autoRemoveFromRecents="true" android:showWhenLocked="true" - android:theme="@style/Theme.HomeAssistant.Assist"> + android:theme="@style/Theme.HomeAssistant.TranslucentOverlay"> diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderActivity.kt index 99cc8ef866f..e5f21ca627f 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderActivity.kt @@ -1,87 +1,48 @@ package io.homeassistant.companion.android.nfc import android.content.Intent -import android.net.Uri import android.nfc.NfcAdapter import android.os.Bundle -import android.widget.Toast import androidx.activity.compose.setContent -import androidx.lifecycle.lifecycleScope +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.BaseActivity -import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.servers.ServerManager -import io.homeassistant.companion.android.nfc.views.TagReaderView -import io.homeassistant.companion.android.util.UrlUtil -import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme -import javax.inject.Inject -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch -import timber.log.Timber +import io.homeassistant.companion.android.common.compose.theme.HATheme +import io.homeassistant.companion.android.nfc.views.TagReaderScreen @AndroidEntryPoint class TagReaderActivity : BaseActivity() { - @Inject - lateinit var serverManager: ServerManager + private val viewModel: TagReaderViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val isNfcTag = intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED + val isQrTag = intent.action == Intent.ACTION_VIEW + setContent { - HomeAssistantAppTheme { - TagReaderView() + HATheme { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + TagReaderScreen( + state = state, + onAllowOnce = viewModel::onAllowOnce, + onAllowAlways = viewModel::onAllowAlways, + onDismissed = viewModel::onDismissed, + onErrorAcknowledged = viewModel::onErrorAcknowledged, + onFinished = ::finish, + ) } } - lifecycleScope.launch { - if (intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED || intent.action == Intent.ACTION_VIEW) { - val isNfcTag = intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED - - val url = - if (isNfcTag) { - NFCUtil.extractUrlFromNFCIntent(intent) - } else { - intent.data - } - try { - handleTag(url, isNfcTag) - } catch (e: Exception) { - showProcessingError(isNfcTag) - Timber.e(e, "Unable to handle url (${if (isNfcTag) "nfc" else "qr"}}): $url") - } - } - finish() - } - } - - private suspend fun handleTag(url: Uri?, isNfcTag: Boolean) { - // https://www.home-assistant.io/tag/5f0ba733-172f-430d-a7f8-e4ad940c88d7 - - val nfcTagId = UrlUtil.splitNfcTagId(url) - Timber.d("Tag ID: $nfcTagId") - if (nfcTagId != null && serverManager.isRegistered()) { - serverManager.servers().map { - lifecycleScope.async { - try { - serverManager.integrationRepository(it.id).scanTag(hashMapOf("tag_id" to nfcTagId)) - Timber.d("Tag scanned to HA successfully") - } catch (e: Exception) { - Timber.e(e, "Tag not scanned to HA") - } - } - }.awaitAll() + if (isNfcTag || isQrTag) { + val url = if (isNfcTag) NFCUtil.extractUrlFromNFCIntent(intent) else intent.data + viewModel.onIntentReceived(url = url, isNfcTag = isNfcTag) } else { - showProcessingError(isNfcTag) + finish() } } - - private fun showProcessingError(isNfcTag: Boolean) { - Toast.makeText( - this, - if (isNfcTag) commonR.string.nfc_processing_tag_error else commonR.string.qrcode_processing_tag_error, - Toast.LENGTH_LONG, - ).show() - } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderUiState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderUiState.kt new file mode 100644 index 00000000000..9531954734b --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderUiState.kt @@ -0,0 +1,30 @@ +package io.homeassistant.companion.android.nfc + +import androidx.annotation.StringRes + +/** + * UI states surfaced by [TagReaderViewModel] and consumed by the tag reader screen. + */ +sealed interface TagReaderUiState { + /** Initial state. No UI is rendered. */ + data object Initial : TagReaderUiState + + /** + * The scanned tag id need approving from the user before any + * server action is taken. + */ + data class ApprovingTag(val tagId: String) : TagReaderUiState + + /** + * The tag is being scanned to all registered servers. + */ + data object Scanning : TagReaderUiState + + /** + * A user-facing error occurred. + */ + data class Error(@StringRes val messageRes: Int) : TagReaderUiState + + /** Terminal state: the activity should call `finish()`. */ + data object Done : TagReaderUiState +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderViewModel.kt new file mode 100644 index 00000000000..b6e1c4ba136 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/TagReaderViewModel.kt @@ -0,0 +1,152 @@ +package io.homeassistant.companion.android.nfc + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.util.UrlUtil +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Lower bound on how long the [TagReaderUiState.Scanning] state stays visible. Servers may + * answer faster than the user can perceive the loading sheet, so we keep it on screen for + * at least this long before transitioning to [TagReaderUiState.Done]. + */ +@VisibleForTesting +internal val MIN_SCANNING_DURATION = 1.5.seconds + +/** + * Drives the NFC / QR tag reader flow. + * + * Reads the tag id from the incoming URL, decides whether it can be auto-scanned + * based on whether it is already present in the allowed tags preference, or requires + * manual user approval when it is not yet allowed, and exposes the result as [uiState]. + */ +@HiltViewModel +class TagReaderViewModel @Inject constructor( + private val serverManager: ServerManager, + private val prefsRepository: PrefsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(TagReaderUiState.Initial) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Called by the activity once the launching intent has been parsed. Drives + * a single tag-handling cycle and updates [uiState] accordingly. + */ + fun onIntentReceived(url: Uri?, isNfcTag: Boolean) { + viewModelScope.launch { + val tagId = UrlUtil.splitNfcTagId(url) + if (tagId == null) { + Timber.w("Tag intent had no tag id, isNfcTag=$isNfcTag") + _uiState.value = TagReaderUiState.Error(errorMessageRes(isNfcTag)) + return@launch + } + if (!serverManager.isRegistered()) { + Timber.w("Tag scanned but no server is registered") + _uiState.value = TagReaderUiState.Error(errorMessageRes(isNfcTag)) + return@launch + } + if (prefsRepository.allowedTags().contains(tagId)) { + scanAndFinish(tagId) + } else { + _uiState.value = TagReaderUiState.ApprovingTag(tagId) + } + } + } + + /** + * Called when the user taps "Allow Once" in the approval bottom sheet. Transitions + * the sheet into its scanning state and dispatches the tag to all registered servers. + * + * Has no effect unless the current state is [TagReaderUiState.ApprovingTag]. + */ + fun onAllowOnce() { + val current = _uiState.value as? TagReaderUiState.ApprovingTag ?: return + viewModelScope.launch { + scanAndFinish(current.tagId) + } + } + + /** + * Called when the user taps "Allow always" in the approval bottom sheet. Persists + * the tag id so future scans of the same tag skip the + * approval step, then proceeds with the scan in the same way as [onAllowOnce]. + * + * Has no effect unless the current state is [TagReaderUiState.ApprovingTag]. + */ + fun onAllowAlways() { + val current = _uiState.value as? TagReaderUiState.ApprovingTag ?: return + viewModelScope.launch { + prefsRepository.addAllowedTag(current.tagId) + scanAndFinish(current.tagId) + } + } + + /** + * Called once the user-facing error has been dismissed. Transitions to [TagReaderUiState.Done] + * so the activity can finish. + */ + fun onErrorAcknowledged() { + if (_uiState.value is TagReaderUiState.Error) { + _uiState.value = TagReaderUiState.Done + } + } + + /** + * Called when the user dismisses the approval. + * Transitions to [TagReaderUiState.Done] so the activity can finish. + */ + fun onDismissed() { + _uiState.value = TagReaderUiState.Done + } + + private suspend fun scanAndFinish(tagId: String) { + _uiState.value = TagReaderUiState.Scanning + coroutineScope { + launch { delay(MIN_SCANNING_DURATION) } + launch { scanTag(tagId) } + } + _uiState.value = TagReaderUiState.Done + } + + private suspend fun scanTag(tagId: String) { + coroutineScope { + serverManager.servers().map { server -> + async { + try { + serverManager.integrationRepository(server.id) + .scanTag(mapOf("tag_id" to tagId)) + Timber.d("Tag scanned to HA serverId=${server.id}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Tag not scanned to HA serverId=${server.id}") + } + } + }.awaitAll() + } + } + + private fun errorMessageRes(isNfcTag: Boolean): Int = if (isNfcTag) { + commonR.string.nfc_processing_tag_error + } else { + commonR.string.qrcode_processing_tag_error + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/views/TagReaderScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/views/TagReaderScreen.kt new file mode 100644 index 00000000000..2b6982d65a2 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/views/TagReaderScreen.kt @@ -0,0 +1,297 @@ +package io.homeassistant.companion.android.nfc.views + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.composable.HAFilledButton +import io.homeassistant.companion.android.common.compose.composable.HAModalBottomSheet +import io.homeassistant.companion.android.common.compose.composable.HAPlainButton +import io.homeassistant.companion.android.common.compose.composable.rememberHAModalBottomSheetState +import io.homeassistant.companion.android.common.compose.theme.HADimens +import io.homeassistant.companion.android.common.compose.theme.HATextStyle +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme +import io.homeassistant.companion.android.common.compose.util.adaptiveIconPainterResource +import io.homeassistant.companion.android.nfc.TagReaderUiState +import io.homeassistant.companion.android.util.safeBottomWindowInsets + +private val SCANNING_CONTENT_HEIGHT = 160.dp + +/** Visible diameter of the sheet close button (background + clip). */ +private val CLOSE_BUTTON_VISIBLE_SIZE = 40.dp + +/** Tap target size of the sheet close button — meets Material's 48dp minimum for accessibility. */ +private val CLOSE_BUTTON_TAP_SIZE = 48.dp + +/** + * Renders the tag reader UI for a given [state]. + * + * - [TagReaderUiState.Initial]: nothing is rendered. + * - [TagReaderUiState.ApprovingTag]: bottom sheet with the approval content. Dismissal is + * wired to [onDismissed]. + * - [TagReaderUiState.Scanning]: bottom sheet with the loading content. + * - [TagReaderUiState.Error]: a snackbar is shown for [TagReaderUiState.Error.messageRes]; + * once it is dismissed [onErrorAcknowledged] is invoked. + * - [TagReaderUiState.Done]: if a sheet was visible, it is animated out before [onFinished] + * is invoked. Without this lag the Scanning → Done transition would snap away unanimated. + * + * @param onFinished invoked after the screen has finished any closing animations and the + * activity should call `finish()`. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TagReaderScreen( + state: TagReaderUiState, + onAllowOnce: () -> Unit, + onAllowAlways: () -> Unit, + onDismissed: () -> Unit, + onErrorAcknowledged: () -> Unit, + onFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + val sheetState = rememberHAModalBottomSheetState() + + // Tracks whether the bottom sheet has ever been shown. Used to decide whether the + // [TagReaderUiState.Done] state should keep the sheet in composition long enough to + // animate it out without this flag, an Error → Done transition would briefly render + // the sheet only to immediately animate it away (a visible blink). + var sheetWasShown by remember { mutableStateOf(false) } + LaunchedEffect(state) { + if (state is TagReaderUiState.ApprovingTag || state is TagReaderUiState.Scanning) { + sheetWasShown = true + } + } + + val showSheet = state is TagReaderUiState.ApprovingTag || + state is TagReaderUiState.Scanning || + (state is TagReaderUiState.Done && sheetWasShown) + + Box(modifier = modifier.fillMaxSize()) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(safeBottomWindowInsets(applyHorizontal = true)), + ) + + if (showSheet) { + HAModalBottomSheet( + bottomSheetState = sheetState, + onDismissRequest = onDismissed, + dragHandle = {}, + ) { + Column( + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + ) { + TagApprovalSheetHeader(onClose = onDismissed) + + when (state) { + is TagReaderUiState.ApprovingTag -> ApprovingContent( + tagId = state.tagId, + onAllowOnce = onAllowOnce, + onAllowAlways = onAllowAlways, + ) + + TagReaderUiState.Scanning -> ScanningContent() + TagReaderUiState.Done -> Unit + } + } + } + } + } + + if (state is TagReaderUiState.Done) { + LaunchedEffect(Unit) { + if (sheetState.isVisible) { + sheetState.hide() + } + onFinished() + } + } + + if (state is TagReaderUiState.Error) { + val message = stringResource(state.messageRes) + LaunchedEffect(state) { + snackbarHostState.showSnackbar(message) + onErrorAcknowledged() + } + } +} + +@Composable +private fun TagApprovalSheetHeader(onClose: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(bottom = HADimens.SPACE2), + ) { + Column(modifier = Modifier.align(Alignment.TopCenter)) { + Image( + painter = adaptiveIconPainterResource(R.mipmap.ic_launcher_round), + contentDescription = stringResource(commonR.string.home_assistant_branding_icon_content_description), + modifier = Modifier + .padding(top = HADimens.SPACE5) + .size(36.dp) + .align(Alignment.CenterHorizontally) + .clip(CircleShape), + ) + Text( + text = stringResource(commonR.string.app_name), + style = HATextStyle.BodyMedium, + modifier = Modifier + .padding(HADimens.SPACE2) + .align(Alignment.CenterHorizontally), + ) + } + // Replicates Close button of the Android 17 biometrics bottom sheet + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(HADimens.SPACE2) + .size(CLOSE_BUTTON_TAP_SIZE) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClose, + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(CLOSE_BUTTON_VISIBLE_SIZE) + .clip(CircleShape) + .background(LocalHAColorScheme.current.colorSurfaceLow), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(commonR.string.cancel), + tint = LocalHAColorScheme.current.colorOnNeutralQuiet, + ) + } + } + } +} + +@Composable +private fun ApprovingContent(tagId: String, onAllowOnce: () -> Unit, onAllowAlways: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HADimens.SPACE6, vertical = HADimens.SPACE4), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE3), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(commonR.string.tag_approval_title), + style = HATextStyle.HeadlineMedium, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource(commonR.string.tag_approval_description, tagId), + style = HATextStyle.Body, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = HADimens.SPACE4), + ) + + HAFilledButton( + text = stringResource(commonR.string.tag_allow_always), + onClick = onAllowAlways, + modifier = Modifier.fillMaxWidth(), + ) + HAPlainButton( + text = stringResource(commonR.string.tag_allow_once), + onClick = onAllowOnce, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ScanningContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .height(SCANNING_CONTENT_HEIGHT) + .padding(horizontal = HADimens.SPACE6, vertical = HADimens.SPACE4), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE4, Alignment.CenterVertically), + ) { + CircularProgressIndicator() + Text( + text = stringResource(commonR.string.tag_reader_title), + style = HATextStyle.Body, + textAlign = TextAlign.Center, + ) + } +} + +@Preview +@Composable +private fun ApprovingPreview() { + HAThemeForPreview { + TagReaderScreen( + state = TagReaderUiState.ApprovingTag("aa69fa33-39fd-4247-a559-af06081b2935"), + onAllowOnce = {}, + onErrorAcknowledged = {}, + onDismissed = {}, + onAllowAlways = {}, + onFinished = {}, + ) + } +} + +@Preview +@Composable +private fun ScanningPreview() { + HAThemeForPreview { + TagReaderScreen( + state = TagReaderUiState.Scanning, + onAllowOnce = {}, + onErrorAcknowledged = {}, + onDismissed = {}, + onAllowAlways = {}, + onFinished = {}, + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/views/TagReaderView.kt b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/views/TagReaderView.kt deleted file mode 100644 index 82ac035fc32..00000000000 --- a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/views/TagReaderView.kt +++ /dev/null @@ -1,47 +0,0 @@ -package io.homeassistant.companion.android.nfc.views - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.homeassistant.companion.android.common.R - -@Composable -fun TagReaderView(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(all = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - Text( - text = stringResource(R.string.tag_reader_title), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth(0.75f) - .padding(top = 16.dp), - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun TagReaderViewPreview() { - TagReaderView() -} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt index f8d76fc25a4..c88101204e6 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt @@ -105,6 +105,17 @@ class DeveloperSettingsFragment : return@setOnPreferenceClickListener true } } + findPreference("tag_clear_allowed")?.let { + it.setOnPreferenceClickListener { + presenter.clearAllowedTags() + Toast.makeText( + requireContext(), + commonR.string.clear_allowed_tags_complete, + Toast.LENGTH_SHORT, + ).show() + return@setOnPreferenceClickListener true + } + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenter.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenter.kt index bd5930bb32d..38c11c02696 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenter.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenter.kt @@ -17,4 +17,5 @@ interface DeveloperSettingsPresenter { fun webViewSupportsClearCache(): Boolean fun clearWebViewCache() + fun clearAllowedTags() } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt index 47edcd6ddd9..e204c81e660 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt @@ -168,4 +168,10 @@ class DeveloperSettingsPresenterImpl @Inject constructor( view.onWebViewClearCacheResult(success = false) } } + + override fun clearAllowedTags() { + mainScope.launch { + prefsRepository.clearAllowedTags() + } + } } diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 8a1187b396e..edccfff7225 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -29,7 +29,7 @@ @bool/isLightMode -