diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2111bfb..fafeb7c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -65,7 +65,8 @@ jobs: - name: 📤 Upload APK as artifact if: steps.check-build.outputs.build_apk == 'true' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: app-release.apk path: app/build/outputs/apk/release/app-release.apk + archive: false diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ffa35a6..253d4ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.jetbrains.kotlin.serialization) - id("com.google.devtools.ksp") version "2.3.5" + id("com.google.devtools.ksp") version "2.3.6" id("com.ncorti.ktfmt.gradle") version "0.25.0" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67fa63e..e78ad9f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ @@ -44,9 +45,32 @@ + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/de/pawcode/cardstore/AppLaunchTileService.kt b/app/src/main/java/de/pawcode/cardstore/AppLaunchTileService.kt new file mode 100644 index 0000000..858d8fe --- /dev/null +++ b/app/src/main/java/de/pawcode/cardstore/AppLaunchTileService.kt @@ -0,0 +1,30 @@ +package de.pawcode.cardstore + +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.service.quicksettings.TileService + +class AppLaunchTileService : TileService() { + override fun onClick() { + super.onClick() + + val launchIntent = + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse( + PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ) + } else { + @Suppress("DEPRECATION") startActivityAndCollapse(launchIntent) + } + } +} diff --git a/app/src/main/java/de/pawcode/cardstore/CardOverlayActivity.kt b/app/src/main/java/de/pawcode/cardstore/CardOverlayActivity.kt new file mode 100644 index 0000000..28e91da --- /dev/null +++ b/app/src/main/java/de/pawcode/cardstore/CardOverlayActivity.kt @@ -0,0 +1,155 @@ +package de.pawcode.cardstore + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import de.pawcode.cardstore.data.database.entities.CardEntity +import de.pawcode.cardstore.data.database.entities.EXAMPLE_CARD +import de.pawcode.cardstore.data.database.repositories.CardRepository +import de.pawcode.cardstore.data.managers.PreferencesManager +import de.pawcode.cardstore.data.services.BiometricAuthService +import de.pawcode.cardstore.ui.components.BiometricPlaceholder +import de.pawcode.cardstore.ui.sheets.ViewCardSheet +import de.pawcode.cardstore.ui.theme.CardStoreTheme +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class CardOverlayActivity : FragmentActivity() { + companion object { + const val EXTRA_CARD_ID = "card_id" + } + + private var card by mutableStateOf(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/utils/BarcodeScanner.kt b/app/src/main/java/de/pawcode/cardstore/ui/utils/BarcodeScanner.kt index d8a3662..bcd1ab8 100644 --- a/app/src/main/java/de/pawcode/cardstore/ui/utils/BarcodeScanner.kt +++ b/app/src/main/java/de/pawcode/cardstore/ui/utils/BarcodeScanner.kt @@ -32,7 +32,7 @@ fun BarcodeScanner(onBarcodeDetected: (Barcode) -> Unit, onCancel: () -> Unit) { fun handleError(exception: Exception) { SnackbarService.showSnackbar( message = context.getString(R.string.scan_error), - actionLabel = context.getString(R.string.copy_error), + actionLabel = context.getString(R.string.common_copy), onAction = { val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 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 6c9ed1b..dd875fe 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -23,7 +23,7 @@ Karte importieren Ungültiges Format Labels - Keine Labels verfügbar + Keine Labels Kartennummer Wähle eine Farbe Karte speichern @@ -37,6 +37,7 @@ Änderungsprotokoll anzeigen Zurück Abbrechen + Kopieren Löschen Verwerfen erforderlich @@ -44,7 +45,6 @@ Scannen Aktualisieren In Zwischenablage kopiert - Kopieren Ticket auf GitHub öffnen Projekt auf GitHub öffnen Jemand hat diese Karte mit dir geteilt. Klicke auf die Karte, um sie zu deiner Sammlung hinzuzufügen. @@ -55,7 +55,7 @@ Diese Karte aktualisieren Label hinzufügen Label hinzugefügt - Möchtest Du das Label wirklich löschen? Es kann nicht wiederhergestellt werden. + Möchtest du das Label wirklich löschen? Es kann nicht wiederhergestellt werden. Label löschen Label bearbeiten Name @@ -69,10 +69,13 @@ Links Im Play Store anzeigen App bewerten und rezensieren - Ein Problem entdeckt? + Problem melden + CardStore öffnen 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 @@ -86,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 75379e1..8fd4329 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ Code Color Hex code - Create manual + Create card manually Import digital card Are you sure you want to delete the card? It cannot be restored. Delete card @@ -22,7 +22,7 @@ Import card Invalid format Labels - No labels created yet + No labels Card number Pick a color Save card @@ -36,6 +36,7 @@ View changelog Back Cancel + Copy Delete Discard required @@ -43,7 +44,6 @@ Scan Update Copied to clipboard - Copy Open an issue on GitHub Open project on GitHub Someone shared this card with you. Click on the card to add it to your collection. @@ -66,9 +66,12 @@ View in Play Store Rate and review the app Report an issue + Open CardStore 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 @@ -85,4 +88,4 @@ Version Visit website - \ No newline at end of file + 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 @@ +