diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 94b75ed..e78ad9f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,6 +45,18 @@ + + + + + + + (null) + private var isAuthenticated by mutableStateOf(false) + private var isLoading by mutableStateOf(true) + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val cardId = intent.getStringExtra(EXTRA_CARD_ID) + if (cardId == null) { + finish() + return + } + + loadCard(cardId) + + setContent { + CardStoreTheme { + if (!isLoading) { + val currentCard = card + if (currentCard != null) { + if (isAuthenticated) { + CardOverlayContent( + card = currentCard, + onDismiss = { finish() }, + ) + } else { + BiometricPlaceholder(onRetry = { checkAuthentication() }) + } + } + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + val newCardId = intent.getStringExtra(EXTRA_CARD_ID) + if (newCardId == null) { + finish() + return + } + isLoading = true + isAuthenticated = false + card = null + loadCard(newCardId) + } + + private fun checkAuthentication() { + lifecycleScope.launch { + val preferencesManager = PreferencesManager(applicationContext) + val biometricEnabled = preferencesManager.biometricEnabled.first() + if (biometricEnabled && BiometricAuthService.isBiometricAvailable(this@CardOverlayActivity)) { + BiometricAuthService.authenticate( + activity = this@CardOverlayActivity, + title = getString(R.string.biometric_auth_title), + subtitle = getString(R.string.biometric_auth_subtitle), + onSuccess = { isAuthenticated = true }, + onError = { finish() }, + ) + } else { + isAuthenticated = true + } + } + } + + private fun loadCard(cardId: String) { + lifecycleScope.launch { + val cardRepository = CardRepository(applicationContext) + val cardWithLabels = cardRepository.getCardById(cardId).first() + if (cardWithLabels == null) { + finish() + return@launch + } + card = cardWithLabels.card + isLoading = false + checkAuthentication() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CardOverlayContent(card: CardEntity, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState() + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = + Modifier.fillMaxSize() + .background(Color.Transparent) + .clickable(interactionSource = interactionSource, indication = null) { onDismiss() }, + ) { + ModalBottomSheet( + modifier = Modifier.fillMaxHeight().windowInsetsPadding(WindowInsets.statusBars), + sheetState = sheetState, + onDismissRequest = onDismiss, + ) { + ViewCardSheet(card) + } + } +} + +@Preview +@Preview(device = "id:pixel_tablet") +@Composable +fun PreviewCardOverlayContent() { + CardStoreTheme { + CardOverlayContent(card = EXAMPLE_CARD, onDismiss = {}) + } +} diff --git a/app/src/main/java/de/pawcode/cardstore/MainActivity.kt b/app/src/main/java/de/pawcode/cardstore/MainActivity.kt index 3c3543a..eddde07 100644 --- a/app/src/main/java/de/pawcode/cardstore/MainActivity.kt +++ b/app/src/main/java/de/pawcode/cardstore/MainActivity.kt @@ -16,6 +16,7 @@ import com.google.android.play.core.review.ReviewManager import com.google.android.play.core.review.ReviewManagerFactory import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import de.pawcode.cardstore.data.managers.PreferencesManager +import de.pawcode.cardstore.data.utils.updateShortcuts import de.pawcode.cardstore.data.services.BiometricAuthService import de.pawcode.cardstore.data.services.DeeplinkService import de.pawcode.cardstore.data.services.ReviewService @@ -65,6 +66,8 @@ class MainActivity : FragmentActivity() { checkAuthentication() + lifecycleScope.launch { updateShortcuts(applicationContext) } + setContent { CardStoreTheme { if (isAuthenticated) { diff --git a/app/src/main/java/de/pawcode/cardstore/data/services/BiometricAuthService.kt b/app/src/main/java/de/pawcode/cardstore/data/services/BiometricAuthService.kt index 151de0a..d3d7330 100644 --- a/app/src/main/java/de/pawcode/cardstore/data/services/BiometricAuthService.kt +++ b/app/src/main/java/de/pawcode/cardstore/data/services/BiometricAuthService.kt @@ -41,8 +41,9 @@ object BiometricAuthService { } override fun onAuthenticationFailed() { + // A single failed scan (e.g. unrecognized finger) — the BiometricPrompt UI + // already shows a retry option, so no action is needed here. super.onAuthenticationFailed() - onError() } }, ) diff --git a/app/src/main/java/de/pawcode/cardstore/data/utils/ShortcutUpdater.kt b/app/src/main/java/de/pawcode/cardstore/data/utils/ShortcutUpdater.kt new file mode 100644 index 0000000..519adb7 --- /dev/null +++ b/app/src/main/java/de/pawcode/cardstore/data/utils/ShortcutUpdater.kt @@ -0,0 +1,97 @@ +package de.pawcode.cardstore.data.utils + +import android.content.Context +import android.content.Intent +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.ui.graphics.Color +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.IconCompat +import de.pawcode.cardstore.CardOverlayActivity +import de.pawcode.cardstore.data.database.entities.CardEntity +import de.pawcode.cardstore.data.database.repositories.CardRepository +import de.pawcode.cardstore.data.enums.SortAttribute +import de.pawcode.cardstore.data.managers.PreferencesManager +import de.pawcode.cardstore.utils.calculateCardScore +import de.pawcode.cardstore.utils.isLightColor +import kotlinx.coroutines.flow.first + +private const val MAX_SHORTCUTS = 3 +internal const val SHORTCUT_ACTION = "de.pawcode.cardstore.ACTION_VIEW_CARD" + +internal fun cardShortcutId(cardId: String) = "card_shortcut_$cardId" + +suspend fun updateShortcuts(context: Context) { + val cardRepository = CardRepository(context) + val preferencesManager = PreferencesManager(context) + + val allCards = cardRepository.allCards.first().map { it.card } + val sortAttribute = preferencesManager.sortAttribute.first() + + val sortedCards: List = + when (sortAttribute) { + SortAttribute.INTELLIGENT -> allCards.sortedByDescending { calculateCardScore(it) } + SortAttribute.ALPHABETICALLY -> allCards.sortedBy { it.storeName } + SortAttribute.RECENTLY_USED -> allCards.sortedByDescending { it.lastUsed } + SortAttribute.MOST_USED -> allCards.sortedByDescending { it.useCount } + } + + val topCards = sortedCards.take(MAX_SHORTCUTS) + + val newShortcutIds = topCards.map { cardShortcutId(it.cardId) }.toSet() + val existingShortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + val staleIds = existingShortcuts.filter { it.id !in newShortcutIds }.map { it.id } + if (staleIds.isNotEmpty()) ShortcutManagerCompat.removeDynamicShortcuts(context, staleIds) + + topCards.forEach { card -> + val shortcutId = cardShortcutId(card.cardId) + val icon = createShortcutIcon(card) + + val intent = + Intent(context, CardOverlayActivity::class.java).apply { + action = SHORTCUT_ACTION + putExtra(CardOverlayActivity.EXTRA_CARD_ID, card.cardId) + } + + val shortcut = + ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(card.storeName.take(25)) + .setLongLabel(card.storeName) + .setIcon(icon) + .setIntent(intent) + .build() + + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } +} + +internal fun createShortcutIcon(card: CardEntity): IconCompat { + val size = 108 + val bitmap = createBitmap(size, size) + val canvas = Canvas(bitmap) + + val circlePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = card.color + style = Paint.Style.FILL + } + canvas.drawCircle(54f, 54f, 36f, circlePaint) + + val isLight = isLightColor(Color(card.color)) + val textPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (isLight) android.graphics.Color.BLACK else android.graphics.Color.WHITE + textSize = size * 0.45f + typeface = Typeface.DEFAULT_BOLD + textAlign = Paint.Align.CENTER + } + + val firstLetter = card.storeName.firstOrNull()?.uppercaseChar()?.toString() ?: "?" + val textY = 54f - (textPaint.descent() + textPaint.ascent()) / 2f + canvas.drawText(firstLetter, 54f, textY, textPaint) + + return IconCompat.createWithAdaptiveBitmap(bitmap) +} diff --git a/app/src/main/java/de/pawcode/cardstore/ui/screens/CardListScreen.kt b/app/src/main/java/de/pawcode/cardstore/ui/screens/CardListScreen.kt index 863b35e..ad9969c 100644 --- a/app/src/main/java/de/pawcode/cardstore/ui/screens/CardListScreen.kt +++ b/app/src/main/java/de/pawcode/cardstore/ui/screens/CardListScreen.kt @@ -115,6 +115,7 @@ fun CardListScreen(navigator: Navigator, viewModel: CardViewModel = viewModel()) }, onEditCard = { card -> navigator.navigate(ScreenCardEdit(card.cardId)) }, onShowCard = { viewModel.addUsage(it) }, + onPinShortcut = { viewModel.pinShortcut(it) }, onDeleteCard = { scope.launch { viewModel.deleteCard(it) } }, onViewLabels = { navigator.navigate(ScreenLabelList) }, onSortChange = { scope.launch { preferencesManager.saveSortAttribute(it) } }, @@ -132,6 +133,7 @@ fun CardListScreenComponent( onImportCard: (importedCard: CardEntity, existingCard: CardEntity?) -> Unit, onEditCard: (CardEntity) -> Unit, onShowCard: (CardEntity) -> Unit, + onPinShortcut: (CardEntity) -> Unit, onDeleteCard: (CardEntity) -> Unit, onViewLabels: () -> Unit, onSortChange: (SortAttribute) -> Unit, @@ -324,6 +326,14 @@ fun CardListScreenComponent( ReviewService.prepareReviewRequest() }, ), + Option( + label = stringResource(R.string.shortcut_pin_to_home), + icon = R.drawable.keep_solid, + onClick = { + onPinShortcut(it) + showCardOptionSheet = null + }, + ), Option( label = stringResource(R.string.card_delete_title), icon = R.drawable.delete_forever_solid, @@ -437,6 +447,7 @@ fun PreviewCardListScreenComponent() { onImportCard = { _, _ -> }, onEditCard = {}, onShowCard = {}, + onPinShortcut = {}, onDeleteCard = {}, onViewLabels = {}, onSortChange = {}, @@ -456,6 +467,7 @@ fun PreviewCardListScreenComponentEmpty() { onImportCard = { _, _ -> }, onEditCard = {}, onShowCard = {}, + onPinShortcut = {}, onDeleteCard = {}, onViewLabels = {}, onSortChange = {}, diff --git a/app/src/main/java/de/pawcode/cardstore/ui/viewmodels/CardViewModel.kt b/app/src/main/java/de/pawcode/cardstore/ui/viewmodels/CardViewModel.kt index baf812a..8ba6ada 100644 --- a/app/src/main/java/de/pawcode/cardstore/ui/viewmodels/CardViewModel.kt +++ b/app/src/main/java/de/pawcode/cardstore/ui/viewmodels/CardViewModel.kt @@ -1,13 +1,21 @@ package de.pawcode.cardstore.ui.viewmodels import android.app.Application +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import de.pawcode.cardstore.CardOverlayActivity import de.pawcode.cardstore.data.database.classes.CardWithLabels import de.pawcode.cardstore.data.database.entities.CardEntity import de.pawcode.cardstore.data.database.entities.LabelEntity import de.pawcode.cardstore.data.database.repositories.CardRepository import de.pawcode.cardstore.data.database.repositories.LabelRepository +import de.pawcode.cardstore.data.utils.SHORTCUT_ACTION +import de.pawcode.cardstore.data.utils.cardShortcutId +import de.pawcode.cardstore.data.utils.createShortcutIcon +import de.pawcode.cardstore.data.utils.updateShortcuts import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch @@ -49,9 +57,33 @@ class CardViewModel(application: Application) : AndroidViewModel(application) { val updatedCard = card.copy(useCount = card.useCount + 1, lastUsed = System.currentTimeMillis()) - updateCard(updatedCard) + cardRepository.updateCard(updatedCard) + updateShortcuts(getApplication()) } + fun pinShortcut(card: CardEntity) { + val context = getApplication() + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) return + + val shortcutId = cardShortcutId(card.cardId) + val icon = createShortcutIcon(card) + val intent = + Intent(context, CardOverlayActivity::class.java).apply { + action = SHORTCUT_ACTION + putExtra(CardOverlayActivity.EXTRA_CARD_ID, card.cardId) + } + + val shortcut = + ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(card.storeName.take(25)) + .setLongLabel(card.storeName) + .setIcon(icon) + .setIntent(intent) + .build() + + ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + } + fun deleteCard(card: CardEntity) = viewModelScope.launch { cardRepository.deleteCard(card) } fun deleteLabel(label: LabelEntity) = viewModelScope.launch { labelRepository.deleteLabel(label) } diff --git a/app/src/main/res/drawable/keep_solid.xml b/app/src/main/res/drawable/keep_solid.xml new file mode 100644 index 0000000..ff9bff6 --- /dev/null +++ b/app/src/main/res/drawable/keep_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f6c4e2f..d5127de 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -74,6 +74,8 @@ Barcode scannen Fehler beim Scannen Einstellungen + Karte öffnen + Zum Startbildschirm hinzufügen Lass jemand anderen diesen QR-Code scannen, um diese Karte zu importieren. Teile deine Karte Alphabetisch @@ -87,4 +89,4 @@ Version Webseite besuchen - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0768194..ccc283f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,6 +70,8 @@ Scan barcode Error while trying to scan barcode App settings + Open card + Add to home screen Let someone else scan this QR code to import this card. Share your card Alphabetically diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a1c6500..4cca2a9 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,6 +3,12 @@ +