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
-