Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
26 changes: 25 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.AppSplash">
<intent-filter>
Expand Down Expand Up @@ -44,9 +45,32 @@
</intent-filter>
</activity>

<activity
android:name=".CardOverlayActivity"
android:exported="true"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:theme="@style/Theme.CardStore.Overlay">
<intent-filter>
<action android:name="de.pawcode.cardstore.ACTION_VIEW_CARD" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<service
android:name=".AppLaunchTileService"
android:exported="true"
android:icon="@drawable/credit_card"
android:label="@string/quick_settings_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />
</application>

</manifest>
</manifest>
30 changes: 30 additions & 0 deletions app/src/main/java/de/pawcode/cardstore/AppLaunchTileService.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
155 changes: 155 additions & 0 deletions app/src/main/java/de/pawcode/cardstore/CardOverlayActivity.kt
Original file line number Diff line number Diff line change
@@ -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<CardEntity?>(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 = {})
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/de/pawcode/cardstore/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +66,8 @@ class MainActivity : FragmentActivity() {

checkAuthentication()

lifecycleScope.launch { updateShortcuts(applicationContext) }

setContent {
CardStoreTheme {
if (isAuthenticated) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
},
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CardEntity> =
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)
}
Loading