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 @@
+
+