feat: full md3 redesign and few tweaks #2

Merged
omni merged 1 commits from fimkov/OmniCards:ui-redesign into main 2026-06-19 15:45:07 +03:00
43 changed files with 4013 additions and 1199 deletions
Showing only changes of commit e62b7f4741 - Show all commits
+5 -2
View File
@@ -33,7 +33,8 @@ android {
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -62,6 +63,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.material3)
implementation("androidx.compose.material:material-icons-extended")
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.material)
@@ -76,5 +78,6 @@ dependencies {
implementation(libs.zxing.android)
implementation("com.google.android.gms:play-services-wearable:18.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation(libs.compose.colorpicker)
implementation("com.materialkolor:material-kolor:2.1.1")
implementation("androidx.graphics:graphics-shapes:1.0.1")
}
+10
View File
@@ -19,3 +19,13 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
*** serializer(...);
}
-keepclasseswithmembers class **$$serializer { *; }
-keepclassmembers enum com.google.zxing.BarcodeFormat { *; }
+22 -12
View File
@@ -1,28 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OmniCards">
<activity
android:name=".BackupActivity"
android:exported="false"
android:theme="@style/Theme.OmniCards" />
<activity
android:name=".EditCardActivity"
android:exported="false"
android:theme="@style/Theme.OmniCards" />
<activity
android:name=".ShowCardActivity"
android:exported="false"
android:theme="@style/Theme.OmniCards" />
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|density|fontScale|locale|layoutDirection"
android:theme="@style/Theme.OmniCards">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -30,6 +24,22 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ScannerActivity"
android:exported="false"
android:screenOrientation="fullSensor"
android:theme="@style/Base.Theme.OmniCards" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -1,150 +0,0 @@
package ru.omni_devel.cards
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import kotlinx.serialization.json.Json
import ru.omni_devel.cards.ui.theme.OmniCardsTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
class BackupActivity : ComponentActivity() {
private lateinit var db: DbHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
db = DbHelper(this, null)
setContent {
OmniCardsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
BackupPage(innerPadding = innerPadding, getDbFun = {
return@BackupPage db
})
}
}
}
}
}
@Composable
fun BackupPage(innerPadding: PaddingValues, getDbFun: () -> DbHelper) {
val context = LocalContext.current
val activityContext = LocalActivity.current
val showImportDialog = rememberSaveable { mutableStateOf(false) }
var pendingUri by rememberSaveable { mutableStateOf<Uri?>(null) }
val createBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let {
try {
val jsonData = Json.encodeToString(getDbFun().getCards())
context.contentResolver.openOutputStream(it)?.use { outputStream ->
outputStream.write(jsonData.toByteArray())
Toast.makeText(context, context.getString(R.string.backup_successfully_saved), Toast.LENGTH_SHORT).show()
activityContext!!.finish()
}
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.failed_to_save_backup, e), Toast.LENGTH_LONG).show()
}
}
}
val restoreFromBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
pendingUri = it
showImportDialog.value = true
}
}
if (showImportDialog.value) {
AlertDialog(
title = { Text(stringResource(R.string.ask_do_restore)) },
text = { Text(stringResource(R.string.restore_caution)) },
confirmButton = {
TextButton(
onClick = {
showImportDialog.value = false
pendingUri?.let { uri ->
try {
context.contentResolver.openInputStream(uri)?.use { stream ->
val json = stream.bufferedReader().use { it.readText() }
val cards = Json.decodeFromString<List<CardInfo>>(json)
val db = getDbFun()
db.clearDatabase()
db.addCards(cards)
Toast.makeText(context, context.getString(R.string.backup_successfully_restored), Toast.LENGTH_SHORT).show()
activityContext!!.finish()
}
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.failed_to_restore_backup, e), Toast.LENGTH_LONG).show()
}
}
}
) {
Text(stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(
onClick = {
showImportDialog.value = false
}
) {
Text(stringResource(R.string.no))
}
},
onDismissRequest = { showImportDialog.value = false },
)
}
Page(stringResource(R.string.backup), innerPadding) {
FullWidthButton(
onClick = {
createBackupLauncher.launch("omnicards-backup.json")
}
) {
Text(stringResource(R.string.do_backup))
}
FullWidthButton(
onClick = {
restoreFromBackupLauncher.launch("application/json")
}
) {
Text(stringResource(R.string.do_restore))
}
}
}
@@ -0,0 +1,155 @@
package ru.omni_devel.cards
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.serialization.json.Json
@Composable
fun BackupScreen(
db: DbHelper,
onBack: () -> Unit,
) {
val context = LocalContext.current
var showRestoreDialog by remember { mutableStateOf(false) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
val createBackupLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let {
try {
val json = Json.encodeToString(db.getCards())
context.contentResolver.openOutputStream(it)?.use { stream ->
stream.write(json.toByteArray())
}
Toast.makeText(context, context.getString(R.string.backup_successfully_saved), Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.failed_to_save_backup, e), Toast.LENGTH_LONG).show()
}
}
}
val restoreLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
pendingUri = it
showRestoreDialog = true
}
}
if (showRestoreDialog) {
AlertDialog(
onDismissRequest = { showRestoreDialog = false },
title = { Text(stringResource(R.string.ask_do_restore)) },
text = { Text(stringResource(R.string.restore_caution)) },
confirmButton = {
TextButton(onClick = {
showRestoreDialog = false
pendingUri?.let { uri ->
try {
context.contentResolver.openInputStream(uri)?.use { stream ->
val json = stream.bufferedReader().use { it.readText() }
val cards = Json.decodeFromString<List<CardInfo>>(json)
db.clearDatabase()
db.addCards(cards)
}
Toast.makeText(context, context.getString(R.string.backup_successfully_restored), Toast.LENGTH_SHORT).show()
onBack()
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.failed_to_restore_backup, e), Toast.LENGTH_LONG).show()
}
}
}) {
Text(stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(onClick = { showRestoreDialog = false }) {
Text(stringResource(R.string.no))
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.statusBarsPadding()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
Text(stringResource(R.string.backup), style = MaterialTheme.typography.titleLarge)
}
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = { createBackupLauncher.launch("omnicards-backup.json") },
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Icon(Icons.Filled.Download, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.do_backup))
}
OutlinedButton(
onClick = { restoreLauncher.launch("application/json") },
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Icon(Icons.Filled.Restore, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.do_restore))
}
}
}
}
@@ -0,0 +1,38 @@
package ru.omni_devel.cards
data class Brand(val name: String, val domain: String)
val brandCatalog = listOf(
Brand("Магнит", "magnit.ru"),
Brand("Пятёрочка", "5ka.ru"),
Brand("Перекрёсток", "perekrestok.ru"),
Brand("Лента", "lenta.com"),
Brand("Ашан", "auchan.ru"),
Brand("Дикси", "dixy.ru"),
Brand("ВкусВилл", "vkusvill.ru"),
Brand("Окей", "okmarket.ru"),
Brand("Метро", "metro-cc.ru"),
Brand("Магнит Косметик", "magnitcosmetic.ru"),
Brand("Лэтуаль", "letu.ru"),
Brand("Золотое яблоко", "goldapple.ru"),
Brand("Рив Гош", "rivegauche.ru"),
Brand("Ozon", "ozon.ru"),
Brand("Wildberries", "wildberries.ru"),
Brand("Яндекс Маркет", "market.yandex.ru"),
Brand("Мегамаркет", "megamarket.ru"),
Brand("DNS", "dns-shop.ru"),
Brand("М.Видео", "mvideo.ru"),
Brand("Эльдорадо", "eldorado.ru"),
Brand("Ситилинк", "citilink.ru"),
Brand("Спортмастер", "sportmaster.ru"),
Brand("Декатлон", "decathlon.ru"),
Brand("Детский мир", "detmir.ru"),
Brand("Леруа Мерлен", "leroymerlin.ru"),
Brand("Петрович", "petrovich.ru"),
Brand("Аптека.ру", "apteka.ru"),
Brand("Ригла", "rigla.ru"),
Brand("Читай-город", "chitai-gorod.ru"),
Brand("Лукойл", "lukoil.ru"),
Brand("Бургер Кинг", "burgerkingrus.ru"),
Brand("Вкусно — и точка", "vkusnoitochka.ru"),
)
@@ -3,16 +3,15 @@ package ru.omni_devel.cards
import com.google.zxing.BarcodeFormat
import kotlinx.serialization.Serializable
enum class EditCardAction {
ADD,
EDIT,
}
@Serializable
class CardInfo (
class CardInfo(
val id: Int,
val name: String,
val codeType: BarcodeFormat,
val codeValue: String,
val color: String?,
) {}
val position: Int = 0,
val iconPath: String? = null,
val monochrome: Boolean = false,
val iconData: String? = null,
)
@@ -0,0 +1,533 @@
package ru.omni_devel.cards
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.TextButton
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CardsScreen(
db: DbHelper,
cards: SnapshotStateList<CardInfo>,
onOpenCard: (CardInfo) -> Unit,
onAddCard: () -> Unit,
onOpenBackup: () -> Unit,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
var fabExpanded by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var draggingId by remember { mutableStateOf<Int?>(null) }
var dragCard by remember { mutableStateOf<CardInfo?>(null) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
var dragHeightPx by remember { mutableFloatStateOf(0f) }
var overDelete by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<CardInfo?>(null) }
var boxCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var listTopPx by remember { mutableFloatStateOf(0f) }
var zoneBottomPx by remember { mutableFloatStateOf(0f) }
fun sync() {
if (isRefreshing) return
scope.launch {
isRefreshing = true
val result = coroutineScope {
val deferred = async {
sendToWatchAwait(context, "/updateCards", cardsSyncJson(db.getCards()))
}
delay(750)
deferred.await()
}
isRefreshing = false
Toast.makeText(
context,
context.getString(if (result) R.string.sync_is_successful else R.string.sync_is_fail),
Toast.LENGTH_SHORT
).show()
}
}
fun deleteCard(card: CardInfo) {
val index = cards.indexOf(card)
if (index < 0) return
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
cards.removeAt(index)
db.removeCard(card.id)
db.updatePositions(cards.map { it.id })
autoSyncCards(context, db)
Toast.makeText(context, context.getString(R.string.card_deleted), Toast.LENGTH_SHORT).show()
}
pendingDelete?.let { card ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.ask_do_delete)) },
text = { Text(card.name) },
confirmButton = {
TextButton(onClick = {
deleteCard(card)
pendingDelete = null
}) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.no)) }
}
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.surfaceContainer
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { boxCoords = it }
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
) {
Text(
stringResource(R.string.app_name),
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = 12.dp, bottom = 8.dp),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground
)
AnimatedVisibility(
visible = draggingId != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Box(
modifier = Modifier.onGloballyPositioned { coords ->
boxCoords?.let {
zoneBottomPx = it.localPositionOf(
coords,
Offset(0f, coords.size.height.toFloat())
).y
}
}
) {
DeleteZone(hover = overDelete)
}
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { sync() },
modifier = Modifier.fillMaxSize()
) {
if (cards.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.add_card_to_get_started),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coords ->
boxCoords?.let { listTopPx = it.localPositionOf(coords, Offset.Zero).y }
},
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(cards, key = { it.id }) { card ->
val isDragging = card.id == draggingId
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value != SwipeToDismissBoxValue.Settled) {
pendingDelete = card
}
false
}
)
val dragModifier = Modifier.pointerInput(card.id) {
detectDragGesturesAfterLongPress(
onDragStart = {
val item = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == card.id } ?: return@detectDragGesturesAfterLongPress
draggingId = card.id
dragCard = card
dragHeightPx = item.size.toFloat()
dragOffsetY = listTopPx + item.offset
overDelete = false
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragEnd = {
val dropped = dragCard
val deleteIt = overDelete
draggingId = null
dragCard = null
overDelete = false
if (deleteIt && dropped != null) {
pendingDelete = dropped
} else {
db.updatePositions(cards.map { it.id })
autoSyncCards(context, db)
}
},
onDragCancel = {
draggingId = null
dragCard = null
overDelete = false
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
val center = dragOffsetY + dragHeightPx / 2f
val newOver = zoneBottomPx > 0f && center < zoneBottomPx
if (newOver && !overDelete) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
overDelete = newOver
if (overDelete) return@detectDragGesturesAfterLongPress
val localCenter = (center - listTopPx).toInt()
val from = cards.indexOfFirst { it.id == card.id }
val target = listState.layoutInfo.visibleItemsInfo.firstOrNull {
it.key != card.id && localCenter in it.offset..(it.offset + it.size)
}
if (from >= 0 && target != null) {
val to = cards.indexOfFirst { it.id == target.key }
if (to in cards.indices) {
cards.add(to, cards.removeAt(from))
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
}
)
}
SwipeToDismissBox(
state = dismissState,
modifier = Modifier
.animateItem()
.alpha(if (isDragging) 0f else 1f),
backgroundContent = { SwipeDeleteBackground(dismissState.dismissDirection) }
) {
CardRow(
card = card,
onClick = { onOpenCard(card) },
dragModifier = dragModifier
)
}
}
}
}
}
}
val floating = dragCard
if (floating != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
) {
FloatingCard(floating)
}
}
if (fabExpanded) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.32f))
.pointerInput(Unit) { detectTapGestures { fabExpanded = false } }
)
}
SpeedDial(
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding(),
expanded = fabExpanded,
onToggle = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
fabExpanded = !fabExpanded
},
onAdd = {
fabExpanded = false
onAddCard()
},
onBackup = {
fabExpanded = false
onOpenBackup()
},
onSource = {
fabExpanded = false
uriHandler.openUri("https://github.com/omni-devel/OmniCards")
}
)
}
}
}
@Composable
private fun SpeedDial(
modifier: Modifier,
expanded: Boolean,
onToggle: () -> Unit,
onAdd: () -> Unit,
onBackup: () -> Unit,
onSource: () -> Unit,
) {
val rotation by animateFloatAsState(if (expanded) 45f else 0f, label = "fabRotation")
Column(
modifier = modifier.padding(16.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
SpeedDialAction(expanded, 80, Icons.Filled.Code, stringResource(R.string.source_code), onSource)
SpeedDialAction(expanded, 40, Icons.Filled.Backup, stringResource(R.string.backup), onBackup)
SpeedDialAction(expanded, 0, Icons.Filled.Add, stringResource(R.string.do_add_card), onAdd)
FloatingActionButton(onClick = onToggle) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.rotate(rotation))
}
}
}
@Composable
private fun SpeedDialAction(
expanded: Boolean,
delayMs: Int,
icon: ImageVector,
label: String,
onClick: () -> Unit,
) {
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(delayMillis = delayMs)) + scaleIn(tween(delayMillis = delayMs)),
exit = fadeOut(tween(120)) + scaleOut(tween(120))
) {
ExtendedFloatingActionButton(
onClick = onClick,
icon = { Icon(icon, contentDescription = null) },
text = { Text(label) },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
@Composable
private fun FloatingCard(card: CardInfo) {
val palette = cardPalette(card)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = palette.container,
shadowElevation = 10.dp,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CardIcon(card = card, palette = palette)
Text(
card.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
color = palette.onContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun DeleteZone(hover: Boolean) {
val color by animateColorAsState(
targetValue = if (hover) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
label = "deleteZoneColor"
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.height(76.dp)
.background(
if (hover) MaterialTheme.colorScheme.error.copy(alpha = 0.14f) else Color.Transparent,
RoundedCornerShape(22.dp)
)
.border(2.dp, color, RoundedCornerShape(22.dp)),
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = color)
Text(stringResource(R.string.delete), color = color, style = MaterialTheme.typography.titleMedium)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CardRow(card: CardInfo, onClick: () -> Unit, dragModifier: Modifier) {
val palette = cardPalette(card)
Surface(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.then(dragModifier),
shape = MaterialTheme.shapes.large,
color = palette.container
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CardIcon(card = card, palette = palette)
Text(
card.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
color = palette.onContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun SwipeDeleteBackground(direction: SwipeToDismissBoxValue) {
val arrangement = when (direction) {
SwipeToDismissBoxValue.StartToEnd -> Arrangement.Start
SwipeToDismissBoxValue.EndToStart -> Arrangement.End
SwipeToDismissBoxValue.Settled -> Arrangement.Center
}
Surface(
modifier = Modifier.fillMaxSize(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalArrangement = arrangement,
verticalAlignment = Alignment.CenterVertically
) {
if (direction != SwipeToDismissBoxValue.Settled) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer)
}
}
}
}
@@ -0,0 +1,96 @@
package ru.omni_devel.cards
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import kotlin.math.hypot
@Composable
fun ColorRevealBox(
targetScheme: ColorScheme,
revealKey: Any?,
content: @Composable () -> Unit,
) {
val gLayer = rememberGraphicsLayer()
var appliedScheme by remember { mutableStateOf(targetScheme) }
var overlay by remember { mutableStateOf<ImageBitmap?>(null) }
var boxSize by remember { mutableStateOf(IntSize.Zero) }
var initialized by remember { mutableStateOf(false) }
val radius = remember { Animatable(0f) }
LaunchedEffect(revealKey) {
if (!initialized) {
initialized = true
appliedScheme = targetScheme
return@LaunchedEffect
}
overlay = runCatching { gLayer.toImageBitmap() }.getOrNull()
appliedScheme = targetScheme
if (overlay != null && boxSize != IntSize.Zero) {
val max = hypot(boxSize.width.toDouble(), boxSize.height.toDouble()).toFloat()
radius.snapTo(0f)
radius.animateTo(max, tween(500))
}
overlay = null
}
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { boxSize = it }
) {
Box(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
gLayer.record { this@drawWithContent.drawContent() }
drawLayer(gLayer)
}
.background(appliedScheme.surfaceContainer)
) {
MaterialTheme(colorScheme = appliedScheme) { content() }
}
val ov = overlay
if (ov != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val origin = Offset(size.width, 0f)
val canvas = drawContext.canvas.nativeCanvas
val checkpoint = canvas.saveLayer(null, null)
drawImage(ov)
drawCircle(
color = Color.Black,
radius = radius.value,
center = origin,
blendMode = BlendMode.Clear
)
canvas.restoreToCount(checkpoint)
}
}
}
}
@@ -6,65 +6,84 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import com.google.zxing.BarcodeFormat
class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?) : SQLiteOpenHelper(context, "omni_cards", factory, 2) {
class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?) : SQLiteOpenHelper(context, "omni_cards", factory, 4) {
override fun onCreate(db: SQLiteDatabase?) {
db!!.execSQL("CREATE TABLE IF NOT EXISTS cards (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, codeType TEXT, codeValue TEXT, color TEXT)")
db!!.execSQL("CREATE TABLE IF NOT EXISTS cards (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, codeType TEXT, codeValue TEXT, color TEXT, position INTEGER DEFAULT 0, iconPath TEXT, monochrome INTEGER DEFAULT 0)")
}
override fun onUpgrade(
db: SQLiteDatabase?,
oldVersion: Int,
p2: Int
) {
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, p2: Int) {
if (oldVersion < 2) {
db!!.execSQL("ALTER TABLE cards ADD COLUMN color TEXT")
}
if (oldVersion < 3) {
db!!.execSQL("ALTER TABLE cards ADD COLUMN position INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE cards ADD COLUMN iconPath TEXT")
db.execSQL("UPDATE cards SET position = id")
}
if (oldVersion < 4) {
db!!.execSQL("ALTER TABLE cards ADD COLUMN monochrome INTEGER DEFAULT 0")
}
}
fun addCard(name: String, codeValue: String, codeType: BarcodeFormat, color: String?) {
val values = ContentValues()
private fun nextPosition(db: SQLiteDatabase): Int {
val cursor = db.rawQuery("SELECT COALESCE(MAX(position), -1) + 1 FROM cards", null)
cursor.moveToFirst()
val next = cursor.getInt(0)
cursor.close()
return next
}
fun addCard(name: String, codeValue: String, codeType: BarcodeFormat, color: String?, iconPath: String?, monochrome: Boolean) {
val db = this.writableDatabase
val values = ContentValues()
values.put("name", name)
values.put("codeValue", codeValue)
values.put("codeType", codeType.name)
values.put("color", color)
val db = this.writableDatabase
values.put("iconPath", iconPath)
values.put("monochrome", if (monochrome) 1 else 0)
values.put("position", nextPosition(db))
db.insert("cards", null, values)
db.close()
}
fun editCard(id: Int, name: String, codeValue: String, codeType: BarcodeFormat, color: String?) {
fun editCard(id: Int, name: String, codeValue: String, codeType: BarcodeFormat, color: String?, iconPath: String?, monochrome: Boolean) {
val values = ContentValues()
values.put("name", name)
values.put("codeValue", codeValue)
values.put("codeType", codeType.name)
values.put("color", color)
values.put("iconPath", iconPath)
values.put("monochrome", if (monochrome) 1 else 0)
val db = this.writableDatabase
db.update("cards", values, "id = ?", arrayOf(id.toString()))
db.close()
}
private fun readCard(cursor: android.database.Cursor): CardInfo {
return CardInfo(
id = cursor.getInt(cursor.getColumnIndexOrThrow("id")),
name = cursor.getString(cursor.getColumnIndexOrThrow("name")),
codeType = BarcodeFormat.valueOf(cursor.getString(cursor.getColumnIndexOrThrow("codeType"))),
codeValue = cursor.getString(cursor.getColumnIndexOrThrow("codeValue")),
color = cursor.getString(cursor.getColumnIndexOrThrow("color")),
position = cursor.getInt(cursor.getColumnIndexOrThrow("position")),
iconPath = cursor.getString(cursor.getColumnIndexOrThrow("iconPath")),
monochrome = cursor.getInt(cursor.getColumnIndexOrThrow("monochrome")) == 1,
)
}
fun getCards(): List<CardInfo> {
val db = this.readableDatabase
val cursor = db.rawQuery("SELECT * FROM cards", null)
val cursor = db.rawQuery("SELECT * FROM cards ORDER BY position ASC, id ASC", null)
val cards = mutableListOf<CardInfo>()
while (cursor.moveToNext()) {
cards.add(CardInfo(
id = cursor.getInt(cursor.getColumnIndexOrThrow("id")),
name = cursor.getString(cursor.getColumnIndexOrThrow("name")),
codeType = BarcodeFormat.valueOf(cursor.getString(cursor.getColumnIndexOrThrow("codeType"))),
codeValue = cursor.getString(cursor.getColumnIndexOrThrow("codeValue")),
color = cursor.getString(cursor.getColumnIndexOrThrow("color"))
))
cards.add(readCard(cursor))
}
cursor.close()
@@ -75,7 +94,6 @@ class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?)
fun getCard(id: Int): CardInfo? {
val db = this.readableDatabase
val cursor = db.rawQuery("SELECT * FROM cards WHERE id = ?", arrayOf(id.toString()))
if (!cursor.moveToFirst()) {
@@ -85,14 +103,7 @@ class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?)
return null
}
val cardInfo = CardInfo(
id = cursor.getInt(cursor.getColumnIndexOrThrow("id")),
name = cursor.getString(cursor.getColumnIndexOrThrow("name")),
codeType = BarcodeFormat.valueOf(cursor.getString(cursor.getColumnIndexOrThrow("codeType"))),
codeValue = cursor.getString(cursor.getColumnIndexOrThrow("codeValue")),
color = cursor.getString(cursor.getColumnIndexOrThrow("color"))
)
val cardInfo = readCard(cursor)
cursor.close()
db.close()
@@ -101,29 +112,45 @@ class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?)
fun removeCard(id: Int) {
val db = this.writableDatabase
db.delete("cards", "id = ?", arrayOf(id.toString()))
db.close()
}
fun updatePositions(orderedIds: List<Int>) {
val db = this.writableDatabase
db.beginTransaction()
try {
orderedIds.forEachIndexed { index, id ->
val values = ContentValues()
values.put("position", index)
db.update("cards", values, "id = ?", arrayOf(id.toString()))
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
db.close()
}
}
fun clearDatabase() {
val db = this.writableDatabase
db.execSQL("DELETE FROM cards")
db.close()
}
fun addCards(cards: List<CardInfo>) {
val db = this.writableDatabase
for (card in cards) {
cards.forEachIndexed { index, card ->
val values = ContentValues()
values.put("id", card.id)
values.put("name", card.name)
values.put("codeValue", card.codeValue)
values.put("codeType", card.codeType.name)
values.put("color", card.color)
values.put("iconPath", card.iconPath)
values.put("position", index)
values.put("monochrome", if (card.monochrome) 1 else 0)
db.insert("cards", null, values)
}
@@ -1,317 +0,0 @@
package ru.omni_devel.cards
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import com.github.skydoves.colorpicker.compose.ColorEnvelope
import com.github.skydoves.colorpicker.compose.HsvColorPicker
import com.github.skydoves.colorpicker.compose.rememberColorPickerController
import com.google.zxing.BarcodeFormat
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import ru.omni_devel.cards.ui.theme.OmniCardsTheme
class EditCardActivity : ComponentActivity() {
private lateinit var db: DbHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
db = DbHelper(this, null)
val cardId = intent.getIntExtra("cardId", -1)
val cardInfo = db.getCard(cardId)
setContent {
OmniCardsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
EditCardPage(cardInfo, innerPadding, getDbFun = {
return@EditCardPage db
})
}
}
}
}
}
@Composable
fun CardEditable(
cardName: String,
onCardNameChange: (String) -> Unit,
codeValue: String,
onCodeValueChange: (String) -> Unit,
codeType: BarcodeFormat,
onCodeTypeChange: (BarcodeFormat) -> Unit,
color: String?,
onColorChange: (String?) -> Unit
) {
val context = LocalContext.current
var isCodeTypeDropdownExpanded by rememberSaveable { mutableStateOf(false) }
var isColorPickerExpanded by rememberSaveable { mutableStateOf(false) }
var isColorEnabled by rememberSaveable { mutableStateOf(color != null) }
val colorPickerController = rememberColorPickerController()
val fieldModifier = Modifier.fillMaxWidth()
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract()
) { result ->
if (result.contents != null) {
onCodeValueChange(result.contents)
result.formatName?.let { formatName ->
try {
onCodeTypeChange(BarcodeFormat.valueOf(formatName))
} catch (e: IllegalArgumentException) {}
}
}
}
OutlinedTextField(
value = cardName,
onValueChange = onCardNameChange,
modifier = fieldModifier,
label = { Text(stringResource(R.string.card_name)) }
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = codeValue,
onValueChange = onCodeValueChange,
modifier = Modifier.weight(1f),
label = { Text(stringResource(R.string.card_value)) }
)
Button(
modifier = Modifier.align(Alignment.CenterVertically),
onClick = {
scanLauncher.launch(
ScanOptions().apply {
setPrompt(context.getString(R.string.scan_your_card))
setBeepEnabled(true)
setOrientationLocked(false)
}
)
}
) {
Text(stringResource(R.string.do_scan_card))
}
}
Box(
modifier = fieldModifier,
) {
OutlinedTextField(
value = codeType.name,
onValueChange = {},
modifier = fieldModifier,
label = { Text(stringResource(R.string.card_code_type)) },
readOnly = true,
)
Box(
modifier = fieldModifier
.matchParentSize()
.clickable { isCodeTypeDropdownExpanded = !isCodeTypeDropdownExpanded }
)
DropdownMenu(
expanded = isCodeTypeDropdownExpanded,
onDismissRequest = {
isCodeTypeDropdownExpanded = false
},
modifier = fieldModifier
) {
BarcodeFormat.entries.forEach { format ->
DropdownMenuItem(
text = { Text(format.name) },
onClick = {
onCodeTypeChange(format)
isCodeTypeDropdownExpanded = false
}
)
}
}
}
Row(
modifier = fieldModifier,
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(
enabled = isColorEnabled,
onClick = {
isColorPickerExpanded = !isColorPickerExpanded
}
) {
Text(stringResource(R.string.do_choose_color))
}
Switch(
checked = isColorEnabled,
onCheckedChange = {
isColorEnabled = it
if (!isColorEnabled) {
onColorChange(null)
}
}
)
}
if (isColorPickerExpanded && isColorEnabled) {
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.height(450.dp)
.padding(10.dp),
initialColor = color?.let { Color(it.toColorInt()) },
controller = colorPickerController,
onColorChanged = { colorEnvelope: ColorEnvelope ->
onColorChange("#${colorEnvelope.hexCode.substring(2)}")
}
)
}
}
@Composable
fun AddCard(innerPadding: PaddingValues, getDbFun: () -> DbHelper) {
var cardName by rememberSaveable { mutableStateOf("") }
var codeValue by rememberSaveable { mutableStateOf("") }
var codeType by rememberSaveable { mutableStateOf(BarcodeFormat.QR_CODE) }
var color by rememberSaveable { mutableStateOf<String?>(null) }
val context = LocalActivity.current
Page(
stringResource(R.string.new_card_title),
innerPadding
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CardEditable(
cardName = cardName,
onCardNameChange = { cardName = it },
codeValue = codeValue,
onCodeValueChange = { codeValue = it },
codeType = codeType,
onCodeTypeChange = { codeType = it },
color = color,
onColorChange = { color = it }
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
cardName = cardName.trim()
val err = checkCardData(cardName, codeValue)
if (err == null) {
getDbFun().addCard(cardName, codeValue, codeType, color)
context!!.finish()
} else {
Toast.makeText(context, context!!.getString(err), Toast.LENGTH_SHORT).show()
}
}
) {
Text(stringResource(R.string.do_save))
}
}
}
}
@Composable
fun EditCard(cardInfo: CardInfo, innerPadding: PaddingValues, getDbFun: () -> DbHelper) {
var cardName by rememberSaveable { mutableStateOf(cardInfo.name) }
var codeValue by rememberSaveable { mutableStateOf(cardInfo.codeValue) }
var codeType by rememberSaveable { mutableStateOf(cardInfo.codeType) }
var color by rememberSaveable { mutableStateOf(cardInfo.color) }
val context = LocalActivity.current
Page(
stringResource(R.string.editing_card_title, cardInfo.id),
innerPadding
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CardEditable(
cardName = cardName,
onCardNameChange = { cardName = it },
codeValue = codeValue,
onCodeValueChange = { codeValue = it },
codeType = codeType,
onCodeTypeChange = { codeType = it },
color = color,
onColorChange = { color = it }
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
cardName = cardName.trim()
val err = checkCardData(cardName, codeValue)
if (err == null) {
getDbFun().editCard(cardInfo.id, cardName, codeValue, codeType, color)
context!!.finish()
} else {
Toast.makeText(context, context!!.getString(err), Toast.LENGTH_SHORT).show()
}
}
) {
Text(stringResource(R.string.do_save))
}
}
}
}
@Composable
fun EditCardPage(cardInfo: CardInfo?, innerPadding: PaddingValues, getDbFun: () -> DbHelper) {
if (cardInfo == null) {
AddCard(innerPadding, getDbFun)
} else {
EditCard(cardInfo, innerPadding, getDbFun)
}
}
@@ -0,0 +1,653 @@
package ru.omni_devel.cards
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import com.google.zxing.BarcodeFormat
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.materialkolor.rememberDynamicColorScheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private fun hex(color: Color) = "#%08X".format(color.toArgb())
private val writableFormats = BarcodeFormat.entries.filterNot {
it == BarcodeFormat.MAXICODE ||
it == BarcodeFormat.RSS_14 ||
it == BarcodeFormat.RSS_EXPANDED ||
it == BarcodeFormat.UPC_EAN_EXTENSION
}
private fun sampleValue(format: BarcodeFormat): String = when (format) {
BarcodeFormat.EAN_13 -> "590123412345"
BarcodeFormat.EAN_8 -> "1234567"
BarcodeFormat.UPC_A -> "12345678901"
BarcodeFormat.UPC_E -> "01234565"
BarcodeFormat.ITF -> "1234567890"
BarcodeFormat.CODABAR -> "A12345A"
else -> "madebyfimkov"
}
@Composable
fun EditCardScreen(
db: DbHelper,
existing: CardInfo?,
onBack: () -> Unit,
onSaved: () -> Unit,
) {
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val systemScheme = MaterialTheme.colorScheme
var name by rememberSaveable { mutableStateOf(existing?.name ?: "") }
var codeValue by rememberSaveable { mutableStateOf(existing?.codeValue ?: "") }
var codeType by remember { mutableStateOf(existing?.codeType ?: BarcodeFormat.QR_CODE) }
var color by remember { mutableStateOf(existing?.color) }
var iconPath by remember { mutableStateOf(existing?.iconPath) }
var monochrome by remember { mutableStateOf(existing?.monochrome ?: false) }
var showIconSheet by remember { mutableStateOf(false) }
var showCodeTypePicker by remember { mutableStateOf(false) }
var colorMenu by remember { mutableStateOf(false) }
val previewCard = CardInfo(0, name, codeType, codeValue, color, 0, iconPath, monochrome)
val logoArgb = remember(iconPath) { extractLogoColor(iconPath) }
val swatches = buildList {
add(systemScheme.primary)
add(systemScheme.secondary)
add(systemScheme.tertiary)
logoArgb?.let { add(Color(it)) }
}
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
codeValue = result.contents
result.formatName?.let { fmt ->
runCatching { codeType = BarcodeFormat.valueOf(fmt) }
}
}
}
val seed = color?.let { runCatching { Color(it.toColorInt()) }.getOrNull() }
val generated = rememberDynamicColorScheme(seed ?: systemScheme.primary, isSystemInDarkTheme(), isAmoled = false)
val themed = if (seed != null) generated else systemScheme
ColorRevealBox(targetScheme = themed, revealKey = color) {
val scheme = MaterialTheme.colorScheme
val palette = cardPalette(previewCard)
if (showCodeTypePicker) {
BackHandler { showCodeTypePicker = false }
CodeTypePickerScreen(
current = codeType,
onPick = {
codeType = it
showCodeTypePicker = false
},
onBack = { showCodeTypePicker = false }
)
return@ColorRevealBox
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(scheme.surfaceContainer)
.verticalScroll(rememberScrollState())
.statusBarsPadding()
.padding(bottom = 32.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
Text(
stringResource(if (existing == null) R.string.new_card_title else R.string.do_edit_card),
style = MaterialTheme.typography.titleLarge
)
}
Column(
modifier = Modifier.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(scheme.surfaceContainerHigh, RoundedCornerShape(20.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CardIcon(card = previewCard, palette = palette, sizeDp = 52, cornerDp = 16)
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.icon_label), style = MaterialTheme.typography.titleSmall)
Text(
stringResource(if (iconPath == null) R.string.icon_auto_desc else R.string.icon_custom_desc),
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant
)
}
OutlinedButton(onClick = { showIconSheet = true }) {
Text(stringResource(R.string.icon_change))
}
}
if (iconPath != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(scheme.surfaceContainerHigh, RoundedCornerShape(18.dp))
.padding(start = 18.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.icon_monochrome), style = MaterialTheme.typography.bodyLarge)
Switch(
checked = monochrome,
onCheckedChange = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
monochrome = it
}
)
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.card_name)) },
singleLine = true
)
OutlinedTextField(
value = codeValue,
onValueChange = { codeValue = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.card_value)) },
singleLine = true,
trailingIcon = {
IconButton(onClick = {
scanLauncher.launch(
ScanOptions().apply {
setPrompt("")
setBeepEnabled(true)
setOrientationLocked(false)
setCaptureActivity(ScannerActivity::class.java)
}
)
}) {
Icon(Icons.Filled.QrCodeScanner, contentDescription = stringResource(R.string.do_scan_card))
}
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.background(scheme.surfaceContainerHigh, RoundedCornerShape(18.dp))
.clickable { showCodeTypePicker = true }
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.card_code_type),
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant
)
Text(codeType.name, style = MaterialTheme.typography.titleMedium)
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = scheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
Box(
modifier = Modifier
.size(28.dp)
.clip(RoundedCornerShape(8.dp))
.background(
color?.let { runCatching { Color(it.toColorInt()) }.getOrNull() }
?: scheme.outlineVariant
)
.clickable { colorMenu = true }
)
DropdownMenu(expanded = colorMenu, onDismissRequest = { colorMenu = false }) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
swatches.forEach { swatch ->
val swatchHex = hex(swatch)
val selected = color?.equals(swatchHex, ignoreCase = true) == true
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(swatch)
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) scheme.onSurface else scheme.outlineVariant,
shape = CircleShape
)
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
color = swatchHex
colorMenu = false
},
contentAlignment = Alignment.Center
) {
if (selected) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = if (swatch.luminance() > 0.5f) Color.Black else Color.White,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
Spacer(Modifier.width(12.dp))
Text(stringResource(R.string.color_card), style = MaterialTheme.typography.bodyLarge)
}
Switch(
checked = color != null,
onCheckedChange = { enabled ->
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
color = if (enabled) hex(swatches.first()) else null
}
)
}
Button(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
name = name.trim()
val err = checkCardData(name, codeValue)
if (err != null) {
Toast.makeText(context, context.getString(err), Toast.LENGTH_SHORT).show()
return@Button
}
if (existing == null) {
db.addCard(name, codeValue, codeType, color, iconPath, monochrome)
} else {
db.editCard(existing.id, name, codeValue, codeType, color, iconPath, monochrome)
}
autoSyncCards(context, db)
onSaved()
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Text(stringResource(R.string.do_save), style = MaterialTheme.typography.titleMedium)
}
}
}
if (showIconSheet) {
IconPickerSheet(
onDismiss = { showIconSheet = false },
onPick = { path, brandName ->
iconPath = path
if (brandName != null && name.isBlank()) name = brandName
showIconSheet = false
}
)
}
}
}
@Composable
private fun CodeTypePickerScreen(
current: BarcodeFormat,
onPick: (BarcodeFormat) -> Unit,
onBack: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
Column(
modifier = Modifier
.fillMaxSize()
.background(scheme.surfaceContainer)
.statusBarsPadding()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
Text(stringResource(R.string.card_code_type), style = MaterialTheme.typography.titleLarge)
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
writableFormats.forEach { fmt ->
val selected = fmt == current
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (selected) scheme.primaryContainer else scheme.surfaceContainerHigh,
RoundedCornerShape(18.dp)
)
.clickable { onPick(fmt) }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.width(104.dp),
contentAlignment = Alignment.Center
) {
StyledCodePreview(
value = sampleValue(fmt),
format = fmt,
background = if (selected) scheme.onPrimaryContainer else scheme.primaryContainer,
module = if (selected) scheme.primaryContainer else scheme.onPrimaryContainer,
finder = if (selected) scheme.primaryContainer else scheme.primary
)
}
Spacer(Modifier.width(14.dp))
Text(
fmt.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
color = if (selected) scheme.onPrimaryContainer else scheme.onSurface
)
if (selected) {
Icon(Icons.Filled.Check, contentDescription = null, tint = scheme.onPrimaryContainer)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun IconPickerSheet(
onDismiss: () -> Unit,
onPick: (String?, String?) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var query by remember { mutableStateOf("") }
var downloading by remember { mutableStateOf(false) }
val cache = remember { mutableStateMapOf<String, String?>() }
LaunchedEffect(Unit) {
brandCatalog.chunked(6).forEach { chunk ->
val results = chunk.map { brand ->
async(Dispatchers.IO) { brand.domain to downloadFaviconToCache(context, brand.domain) }
}.awaitAll()
results.forEach { (domain, path) ->
cache[domain] = path
}
}
}
val photoLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
scope.launch {
val path = withContext(Dispatchers.IO) { saveCustomIcon(context, uri) }
onPick(path, null)
}
}
}
val filtered = remember(query) {
if (query.isBlank()) brandCatalog
else brandCatalog.filter { it.name.contains(query, ignoreCase = true) }
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(stringResource(R.string.icon_label), style = MaterialTheme.typography.headlineSmall)
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.search_service)) },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
singleLine = true
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(
onClick = { onPick(null, null) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.QrCodeScanner, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.icon_auto))
}
OutlinedButton(
onClick = {
photoLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.PhotoLibrary, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.icon_from_gallery))
}
}
Box {
LazyVerticalGrid(
columns = GridCells.Fixed(4),
modifier = Modifier.heightIn(max = 340.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
items(filtered, key = { it.domain }) { brand ->
val loading = brand.domain !in cache
BrandTile(brand = brand, iconPath = cache[brand.domain], loading = loading, enabled = !downloading) {
val cached = cache[brand.domain]
scope.launch {
downloading = true
val cardPath = withContext(Dispatchers.IO) {
val src = cached ?: downloadFaviconToCache(context, brand.domain)
src?.let { copyToCardIcon(context, it) }
}
downloading = false
if (cardPath != null) {
onPick(cardPath, brand.name)
} else {
Toast.makeText(context, context.getString(R.string.icon_failed), Toast.LENGTH_SHORT).show()
}
}
}
}
}
if (downloading) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
}
@Composable
private fun BrandTile(brand: Brand, iconPath: String?, loading: Boolean, enabled: Boolean, onClick: () -> Unit) {
val bitmap = rememberIconBitmap(iconPath)
val transition = rememberInfiniteTransition(label = "shimmer")
val shimmerAlpha by transition.animateFloat(
initialValue = 0.3f,
targetValue = 0.65f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "alpha"
)
val tileColor = when {
loading -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = shimmerAlpha)
bitmap != null -> Color.White
else -> MaterialTheme.colorScheme.secondaryContainer
}
Column(
modifier = Modifier.clickable(enabled = enabled && !loading, onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(16.dp))
.background(tileColor),
contentAlignment = Alignment.Center
) {
when {
loading -> {}
bitmap != null -> Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
else -> Text(
brand.name.first().uppercase(),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(Modifier.height(4.dp))
Text(
brand.name,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -1,241 +1,117 @@
package ru.omni_devel.cards
import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
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.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import kotlinx.coroutines.launch
import com.google.zxing.BarcodeFormat
data class CardPalette(val container: Color, val onContainer: Color, val tile: Color, val onTile: Color)
@Composable
fun Page(name: String, innerPadding: PaddingValues, pages: List<NavigationPageData>? = null, content: @Composable () -> Unit) {
val activityContext = LocalActivity.current
fun cardPalette(card: CardInfo): CardPalette {
val scheme = MaterialTheme.colorScheme
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
BackHandler(
enabled = pages != null && drawerState.isOpen
) {
scope.launch {
drawerState.close()
}
val accent = card.color?.let {
runCatching { Color(it.toColorInt()) }.getOrNull()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
if (pages != null) {
ModalDrawerSheet(
modifier = Modifier.background(MaterialTheme.colorScheme.background).padding(8.dp)
) {
for (page in pages) {
NavigationDrawerItem(
label = { Text(page.name) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
if (page.pageClass != null) {
val intent = Intent(activityContext, page.pageClass)
activityContext!!.startActivity(intent)
} else if (page.onClick != null) {
page.onClick()
}
}
)
}
}
}
}
) {
Scaffold(
topBar = {
TopBar(name, innerPadding, if (pages != null) {
{
Text(
"",
fontSize = 24.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.clickable() {
scope.launch {
drawerState.open()
}
}
)
}
} else null)
}
) { padding ->
Box(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
Column() {
content()
}
}
}
}
}
@Composable
fun TopBar(text: String, innerPadding: PaddingValues, openMenuButton: (@Composable () -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(top = innerPadding.calculateTopPadding())
.padding(vertical = 12.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (openMenuButton != null) {
openMenuButton()
}
Text(
text,
modifier = Modifier,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
return if (accent != null) {
val onTile = if (accent.luminance() > 0.5f) Color.Black.copy(alpha = 0.8f) else Color.White
CardPalette(
container = accent.copy(alpha = 0.16f).compositeOver(scheme.surface),
onContainer = scheme.onSurface,
tile = accent,
onTile = onTile,
)
} else {
CardPalette(
container = scheme.surfaceContainerHigh,
onContainer = scheme.onSurface,
tile = scheme.secondaryContainer,
onTile = scheme.onSecondaryContainer,
)
}
}
@Composable
fun CardButton(cardInfo: CardInfo, onDeleteCard: (Int) -> Unit) {
val context = LocalContext.current
var menuExpanded by remember { mutableStateOf(false) }
private fun Color.compositeOver(background: Color): Color {
val a = alpha + background.alpha * (1f - alpha)
if (a == 0f) return Color.Transparent
val r = (red * alpha + background.red * background.alpha * (1f - alpha)) / a
val g = (green * alpha + background.green * background.alpha * (1f - alpha)) / a
val b = (blue * alpha + background.blue * background.alpha * (1f - alpha)) / a
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = {
val intent = Intent(context, ShowCardActivity::class.java)
intent.putExtra("cardId", cardInfo.id)
context.startActivity(intent)
},
onLongPress = {
menuExpanded = true
}
)
},
shape = ButtonDefaults.shape,
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 2.dp
) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(32.dp)
.background(
if (cardInfo.color == null) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.625f)
else Color(cardInfo.color.toColorInt()).copy(alpha = 0.625f)
)
)
return Color(r, g, b, a)
}
Text(
cardInfo.name,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.weight(1f),
textAlign = TextAlign.Start
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.do_edit_card)) },
onClick = {
menuExpanded = false
val intent = Intent(context, EditCardActivity::class.java)
intent.putExtra("cardId", cardInfo.id)
context.startActivity(intent)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.do_delete_card)) },
onClick = {
menuExpanded = false
onDeleteCard(cardInfo.id)
}
)
}
fun autoGlyph(codeType: BarcodeFormat): ImageVector {
return when (codeType) {
BarcodeFormat.QR_CODE, BarcodeFormat.DATA_MATRIX, BarcodeFormat.AZTEC, BarcodeFormat.PDF_417 -> Icons.Filled.QrCode2
else -> Icons.Filled.QrCodeScanner
}
}
@Composable
fun FullWidthButton(onClick: () -> Unit, content: @Composable () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onClick
fun rememberIconBitmap(path: String?): ImageBitmap? {
return remember(path) {
if (path == null) return@remember null
runCatching { BitmapFactory.decodeFile(path)?.asImageBitmap() }.getOrNull()
}
}
@Composable
fun CardIcon(card: CardInfo, palette: CardPalette, sizeDp: Int = 48, cornerDp: Int = 16) {
val bitmap = rememberIconBitmap(card.iconPath)
Box(
modifier = Modifier
.size(sizeDp.dp)
.clip(RoundedCornerShape(cornerDp.dp))
.background(if (bitmap != null) Color.White else palette.tile),
contentAlignment = Alignment.Center
) {
content()
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.size((sizeDp - 8).dp),
contentScale = ContentScale.Fit,
colorFilter = if (card.monochrome) {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
} else {
null
}
)
} else {
Icon(
imageVector = autoGlyph(card.codeType),
contentDescription = null,
tint = palette.onTile,
modifier = Modifier.size((sizeDp * 0.55f).dp)
)
}
}
}
@@ -1,62 +1,47 @@
package ru.omni_devel.cards
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import ru.omni_devel.cards.ui.theme.OmniCardsTheme
import kotlin.coroutines.cancellation.CancellationException
private const val ANIMATION_DELAY = 300L
sealed interface Screen {
object Cards : Screen
data class Show(val cardId: Int) : Screen
data class Edit(val cardId: Int?) : Screen
object Backup : Screen
}
class MainActivity : ComponentActivity() {
private lateinit var db: DbHelper
private val cards = mutableStateListOf<CardInfo>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -65,77 +50,154 @@ class MainActivity : ComponentActivity() {
setContent {
OmniCardsTheme {
Scaffold(
modifier = Modifier.fillMaxSize()
) { innerPadding ->
MainPage(innerPadding, cards, onDeleteCard = { cardId ->
db.removeCard(cardId)
cards.removeIf { it.id == cardId }
}, getDbFun = {
return@MainPage db
})
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface
) {
AppRoot(db)
}
}
}
}
override fun onResume() {
super.onResume()
cards.clear()
cards.addAll(db.getCards())
}
}
@Composable
fun MainPage(
innerPadding: PaddingValues,
cards: List<CardInfo>,
onDeleteCard: (Int) -> Unit,
getDbFun: () -> DbHelper
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
fun AppRoot(db: DbHelper) {
val cards = remember { mutableStateListOf<CardInfo>() }
val backStack = remember { mutableStateListOf<Screen>(Screen.Cards) }
val revealedCards = remember { mutableSetOf<Int>() }
Box(
modifier = Modifier.fillMaxSize()
) {
Page(
stringResource(R.string.app_name),
innerPadding,
pages = listOf(
NavigationPageData(stringResource(R.string.do_add_card), EditCardActivity::class.java),
NavigationPageData(stringResource(R.string.do_sync)) {
Toast.makeText(context, context.getString(R.string.sync_in_progress), Toast.LENGTH_SHORT).show()
fun reload() {
cards.clear()
cards.addAll(db.getCards())
}
sendToWatch(context, "/updateCards", Json.encodeToString(getDbFun().getCards())) { isSuccess ->
if (isSuccess) {
Toast.makeText(context, context.getString(R.string.sync_is_successful), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, context.getString(R.string.sync_is_fail), Toast.LENGTH_LONG).show()
}
LaunchedEffect(Unit) { reload() }
val current = backStack.last()
val previous = if (backStack.size > 1) backStack[backStack.size - 2] else null
fun navigate(screen: Screen) {
backStack.add(screen)
}
fun back() {
if (backStack.size > 1) {
val removed = backStack.removeAt(backStack.lastIndex)
if (removed is Screen.Show) revealedCards.remove(removed.cardId)
reload()
}
}
val backProgress = remember { Animatable(0f) }
var predictivePop by remember { mutableStateOf(false) }
LaunchedEffect(current) { predictivePop = false }
PredictiveBackHandler(enabled = backStack.size > 1) { progress ->
try {
progress.collect { event -> backProgress.snapTo(event.progress) }
predictivePop = true
back()
backProgress.snapTo(0f)
} catch (e: CancellationException) {
backProgress.animateTo(0f, tween(220))
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (backProgress.value > 0.001f && previous != null) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val s = 0.9f + 0.1f * backProgress.value
scaleX = s
scaleY = s
alpha = 0.5f + 0.5f * backProgress.value
}
},
NavigationPageData(stringResource(R.string.open_backup_menu), BackupActivity::class.java),
NavigationPageData(stringResource(R.string.source_code)) {
uriHandler.openUri("https://github.com/omni-devel/OmniCards")
},
)
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(bottom = 64.dp).background(Color.Transparent),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (cards.isEmpty()) {
Text(
stringResource(R.string.add_card_to_get_started)
)
} else {
for (card in cards) {
CardButton(card, onDeleteCard)
}
}
ScreenHost(previous, db, cards, revealedCards, onNavigate = { navigate(it) }, onBack = { back() })
}
}
AnimatedContent(
targetState = current,
transitionSpec = {
if (predictivePop) {
EnterTransition.None togetherWith ExitTransition.None
} else {
(fadeIn(tween(220)) + slideInHorizontally(tween(280)) { it / 6 }) togetherWith
(fadeOut(tween(180)) + slideOutHorizontally(tween(280)) { -it / 8 })
}
},
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val scale = 1f - 0.12f * backProgress.value
scaleX = scale
scaleY = scale
translationX = size.width * 0.9f * backProgress.value
alpha = 1f - 0.1f * backProgress.value
},
label = "screen"
) { screen ->
ScreenHost(screen, db, cards, revealedCards, onNavigate = { navigate(it) }, onBack = { back() })
}
}
}
@Composable
private fun ScreenHost(
screen: Screen,
db: DbHelper,
cards: SnapshotStateList<CardInfo>,
revealedCards: MutableSet<Int>,
onNavigate: (Screen) -> Unit,
onBack: () -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
when (screen) {
is Screen.Cards -> CardsScreen(
db = db,
cards = cards,
onOpenCard = { onNavigate(Screen.Show(it.id)) },
onAddCard = { onNavigate(Screen.Edit(null)) },
onOpenBackup = { onNavigate(Screen.Backup) },
)
is Screen.Show -> {
val card = cards.find { it.id == screen.cardId }
if (card == null) {
LaunchedEffect(screen) { onBack() }
} else {
val animateReveal = card.id !in revealedCards
LaunchedEffect(card.id) { revealedCards.add(card.id) }
ShowCardScreen(
card = card,
animateReveal = animateReveal,
onBack = onBack,
onEdit = { onNavigate(Screen.Edit(card.id)) },
onDeleted = {
db.removeCard(card.id)
autoSyncCards(context, db)
onBack()
},
)
}
}
is Screen.Edit -> EditCardScreen(
db = db,
existing = screen.cardId?.let { db.getCard(it) },
onBack = onBack,
onSaved = onBack,
)
is Screen.Backup -> BackupScreen(
db = db,
onBack = onBack,
)
}
}
@@ -1,9 +0,0 @@
package ru.omni_devel.cards
import android.app.Activity
data class NavigationPageData(
val name: String,
val pageClass: Class<out Activity>? = null,
val onClick: (() -> Unit)? = null
)
@@ -0,0 +1,462 @@
package ru.omni_devel.cards
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PathMeasure
import android.graphics.PointF
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.LinearInterpolator
import com.google.android.material.color.MaterialColors
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.ByteMatrix
import com.google.zxing.qrcode.encoder.Encoder
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
class ScanResultView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context, attrs) {
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val qrStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
private val qrFill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val cookiePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val checkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val bmpPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
private val pdfPaint = Paint().apply { isFilterBitmap = false }
private var frame: Bitmap? = null
private val frameMatrix = Matrix()
private val box = RectF()
private var corner = 0f
private var qrMatrix: ByteMatrix? = null
private var matrix2d: BitMatrix? = null
private var barcodeRow: BooleanArray? = null
private var pdfBitmap: Bitmap? = null
private var resultFormat: BarcodeFormat? = null
private var primary = 0xFFFFFFFF.toInt()
private var onPrimary = 0xFF000000.toInt()
private var primaryContainer = 0xFF335024.toInt()
private var onPrimaryContainer = 0xFFD7F5BC.toInt()
private var progress = 0f
private var animator: ValueAnimator? = null
private val perimeter = Path()
private val perimeterMeasure = PathMeasure()
private val traceDst = Path()
private val clipBox = Path()
private val clipReveal = Path()
private val checkPath = Path()
private val checkMeasure = PathMeasure()
private val checkDst = Path()
fun show(bitmap: Bitmap?, points: List<PointF>, scaleFactor: Int, value: String, format: BarcodeFormat, onDone: () -> Unit) {
primary = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimary, primary)
onPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimary, onPrimary)
primaryContainer = MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimaryContainer, primaryContainer)
onPrimaryContainer = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimaryContainer, onPrimaryContainer)
frame = bitmap
resultFormat = format
qrMatrix = null
matrix2d = null
barcodeRow = null
pdfBitmap = null
when {
format == BarcodeFormat.QR_CODE ->
qrMatrix = runCatching { Encoder.encode(value, ErrorCorrectionLevel.H).matrix }.getOrNull()
format == BarcodeFormat.DATA_MATRIX || format == BarcodeFormat.AZTEC ->
matrix2d = encodeMatrix(value, format)
format == BarcodeFormat.PDF_417 ->
pdfBitmap = encodeMatrix(value, format)?.let { matrixToBitmap(it, onPrimaryContainer) }
else ->
barcodeRow = runCatching {
val bm = MultiFormatWriter().encode(value, format, 0, 1)
BooleanArray(bm.width) { bm.get(it, 0) }
}.getOrNull()
}
visibility = VISIBLE
post {
computeGeometry(bitmap, points, scaleFactor.coerceAtLeast(1))
animator?.cancel()
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1900L
interpolator = LinearInterpolator()
addUpdateListener {
progress = it.animatedValue as Float
invalidate()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
onDone()
}
})
start()
}
}
}
private fun computeGeometry(bitmap: Bitmap?, points: List<PointF>, scaleFactor: Int) {
val vw = width.toFloat()
val vh = height.toFloat()
val mapped = ArrayList<PointF>(points.size)
if (bitmap != null && bitmap.width > 0 && bitmap.height > 0) {
val bw = bitmap.width.toFloat()
val bh = bitmap.height.toFloat()
val scale = max(vw / bw, vh / bh)
frameMatrix.reset()
frameMatrix.setScale(scale, scale)
frameMatrix.postTranslate((vw - bw * scale) / 2f, (vh - bh * scale) / 2f)
if (points.isNotEmpty()) {
val pts = FloatArray(points.size * 2)
points.forEachIndexed { i, p ->
pts[i * 2] = p.x / scaleFactor
pts[i * 2 + 1] = p.y / scaleFactor
}
frameMatrix.mapPoints(pts)
for (i in points.indices) mapped.add(PointF(pts[i * 2], pts[i * 2 + 1]))
}
}
if (mapped.isEmpty()) {
val s = min(vw, vh) * 0.6f
box.set(vw / 2 - s / 2, vh / 2 - s / 2, vw / 2 + s / 2, vh / 2 + s / 2)
} else {
var minX = Float.MAX_VALUE
var minY = Float.MAX_VALUE
var maxX = -Float.MAX_VALUE
var maxY = -Float.MAX_VALUE
mapped.forEach {
minX = min(minX, it.x); minY = min(minY, it.y)
maxX = max(maxX, it.x); maxY = max(maxY, it.y)
}
val w = maxX - minX
val h = maxY - minY
val cx = (minX + maxX) / 2f
val cy = (minY + maxY) / 2f
val bw2: Float
val bh2: Float
when (resultFormat) {
BarcodeFormat.QR_CODE -> {
val s = max(w, h) * 1.34f
bw2 = s; bh2 = s
}
BarcodeFormat.DATA_MATRIX, BarcodeFormat.AZTEC -> {
val s = max(w, h) * 1.12f
bw2 = s; bh2 = s
}
BarcodeFormat.PDF_417 -> {
bw2 = w * 1.10f
bh2 = max(h, w * 0.18f) * 1.15f
}
else -> {
bw2 = w * 1.30f
bh2 = max(h * 1.6f, w * 0.52f)
}
}
box.set(cx - bw2 / 2, cy - bh2 / 2, cx + bw2 / 2, cy + bh2 / 2)
}
val m = min(vw, vh) * 0.04f
if (box.left < m) box.left = m
if (box.top < m) box.top = m
if (box.right > vw - m) box.right = vw - m
if (box.bottom > vh - m) box.bottom = vh - m
corner = min(box.width(), box.height()) * 0.10f
buildPerimeter()
}
private fun buildPerimeter() {
val r = corner
val l = box.left
val t = box.top
val rt = box.right
val b = box.bottom
perimeter.reset()
perimeter.moveTo(l, b - r)
perimeter.lineTo(l, t + r)
perimeter.arcTo(l, t, l + 2 * r, t + 2 * r, 180f, 90f, false)
perimeter.lineTo(rt - r, t)
perimeter.arcTo(rt - 2 * r, t, rt, t + 2 * r, 270f, 90f, false)
perimeter.lineTo(rt, b - r)
perimeter.arcTo(rt - 2 * r, b - 2 * r, rt, b, 0f, 90f, false)
perimeter.lineTo(l + r, b)
perimeter.arcTo(l, b - 2 * r, l + 2 * r, b, 90f, 90f, false)
perimeter.close()
perimeterMeasure.setPath(perimeter, false)
}
private fun smooth(a: Float, b: Float, t: Float): Float {
val x = ((t - a) / (b - a)).coerceIn(0f, 1f)
return x * x * (3f - 2f * x)
}
override fun onDraw(canvas: Canvas) {
frame?.let { canvas.drawBitmap(it, frameMatrix, bmpPaint) }
if (box.isEmpty) return
val traceA = smooth(0f, 0.24f, progress)
val fillB = smooth(0.24f, 0.52f, progress)
val popC = smooth(0.78f, 0.90f, progress)
val checkC = smooth(0.88f, 1f, progress)
if (fillB > 0f) {
clipBox.reset()
clipBox.addRoundRect(box, corner, corner, Path.Direction.CW)
val diag = hypot(box.width().toDouble(), box.height().toDouble()).toFloat()
clipReveal.reset()
clipReveal.addCircle(box.right, box.top, fillB * diag * 1.05f, Path.Direction.CW)
canvas.save()
canvas.clipPath(clipBox)
canvas.clipPath(clipReveal)
bgPaint.color = primaryContainer
canvas.drawRect(box, bgPaint)
drawContent(canvas)
canvas.restore()
}
val len = perimeterMeasure.length
if (len > 0f && traceA > 0f) {
val half = len / 2f
borderPaint.color = primary
borderPaint.strokeWidth = min(box.width(), box.height()) * 0.028f
traceDst.reset()
perimeterMeasure.getSegment(0f, half * traceA, traceDst, true)
canvas.drawPath(traceDst, borderPaint)
traceDst.reset()
perimeterMeasure.getSegment(len - half * traceA, len, traceDst, true)
canvas.drawPath(traceDst, borderPaint)
}
if (popC > 0f) {
val ccx = box.centerX()
val ccy = box.centerY()
val baseR = min(box.width(), box.height()) * 0.17f * (0.7f + 0.3f * popC)
val overshoot = 1f + 0.12f * (1f - popC)
val r = baseR * overshoot
cookiePaint.color = primary
cookiePaint.alpha = (255 * popC).toInt()
canvas.drawPath(cookiePath(ccx, ccy, r, r * 0.12f, 6, progress * 0.4f), cookiePaint)
if (checkC > 0f) {
buildCheck(ccx, ccy, baseR)
checkMeasure.setPath(checkPath, false)
val cl = checkMeasure.length
checkDst.reset()
checkMeasure.getSegment(0f, cl * checkC, checkDst, true)
checkPaint.color = onPrimary
checkPaint.strokeWidth = baseR * 0.22f
canvas.drawPath(checkDst, checkPaint)
}
}
}
private fun drawContent(canvas: Canvas) {
when {
qrMatrix != null -> drawStyledQr(canvas)
matrix2d != null -> drawStyledMatrix(canvas)
barcodeRow != null -> drawStyledBarcode(canvas)
pdfBitmap != null -> drawPdf(canvas)
}
}
private fun matrixToBitmap(matrix: BitMatrix, onArgb: Int): Bitmap {
val rect = matrix.enclosingRectangle
val left = rect?.get(0) ?: 0
val top = rect?.get(1) ?: 0
val w = rect?.get(2) ?: matrix.width
val h = rect?.get(3) ?: matrix.height
val pixels = IntArray(w * h)
for (y in 0 until h) {
for (x in 0 until w) {
pixels[y * w + x] = if (matrix.get(left + x, top + y)) onArgb else 0
}
}
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bmp.setPixels(pixels, 0, w, 0, 0, w, h)
return bmp
}
private fun drawPdf(canvas: Canvas) {
val bmp = pdfBitmap ?: return
val availW = box.width() * 0.84f
val scale = availW / bmp.width
val dw = bmp.width * scale
val dh = bmp.height * scale
val dst = RectF(
box.centerX() - dw / 2f,
box.centerY() - dh / 2f,
box.centerX() + dw / 2f,
box.centerY() + dh / 2f
)
canvas.drawBitmap(bmp, null, dst, pdfPaint)
}
private fun drawStyledBarcode(canvas: Canvas) {
val row = barcodeRow ?: return
val n = row.size
if (n <= 0) return
val padX = box.width() * 0.08f
val usableW = box.width() - padX * 2f
val sx = usableW / n
val left0 = box.left + padX
val barH = box.height() * 0.5f
val cy = box.centerY()
qrFill.color = onPrimaryContainer
var x = 0
while (x < n) {
if (row[x]) {
var e = x
while (e + 1 < n && row[e + 1]) e++
val l = left0 + x * sx
val w = (e - x + 1) * sx
val r = min(w / 2f, barH / 2f)
canvas.drawRoundRect(l, cy - barH / 2f, l + w, cy + barH / 2f, r, r, qrFill)
x = e + 1
} else {
x++
}
}
}
private fun drawStyledQr(canvas: Canvas) {
val m = qrMatrix ?: return
drawModules(canvas, m.width) { x, y -> m.get(x, y).toInt() == 1 }
}
private fun drawStyledMatrix(canvas: Canvas) {
val m = matrix2d ?: return
val cols = m.width
val rows = m.height
if (cols <= 0 || rows <= 0) return
val pad = min(box.width(), box.height()) * 0.12f
val side = min(box.width(), box.height()) - pad * 2f
val cell = side / max(cols, rows)
val ox = box.centerX() - cols * cell / 2f
val oy = box.centerY() - rows * cell / 2f
val dot = cell * 0.86f
val round = dot * 0.32f
val off = (cell - dot) / 2f
qrFill.color = onPrimaryContainer
for (y in 0 until rows) {
for (x in 0 until cols) {
if (!m.get(x, y)) continue
val left = ox + x * cell + off
val top = oy + y * cell + off
canvas.drawRoundRect(left, top, left + dot, top + dot, round, round, qrFill)
}
}
}
private fun drawModules(canvas: Canvas, n: Int, on: (Int, Int) -> Boolean) {
if (n <= 0) return
val pad = min(box.width(), box.height()) * 0.12f
val side = min(box.width(), box.height()) - pad * 2f
val cell = side / n
val ox = box.centerX() - side / 2f
val oy = box.centerY() - side / 2f
fun cx(x: Int) = ox + (x + 0.5f) * cell
fun cy(y: Int) = oy + (y + 0.5f) * cell
val thick = cell * 0.82f
qrStroke.color = onPrimaryContainer
qrStroke.strokeWidth = thick
qrFill.color = onPrimaryContainer
val used = Array(n) { BooleanArray(n) }
for (y in 0 until n) {
var x = 0
while (x < n) {
if (on(x, y)) {
var e = x
while (e + 1 < n && on(e + 1, y)) e++
if (e > x) {
for (i in x..e) used[i][y] = true
canvas.drawLine(cx(x), cy(y), cx(e), cy(y), qrStroke)
}
x = e + 1
} else {
x++
}
}
}
for (x in 0 until n) {
var y = 0
while (y < n) {
if (on(x, y) && !used[x][y]) {
var e = y
while (e + 1 < n && on(x, e + 1) && !used[x][e + 1]) e++
if (e > y) {
for (i in y..e) used[x][i] = true
canvas.drawLine(cx(x), cy(y), cx(x), cy(e), qrStroke)
}
y = e + 1
} else {
y++
}
}
}
for (y in 0 until n) {
for (x in 0 until n) {
if (on(x, y) && !used[x][y]) canvas.drawCircle(cx(x), cy(y), thick * 0.5f, qrFill)
}
}
}
private fun buildCheck(cx: Float, cy: Float, r: Float) {
checkPath.reset()
checkPath.moveTo(cx - r * 0.42f, cy + r * 0.02f)
checkPath.lineTo(cx - r * 0.10f, cy + r * 0.34f)
checkPath.lineTo(cx + r * 0.46f, cy - r * 0.34f)
}
private fun cookiePath(cx: Float, cy: Float, baseR: Float, amp: Float, lobes: Int, rot: Float): Path {
val p = Path()
val steps = 120
for (i in 0..steps) {
val t = i.toFloat() / steps * (2f * Math.PI.toFloat())
val rr = baseR + amp * cos(lobes * t)
val a = t + rot
val x = cx + rr * cos(a)
val y = cy + rr * sin(a)
if (i == 0) p.moveTo(x, y) else p.lineTo(x, y)
}
p.close()
return p
}
}
@@ -0,0 +1,100 @@
package ru.omni_devel.cards
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.PointF
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.DynamicColors
import com.journeyapps.barcodescanner.BarcodeCallback
import com.journeyapps.barcodescanner.BarcodeResult
import com.journeyapps.barcodescanner.DecoratedBarcodeView
class ScannerActivity : AppCompatActivity() {
private lateinit var barcodeView: DecoratedBarcodeView
private lateinit var resultView: ScanResultView
private var torchOn = false
private var handled = false
private val cameraPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
startScanning()
} else {
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DynamicColors.applyToActivityIfAvailable(this)
setContentView(R.layout.activity_scanner)
barcodeView = findViewById(R.id.barcode_scanner)
resultView = findViewById(R.id.scan_result)
barcodeView.setStatusText("")
barcodeView.barcodeView.setMarginFraction(0.0)
barcodeView.viewFinder.visibility = View.GONE
findViewById<MaterialButton>(R.id.back_button).setOnClickListener { finish() }
val torch = findViewById<MaterialButton>(R.id.torch_button)
torch.setOnClickListener {
torchOn = !torchOn
if (torchOn) barcodeView.setTorchOn() else barcodeView.setTorchOff()
torch.setIconResource(if (torchOn) R.drawable.ic_flash_on else R.drawable.ic_flash_off)
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startScanning()
} else {
cameraPermission.launch(Manifest.permission.CAMERA)
}
}
private fun startScanning() {
barcodeView.decodeContinuous(object : BarcodeCallback {
override fun barcodeResult(result: BarcodeResult) {
if (handled) return
handled = true
barcodeView.pause()
val points = result.transformedResultPoints?.map { PointF(it.x, it.y) } ?: emptyList()
resultView.show(result.bitmap, points, result.bitmapScaleFactor, result.text ?: "", result.barcodeFormat) {
val intent = Intent()
intent.putExtra("SCAN_RESULT", result.text)
intent.putExtra("SCAN_RESULT_FORMAT", result.barcodeFormat.name)
setResult(RESULT_OK, intent)
finish()
}
}
})
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
barcodeView.resume()
}
}
override fun onResume() {
super.onResume()
if (!handled && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
barcodeView.resume()
}
}
override fun onPause() {
super.onPause()
barcodeView.pause()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return barcodeView.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
}
}
@@ -1,117 +0,0 @@
package ru.omni_devel.cards
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import ru.omni_devel.cards.ui.theme.OmniCardsTheme
class ShowCardActivity : ComponentActivity() {
private lateinit var db: DbHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
db = DbHelper(this, null)
val cardId = intent.getIntExtra("cardId", -1)
val cardInfo = db.getCard(cardId)
if (cardInfo == null) {
Toast.makeText(this, "Card is not exists", Toast.LENGTH_LONG).show()
finish()
return
}
setContent {
OmniCardsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
ShowCardPage(innerPadding, cardInfo)
}
}
}
}
override fun onResume() {
super.onResume()
setBrightness(1f)
}
override fun onPause() {
super.onPause()
setBrightness(WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE)
}
private fun setBrightness(level: Float) {
window.attributes.screenBrightness = level
}
}
@Composable
fun ShowCardPage(innerPadding: PaddingValues, cardInfo: CardInfo) {
val bitmap = remember(cardInfo) {
try {
generateCode(cardInfo.codeValue, cardInfo.codeType).asImageBitmap()
} catch (_: Exception) {
null
}
}
Page(cardInfo.name, innerPadding) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = cardInfo.codeValue,
modifier = Modifier
.padding(bottom = 8.dp)
.fillMaxWidth()
.weight(1f, fill = false),
contentScale = ContentScale.Fit
)
Text(
cardInfo.codeValue,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
} else {
Text(
stringResource(R.string.failed_to_generate_card_code),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@@ -0,0 +1,212 @@
package ru.omni_devel.cards
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import com.google.zxing.BarcodeFormat
import com.materialkolor.rememberDynamicColorScheme
@Composable
fun ShowCardScreen(
card: CardInfo,
animateReveal: Boolean = true,
onBack: () -> Unit,
onEdit: () -> Unit,
onDeleted: () -> Unit,
) {
val context = LocalContext.current
val activity = LocalActivity.current
val haptics = LocalHapticFeedback.current
var showDeleteConfirm by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
val window = activity?.window
window?.let {
it.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
onDispose {
window?.let {
it.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
it.attributes = it.attributes.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
}
}
val systemScheme = MaterialTheme.colorScheme
val seed = card.color?.let { runCatching { Color(it.toColorInt()) }.getOrNull() }
val generated = rememberDynamicColorScheme(seed ?: systemScheme.primary, isSystemInDarkTheme(), isAmoled = false)
val qrScheme = rememberDynamicColorScheme(seed ?: systemScheme.primary, false, isAmoled = false)
val themed = if (seed != null) generated else systemScheme
MaterialTheme(colorScheme = themed) {
val scheme = MaterialTheme.colorScheme
val palette = cardPalette(card)
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text(stringResource(R.string.ask_do_delete)) },
text = { Text(card.name) },
confirmButton = {
TextButton(onClick = {
showDeleteConfirm = false
onDeleted()
}) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.no)) }
}
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(scheme.surfaceContainer)
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = scheme.onSurface)
}
Text(
card.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleLarge,
color = scheme.onSurface
)
IconButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.do_edit_card), tint = scheme.onSurface)
}
IconButton(onClick = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
shareCodeImage(context, card)
}) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.do_share), tint = scheme.onSurface)
}
IconButton(onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
showDeleteConfirm = true
}) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.do_delete_card), tint = scheme.onSurface)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 28.dp, vertical = 16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CardIcon(card = card, palette = palette, sizeDp = 40, cornerDp = 13)
Spacer(Modifier.width(10.dp))
Text(
stringResource(R.string.loyalty_card),
style = MaterialTheme.typography.titleMedium,
color = scheme.onSurface
)
}
Spacer(Modifier.padding(10.dp))
val type = card.codeType
val isMatrix2d = type == BarcodeFormat.DATA_MATRIX || type == BarcodeFormat.AZTEC
if (type == BarcodeFormat.QR_CODE) {
StyledQrCode(
value = card.codeValue,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
background = qrScheme.primaryContainer,
module = qrScheme.onPrimaryContainer,
finder = qrScheme.primary,
decor = qrScheme.primary.copy(alpha = 0.30f),
animate = animateReveal
)
} else if (isMatrix2d) {
StyledMatrix(
value = card.codeValue,
format = type,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
background = qrScheme.primaryContainer,
module = qrScheme.onPrimaryContainer,
animate = animateReveal
)
} else if (type == BarcodeFormat.PDF_417) {
StyledPdf417(
value = card.codeValue,
modifier = Modifier.fillMaxWidth(),
background = qrScheme.primaryContainer,
module = qrScheme.onPrimaryContainer,
animate = animateReveal
)
} else {
StyledBarcode(
value = card.codeValue,
format = type,
modifier = Modifier.fillMaxWidth(),
background = qrScheme.primaryContainer,
module = qrScheme.onPrimaryContainer,
onBackground = qrScheme.onPrimaryContainer,
animate = animateReveal
)
}
}
}
}
}
}
@@ -0,0 +1,583 @@
package ru.omni_devel.cards
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import android.graphics.Bitmap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.CornerRounding
import androidx.graphics.shapes.RoundedPolygon
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
private val Cookie4Sided: RoundedPolygon by lazy { buildCookie4() }
private fun buildCookie4(): RoundedPolygon {
val cx = 0.5f
val cy = 0.5f
val motif = floatArrayOf(1.237f, 1.236f, 0.5f, 0.918f)
val motifRounding = listOf(CornerRounding(0.258f), CornerRounding(0.233f))
val reps = 4
val np = 2
val verts = FloatArray(np * reps * 2)
val rounds = ArrayList<CornerRounding>(np * reps)
for (idx in 0 until np * reps) {
val mp = idx % np
val ox = motif[mp * 2]
val oy = motif[mp * 2 + 1]
val a = (idx / np) * (360f / reps) * (PI.toFloat() / 180f)
val dx = ox - cx
val dy = oy - cy
verts[idx * 2] = dx * cos(a) - dy * sin(a) + cx
verts[idx * 2 + 1] = dx * sin(a) + dy * cos(a) + cy
rounds.add(motifRounding[mp])
}
return RoundedPolygon(
vertices = verts,
perVertexRounding = rounds,
centerX = cx,
centerY = cy
)
}
private fun RoundedPolygon.toComposePath(): Path {
val path = Path()
val cubics = this.cubics
if (cubics.isEmpty()) return path
val first = cubics.first()
path.moveTo(first.anchor0X, first.anchor0Y)
cubics.forEach { c ->
path.cubicTo(c.control0X, c.control0Y, c.control1X, c.control1Y, c.anchor1X, c.anchor1Y)
}
path.close()
return path
}
private fun hexCookiePath(cx: Float, cy: Float, baseRadius: Float, amplitude: Float, lobes: Int, rotation: Float): Path {
val path = Path()
val steps = 120
for (i in 0..steps) {
val t = i.toFloat() / steps * (2f * PI.toFloat())
val r = baseRadius + amplitude * cos(lobes * t)
val a = t + rotation
val x = cx + r * cos(a)
val y = cy + r * sin(a)
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
path.close()
return path
}
@Composable
fun StyledQrCode(
value: String,
modifier: Modifier,
background: Color,
module: Color,
finder: Color,
decor: Color,
animate: Boolean = true,
) {
val matrix = remember(value) {
runCatching {
Encoder.encode(
value,
ErrorCorrectionLevel.H,
mapOf(EncodeHintType.CHARACTER_SET to "UTF-8")
).matrix
}.getOrNull()
} ?: return
val progress = remember { Animatable(if (animate) 0f else 1f) }
LaunchedEffect(value) {
if (animate) {
progress.snapTo(0f)
progress.animateTo(1f, tween(durationMillis = 900, easing = FastOutSlowInEasing))
}
}
val infinite = rememberInfiniteTransition(label = "qrSpin")
val spin by infinite.animateFloat(
initialValue = 0f,
targetValue = 2f * PI.toFloat(),
animationSpec = infiniteRepeatable(tween(12000, easing = LinearEasing), RepeatMode.Restart),
label = "spin"
)
Canvas(modifier = modifier) {
val n = matrix.width
if (n <= 0) return@Canvas
val minDim = size.minDimension
val moduleSide = minDim * 0.72f
val cell = moduleSide / n
val cx0 = size.width / 2f
val cy0 = size.height / 2f
val originX = cx0 - moduleSide / 2f
val originY = cy0 - moduleSide / 2f
val p = progress.value
val cookiePath = Cookie4Sided.toComposePath()
val cb = cookiePath.getBounds()
val cookieTarget = minDim * 0.99f
val cookieScale = cookieTarget / max(cb.width, cb.height)
cookiePath.transform(
Matrix().apply {
translate(cx0, cy0)
scale(cookieScale, cookieScale)
translate(-cb.center.x, -cb.center.y)
}
)
drawPath(cookiePath, color = background)
fun inFinder(x: Int, y: Int): Boolean {
return (x < 7 && y < 7) || (x >= n - 7 && y < 7) || (x < 7 && y >= n - 7)
}
fun on(x: Int, y: Int): Boolean =
x in 0 until n && y in 0 until n && matrix.get(x, y).toInt() == 1 && !inFinder(x, y)
fun center(x: Int, y: Int) = Offset(originX + (x + 0.5f) * cell, originY + (y + 0.5f) * cell)
val mid = (n - 1) / 2f
val maxDist = hypot(mid.toDouble(), mid.toDouble()).toFloat().coerceAtLeast(1f)
val bandW = 0.35f
fun reveal(x: Int, y: Int): Float {
val d = hypot((x - mid).toDouble(), (y - mid).toDouble()).toFloat() / maxDist
return ((p - d * (1f - bandW)) / bandW).coerceIn(0f, 1f)
}
fun emptyCell(x: Int, y: Int): Boolean =
x in 0 until n && y in 0 until n && matrix.get(x, y).toInt() == 0 && !inFinder(x, y)
drawRuns(n, ::emptyCell, decor, cell * 0.24f, cell * 0.12f, ::reveal, ::center)
val thick = cell * 0.82f
drawRuns(n, ::on, module, thick, thick * 0.5f, ::reveal, ::center)
val fa = (p * 1.6f).coerceIn(0f, 1f)
if (fa <= 0f) return@Canvas
listOf(Triple(0, 0, 1f), Triple(n - 7, 0, -1f), Triple(0, n - 7, 1f)).forEach { (fx, fy, dir) ->
val cx = originX + (fx + 3.5f) * cell
val cy = originY + (fy + 3.5f) * cell
drawCircle(finder.copy(alpha = fa), radius = cell * 3f, center = Offset(cx, cy), style = Stroke(width = cell * 1.05f))
drawPath(
hexCookiePath(cx, cy, baseRadius = cell * 1.5f * fa, amplitude = cell * 0.16f * fa, lobes = 6, rotation = spin * dir),
color = finder.copy(alpha = fa)
)
}
}
}
private fun DrawScope.drawRuns(
n: Int,
isOn: (Int, Int) -> Boolean,
color: Color,
runThick: Float,
dotRadius: Float,
reveal: (Int, Int) -> Float,
center: (Int, Int) -> Offset,
) {
val used = Array(n) { BooleanArray(n) }
for (y in 0 until n) {
var x = 0
while (x < n) {
if (isOn(x, y)) {
var e = x
while (e + 1 < n && isOn(e + 1, y)) e++
if (e > x) {
for (i in x..e) used[i][y] = true
val s = reveal((x + e) / 2, y)
if (s > 0f) drawLine(color, center(x, y), center(e, y), strokeWidth = runThick * s, cap = StrokeCap.Round)
}
x = e + 1
} else {
x++
}
}
}
for (x in 0 until n) {
var y = 0
while (y < n) {
if (isOn(x, y) && !used[x][y]) {
var e = y
while (e + 1 < n && isOn(x, e + 1) && !used[x][e + 1]) e++
if (e > y) {
for (i in y..e) used[x][i] = true
val s = reveal(x, (y + e) / 2)
if (s > 0f) drawLine(color, center(x, y), center(x, e), strokeWidth = runThick * s, cap = StrokeCap.Round)
}
y = e + 1
} else {
y++
}
}
}
for (y in 0 until n) {
for (x in 0 until n) {
if (!isOn(x, y) || used[x][y]) continue
val s = reveal(x, y)
if (s > 0f) drawCircle(color, radius = dotRadius * s, center = center(x, y))
}
}
}
@Composable
fun StyledMatrix(
value: String,
format: BarcodeFormat,
modifier: Modifier,
background: Color,
module: Color,
animate: Boolean = true,
) {
val matrix = remember(value, format) { encodeMatrix(value, format) } ?: return
val progress = remember { Animatable(if (animate) 0f else 1f) }
LaunchedEffect(value, format) {
if (animate) {
progress.snapTo(0f)
progress.animateTo(1f, tween(durationMillis = 900, easing = FastOutSlowInEasing))
}
}
Canvas(modifier = modifier) {
val n = matrix.width
if (n <= 0) return@Canvas
val minDim = size.minDimension
val corner = minDim * 0.10f
drawRoundRect(color = background, cornerRadius = CornerRadius(corner, corner))
val moduleSide = minDim * 0.84f
val cols = n
val rows = matrix.height
val units = max(cols, rows)
val cell = moduleSide / units
val contentW = cols * cell
val contentH = rows * cell
val originX = size.width / 2f - contentW / 2f
val originY = size.height / 2f - contentH / 2f
val p = progress.value
val midX = (cols - 1) / 2f
val midY = (rows - 1) / 2f
val maxDist = hypot(midX.toDouble(), midY.toDouble()).toFloat().coerceAtLeast(1f)
val bandW = 0.35f
fun reveal(x: Int, y: Int): Float {
val d = hypot((x - midX).toDouble(), (y - midY).toDouble()).toFloat() / maxDist
return ((p - d * (1f - bandW)) / bandW).coerceIn(0f, 1f)
}
val dot = cell * 0.86f
val round = dot * 0.32f
for (y in 0 until rows) {
for (x in 0 until cols) {
if (!matrix.get(x, y)) continue
val s = reveal(x, y)
if (s <= 0f) continue
val sz = dot * s
val left = originX + x * cell + (cell - sz) / 2f
val top = originY + y * cell + (cell - sz) / 2f
drawRoundRect(
color = module,
topLeft = Offset(left, top),
size = Size(sz, sz),
cornerRadius = CornerRadius(round * s, round * s)
)
}
}
}
}
private fun matrixToImageBitmap(matrix: com.google.zxing.common.BitMatrix, onArgb: Int): ImageBitmap {
val rect = matrix.enclosingRectangle
val left = rect?.get(0) ?: 0
val top = rect?.get(1) ?: 0
val w = rect?.get(2) ?: matrix.width
val h = rect?.get(3) ?: matrix.height
val pixels = IntArray(w * h)
for (y in 0 until h) {
for (x in 0 until w) {
pixels[y * w + x] = if (matrix.get(left + x, top + y)) onArgb else 0
}
}
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bmp.setPixels(pixels, 0, w, 0, 0, w, h)
return bmp.asImageBitmap()
}
@Composable
fun StyledPdf417(
value: String,
modifier: Modifier,
background: Color,
module: Color,
animate: Boolean = true,
) {
val image = remember(value, module) {
encodeMatrix(value, BarcodeFormat.PDF_417)?.let { matrixToImageBitmap(it, module.toArgb()) }
}
val progress = remember { Animatable(if (animate) 0f else 1f) }
LaunchedEffect(value) {
if (animate) {
progress.snapTo(0f)
progress.animateTo(1f, tween(durationMillis = 700, easing = FastOutSlowInEasing))
}
}
Column(
modifier = modifier
.background(background, RoundedCornerShape(28.dp))
.padding(horizontal = 24.dp, vertical = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (image == null) {
Text(
text = value,
color = module,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
return@Column
}
Image(
bitmap = image,
contentDescription = value,
modifier = Modifier
.fillMaxWidth()
.alpha(progress.value),
contentScale = ContentScale.FillWidth,
filterQuality = FilterQuality.None
)
}
}
@Composable
fun StyledBarcode(
value: String,
format: BarcodeFormat,
modifier: Modifier,
background: Color,
module: Color,
onBackground: Color,
animate: Boolean = true,
) {
val row = remember(value, format) {
runCatching {
val bm = MultiFormatWriter().encode(value, format, 0, 1)
BooleanArray(bm.width) { bm.get(it, 0) }
}.getOrNull()
}
val progress = remember { Animatable(if (animate) 0f else 1f) }
LaunchedEffect(value, format) {
if (animate) {
progress.snapTo(0f)
progress.animateTo(1f, tween(durationMillis = 800, easing = FastOutSlowInEasing))
}
}
Column(
modifier = modifier
.background(background, RoundedCornerShape(28.dp))
.padding(horizontal = 24.dp, vertical = 26.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (row == null || row.isEmpty()) {
Text(
text = value,
color = onBackground,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
return@Column
}
BarcodeBars(
row = row,
modifier = Modifier
.fillMaxWidth()
.height(150.dp),
module = module,
reveal = progress.value
)
Text(
text = value,
modifier = Modifier.padding(top = 18.dp),
color = onBackground,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
}
}
@Composable
private fun BarcodeBars(
row: BooleanArray,
modifier: Modifier,
module: Color,
reveal: Float,
) {
Canvas(modifier = modifier) {
val count = row.size
if (count <= 0) return@Canvas
val pad = size.width * 0.015f
val usableW = size.width - pad * 2f
val sx = usableW / count
val cy = size.height / 2f
val fullH = size.height * 0.92f
val band = 0.28f
var x = 0
while (x < count) {
if (row[x]) {
var e = x
while (e + 1 < count && row[e + 1]) e++
val left = pad + x * sx
val w = (e - x + 1) * sx
val cxFrac = (left + w / 2f) / size.width
val s = ((reveal - cxFrac * (1f - band)) / band).coerceIn(0f, 1f)
if (s > 0f) {
val barH = fullH * s
val r = min(w / 2f, barH / 2f)
drawRoundRect(
color = module,
topLeft = Offset(left, cy - barH / 2f),
size = Size(w, barH),
cornerRadius = CornerRadius(r, r),
alpha = s
)
}
x = e + 1
} else {
x++
}
}
}
}
@Composable
fun StyledCodePreview(
value: String,
format: BarcodeFormat,
background: Color,
module: Color,
finder: Color,
) {
when {
format == BarcodeFormat.QR_CODE ->
StyledQrCode(value, Modifier.size(60.dp), background, module, finder, finder.copy(alpha = 0.30f))
format == BarcodeFormat.DATA_MATRIX || format == BarcodeFormat.AZTEC ->
StyledMatrix(value, format, Modifier.size(60.dp), background, module)
format == BarcodeFormat.PDF_417 -> {
val img = remember(value, module) {
encodeMatrix(value, BarcodeFormat.PDF_417)?.let { matrixToImageBitmap(it, module.toArgb()) }
}
Box(
modifier = Modifier
.size(width = 104.dp, height = 60.dp)
.clip(RoundedCornerShape(14.dp))
.background(background),
contentAlignment = Alignment.Center
) {
img?.let {
Image(
bitmap = it,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
contentScale = ContentScale.FillWidth,
filterQuality = FilterQuality.None
)
}
}
}
else -> {
val row = remember(value, format) {
runCatching {
val bm = MultiFormatWriter().encode(value, format, 0, 1)
BooleanArray(bm.width) { bm.get(it, 0) }
}.getOrNull()
}
Box(
modifier = Modifier
.size(width = 104.dp, height = 60.dp)
.clip(RoundedCornerShape(14.dp))
.background(background),
contentAlignment = Alignment.Center
) {
if (row != null) {
BarcodeBars(
row = row,
modifier = Modifier
.fillMaxWidth()
.height(34.dp)
.padding(horizontal = 10.dp),
module = module,
reveal = 1f
)
}
}
}
}
}
+236 -28
View File
@@ -1,11 +1,34 @@
package ru.omni_devel.cards
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import androidx.core.content.FileProvider
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Wearable
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.BarcodeEncoder
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.json.Json
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import kotlin.coroutines.resume
fun encodeMatrix(value: String, format: BarcodeFormat): com.google.zxing.common.BitMatrix? {
val hints: Map<com.google.zxing.EncodeHintType, Any>? = if (format == BarcodeFormat.DATA_MATRIX) {
mapOf(com.google.zxing.EncodeHintType.DATA_MATRIX_SHAPE to com.google.zxing.datamatrix.encoder.SymbolShapeHint.FORCE_SQUARE)
} else {
null
}
return runCatching { MultiFormatWriter().encode(value, format, 0, 0, hints) }.getOrNull()
}
fun generateCode(value: String, format: BarcodeFormat): Bitmap {
val writer = MultiFormatWriter()
@@ -21,38 +44,81 @@ fun generateCode(value: String, format: BarcodeFormat): Bitmap {
}
fun sendToWatch(context: Context, path: String, message: String, onResult: (Boolean) -> Unit) {
val nodeClient = Wearable.getNodeClient(context)
nodeClient.connectedNodes.addOnSuccessListener { nodes ->
if (nodes.isEmpty()) {
onResult(false)
return@addOnSuccessListener
}
var completedCount = 0
var isAnySuccess = false
nodes.forEach { node ->
Wearable.getMessageClient(context).sendMessage(
node.id,
path,
message.toByteArray(Charsets.UTF_8)
).addOnCompleteListener(context.mainExecutor) { task ->
completedCount++
if (task.isSuccessful) {
isAnySuccess = true
}
if (completedCount == nodes.size) {
onResult(isAnySuccess)
}
}.addOnFailureListener(context.mainExecutor) {
Wearable.getCapabilityClient(context)
.getCapability("omnicards_wear_app", CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { info ->
val nodes = info.nodes
if (nodes.isEmpty()) {
onResult(false)
return@addOnSuccessListener
}
var completedCount = 0
var isAnySuccess = false
nodes.forEach { node ->
Wearable.getMessageClient(context).sendMessage(
node.id,
path,
message.toByteArray(Charsets.UTF_8)
).addOnCompleteListener(context.mainExecutor) { task ->
completedCount++
if (task.isSuccessful) {
isAnySuccess = true
}
if (completedCount == nodes.size) {
onResult(isAnySuccess)
}
}
}
}
.addOnFailureListener { onResult(false) }
}
suspend fun sendToWatchAwait(context: Context, path: String, message: String): Boolean =
suspendCancellableCoroutine { cont ->
sendToWatch(context, path, message) { ok ->
if (cont.isActive) cont.resume(ok)
}
}
private fun encodeIconBase64(path: String?): String? {
if (path == null) return null
return runCatching {
val src = BitmapFactory.decodeFile(path) ?: return null
val max = 96
val largest = maxOf(src.width, src.height)
val scaled = if (largest > max) {
val s = max.toFloat() / largest
Bitmap.createScaledBitmap(
src,
(src.width * s).toInt().coerceAtLeast(1),
(src.height * s).toInt().coerceAtLeast(1),
true
)
} else {
src
}
val baos = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, baos)
Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
}.getOrNull()
}
fun cardsSyncJson(cards: List<CardInfo>): String {
val withIcons = cards.map { c ->
CardInfo(c.id, c.name, c.codeType, c.codeValue, c.color, c.position, c.iconPath, c.monochrome, encodeIconBase64(c.iconPath))
}
return Json.encodeToString(withIcons)
}
fun autoSyncCards(context: Context, db: DbHelper) {
sendToWatch(context, "/updateCards", cardsSyncJson(db.getCards())) { }
}
fun checkCardData(name: String, value: String): Int? {
@@ -66,3 +132,145 @@ fun checkCardData(name: String, value: String): Int? {
return null
}
private fun iconsDir(context: Context): File {
val dir = File(context.filesDir, "icons")
if (!dir.exists()) dir.mkdirs()
return dir
}
private fun faviconCacheDir(context: Context): File {
val dir = File(context.filesDir, "favicons")
if (!dir.exists()) dir.mkdirs()
return dir
}
private fun faviconFile(context: Context, domain: String): File {
return File(faviconCacheDir(context), domain.replace(Regex("[^a-zA-Z0-9]"), "_") + ".png")
}
fun cachedFaviconPath(context: Context, domain: String): String? {
val f = faviconFile(context, domain)
return if (f.exists() && f.length() > 0) f.absolutePath else null
}
fun downloadFaviconToCache(context: Context, domain: String): String? {
cachedFaviconPath(context, domain)?.let { return it }
return try {
val url = URL("https://www.google.com/s2/favicons?domain=$domain&sz=128")
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 8000
connection.readTimeout = 8000
connection.instanceFollowRedirects = true
connection.inputStream.use { input ->
val target = faviconFile(context, domain)
target.outputStream().use { output -> input.copyTo(output) }
if (target.length() > 0) target.absolutePath else null
}
} catch (e: Exception) {
null
}
}
fun copyToCardIcon(context: Context, srcPath: String): String? {
return try {
val target = File(iconsDir(context), "${UUID.randomUUID()}.png")
File(srcPath).inputStream().use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
if (target.length() > 0) target.absolutePath else null
} catch (e: Exception) {
null
}
}
fun extractLogoColor(path: String?): Int? {
if (path == null) return null
return try {
val bmp = android.graphics.BitmapFactory.decodeFile(path) ?: return null
var r = 0L
var g = 0L
var b = 0L
var count = 0
val stepX = maxOf(1, bmp.width / 24)
val stepY = maxOf(1, bmp.height / 24)
var y = 0
while (y < bmp.height) {
var x = 0
while (x < bmp.width) {
val p = bmp.getPixel(x, y)
val a = (p ushr 24) and 0xFF
if (a > 128) {
val pr = (p ushr 16) and 0xFF
val pg = (p ushr 8) and 0xFF
val pb = p and 0xFF
val mx = maxOf(pr, pg, pb)
val mn = minOf(pr, pg, pb)
val sat = if (mx == 0) 0 else (mx - mn) * 255 / mx
if (sat > 45 && mx > 50) {
r += pr
g += pg
b += pb
count++
}
}
x += stepX
}
y += stepY
}
if (count == 0) return null
(0xFF shl 24) or ((r / count).toInt() shl 16) or ((g / count).toInt() shl 8) or (b / count).toInt()
} catch (e: Exception) {
null
}
}
fun saveCustomIcon(context: Context, uri: Uri): String? {
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
val target = File(iconsDir(context), "${UUID.randomUUID()}.png")
target.outputStream().use { output -> input.copyTo(output) }
target.absolutePath
}
} catch (e: Exception) {
null
}
}
fun shareCodeImage(context: Context, cardInfo: CardInfo) {
try {
val bitmap = generateCode(cardInfo.codeValue, cardInfo.codeType)
val dir = File(context.cacheDir, "shared")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "code.png")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TITLE, cardInfo.name)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, context.getString(R.string.do_share)))
} catch (e: Exception) {
}
}
@@ -2,10 +2,56 @@ package ru.omni_devel.cards.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val LightPrimary = Color(0xFF5A4FCF)
val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFFE4DFFF)
val LightOnPrimaryContainer = Color(0xFF14005C)
val LightSecondary = Color(0xFF5F5C71)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFE5DFF9)
val LightOnSecondaryContainer = Color(0xFF1B192C)
val LightTertiary = Color(0xFF7C5264)
val LightOnTertiary = Color(0xFFFFFFFF)
val LightTertiaryContainer = Color(0xFFFFD8E7)
val LightOnTertiaryContainer = Color(0xFF301120)
val LightBackground = Color(0xFFFCF8FF)
val LightOnBackground = Color(0xFF1B1B21)
val LightSurface = Color(0xFFFCF8FF)
val LightOnSurface = Color(0xFF1B1B21)
val LightSurfaceVariant = Color(0xFFE4E1EC)
val LightOnSurfaceVariant = Color(0xFF47464F)
val LightSurfaceContainer = Color(0xFFF1ECF7)
val LightSurfaceContainerHigh = Color(0xFFEBE6F1)
val LightOutline = Color(0xFF787680)
val LightOutlineVariant = Color(0xFFC8C5D0)
val LightError = Color(0xFFBA1A1A)
val LightOnError = Color(0xFFFFFFFF)
val LightErrorContainer = Color(0xFFFFDAD6)
val LightOnErrorContainer = Color(0xFF410002)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val DarkPrimary = Color(0xFFC7BFFF)
val DarkOnPrimary = Color(0xFF2A1A8F)
val DarkPrimaryContainer = Color(0xFF4236B6)
val DarkOnPrimaryContainer = Color(0xFFE4DFFF)
val DarkSecondary = Color(0xFFC8C3DD)
val DarkOnSecondary = Color(0xFF302E42)
val DarkSecondaryContainer = Color(0xFF474459)
val DarkOnSecondaryContainer = Color(0xFFE5DFF9)
val DarkTertiary = Color(0xFFEDB8CD)
val DarkOnTertiary = Color(0xFF482536)
val DarkTertiaryContainer = Color(0xFF623B4C)
val DarkOnTertiaryContainer = Color(0xFFFFD8E7)
val DarkBackground = Color(0xFF131318)
val DarkOnBackground = Color(0xFFE4E1E9)
val DarkSurface = Color(0xFF131318)
val DarkOnSurface = Color(0xFFE4E1E9)
val DarkSurfaceVariant = Color(0xFF47464F)
val DarkOnSurfaceVariant = Color(0xFFC8C5D0)
val DarkSurfaceContainer = Color(0xFF1F1F25)
val DarkSurfaceContainerHigh = Color(0xFF2A2930)
val DarkOutline = Color(0xFF918F9A)
val DarkOutlineVariant = Color(0xFF47464F)
val DarkError = Color(0xFFFFB4AB)
val DarkOnError = Color(0xFF690005)
val DarkErrorContainer = Color(0xFF93000A)
val DarkOnErrorContainer = Color(0xFFFFDAD6)
@@ -0,0 +1,13 @@
package ru.omni_devel.cards.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(18.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)
@@ -1,6 +1,5 @@
package ru.omni_devel.cards.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -11,32 +10,67 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
secondaryContainer = LightSecondaryContainer,
onSecondaryContainer = LightOnSecondaryContainer,
tertiary = LightTertiary,
onTertiary = LightOnTertiary,
tertiaryContainer = LightTertiaryContainer,
onTertiaryContainer = LightOnTertiaryContainer,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
surfaceContainer = LightSurfaceContainer,
surfaceContainerHigh = LightSurfaceContainerHigh,
outline = LightOutline,
outlineVariant = LightOutlineVariant,
error = LightError,
onError = LightOnError,
errorContainer = LightErrorContainer,
onErrorContainer = LightOnErrorContainer,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
onSecondaryContainer = DarkOnSecondaryContainer,
tertiary = DarkTertiary,
onTertiary = DarkOnTertiary,
tertiaryContainer = DarkTertiaryContainer,
onTertiaryContainer = DarkOnTertiaryContainer,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
surfaceContainer = DarkSurfaceContainer,
surfaceContainerHigh = DarkSurfaceContainerHigh,
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
error = DarkError,
onError = DarkOnError,
errorContainer = DarkErrorContainer,
onErrorContainer = DarkOnErrorContainer,
)
@Composable
fun OmniCardsTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
@@ -53,6 +87,7 @@ fun OmniCardsTheme(
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}
@@ -6,29 +6,20 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
displayLarge = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 52.sp, lineHeight = 60.sp, letterSpacing = (-0.5).sp),
displayMedium = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 42.sp, lineHeight = 50.sp, letterSpacing = (-0.25).sp),
displaySmall = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 34.sp, lineHeight = 42.sp),
headlineLarge = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 30.sp, lineHeight = 38.sp),
headlineMedium = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 26.sp, lineHeight = 34.sp),
headlineSmall = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 30.sp),
titleLarge = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp),
titleMedium = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp),
titleSmall = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
bodyLarge = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp),
bodyMedium = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp),
bodySmall = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp),
labelLarge = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
labelMedium = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
labelSmall = TextStyle(fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
)
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M3.27,3L2,4.27l5,5V13h3v9l3.58,-6.14L17.73,20 19,18.73 3.27,3zM17,10h-4l4,-8H7v2.18l8.46,8.46L17,10z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
</vector>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<com.journeyapps.barcodescanner.DecoratedBarcodeView
android:id="@+id/barcode_scanner"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:zxing_use_texture_view="true" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:fitsSystemWindows="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/back_button"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/scanner_title"
app:icon="@drawable/ic_arrow_back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/scanner_title"
android:textColor="@android:color/white"
android:textSize="22sp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/torch_button"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="40dp"
android:text="@string/torch"
app:icon="@drawable/ic_flash_off" />
<ru.omni_devel.cards.ScanResultView
android:id="@+id/scan_result"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
+20 -1
View File
@@ -9,7 +9,7 @@
<string name="do_sync">Синхронизировать с WearOS</string>
<string name="sync_in_progress">Синхронизация в процессе…</string>
<string name="sync_is_successful">Успешная синхронизация</string>
<string name="sync_is_fail">Ошибка синхронизации. Проверьте подключение к часам</string>
<string name="sync_is_fail">Не удалось. Установите OmniCards на часы и проверьте подключение</string>
<string name="backup">Резервное копирование</string>
<string name="open_backup_menu">Резервное копирование</string>
@@ -39,4 +39,23 @@
<string name="name_must_be_shorter_than_32_symbols">Имя карты должно быть короче 32 символов</string>
<string name="failed_to_generate_card_code">Не удалось сгенерировать код карты. Проверьте данные</string>
<string name="cards_count">Карт: %d</string>
<string name="card_deleted">Карта удалена</string>
<string name="do_share">Поделиться</string>
<string name="loyalty_card">Карта лояльности</string>
<string name="icon_label">Иконка</string>
<string name="icon_auto">Авто</string>
<string name="icon_auto_desc">Авто, по типу кода</string>
<string name="icon_custom_desc">Своя иконка</string>
<string name="icon_from_gallery">Из галереи</string>
<string name="icon_change">Изменить</string>
<string name="icon_failed">Не удалось загрузить иконку</string>
<string name="search_service">Поиск сервиса…</string>
<string name="color_card">Цвет карты</string>
<string name="delete">Удалить</string>
<string name="icon_monochrome">Монохром иконки</string>
<string name="ask_do_delete">Удалить карту?</string>
<string name="scanner_title">Сканирование</string>
<string name="torch">Вспышка</string>
</resources>
+20 -1
View File
@@ -14,7 +14,7 @@
<string name="do_sync">Sync with WearOS</string>
<string name="sync_in_progress">Sync in progress…</string>
<string name="sync_is_successful">Sync successful</string>
<string name="sync_is_fail">Sync failed. Check your watch connection</string>
<string name="sync_is_fail">Sync failed. Install OmniCards on your watch and check the connection</string>
<string name="backup">Backup</string>
<string name="open_backup_menu">Backup</string>
@@ -44,4 +44,23 @@
<string name="name_must_be_shorter_than_32_symbols">Card name must be shorter than 32 characters</string>
<string name="failed_to_generate_card_code">Failed to generate card code. Check the card data</string>
<string name="cards_count">%d cards</string>
<string name="card_deleted">Card deleted</string>
<string name="do_share">Share</string>
<string name="loyalty_card">Loyalty card</string>
<string name="icon_label">Icon</string>
<string name="icon_auto">Auto</string>
<string name="icon_auto_desc">Auto, by code type</string>
<string name="icon_custom_desc">Custom icon</string>
<string name="icon_from_gallery">From gallery</string>
<string name="icon_change">Change</string>
<string name="icon_failed">Failed to load icon</string>
<string name="search_service">Search service…</string>
<string name="color_card">Card color</string>
<string name="delete">Delete</string>
<string name="icon_monochrome">Monochrome icon</string>
<string name="ask_do_delete">Delete card?</string>
<string name="scanner_title">Scan card</string>
<string name="torch">Flash</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="shared" path="shared/" />
</paths>
+6 -1
View File
@@ -27,8 +27,12 @@ android {
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -52,6 +56,7 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation("androidx.compose.material:material-icons-extended")
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
+10
View File
@@ -19,3 +19,13 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
*** serializer(...);
}
-keepclasseswithmembers class **$$serializer { *; }
-keepclassmembers enum com.google.zxing.BarcodeFormat { *; }
@@ -4,10 +4,14 @@ import com.google.zxing.BarcodeFormat
import kotlinx.serialization.Serializable
@Serializable
class CardInfo (
class CardInfo(
val id: Int,
val name: String,
val codeType: BarcodeFormat,
val codeValue: String,
val color: String?
val color: String?,
val position: Int = 0,
val iconPath: String? = null,
val monochrome: Boolean = false,
val iconData: String? = null,
)
@@ -6,40 +6,40 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import com.google.zxing.BarcodeFormat
class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?) : SQLiteOpenHelper(context, "omni_cards", factory, 2) {
class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?) : SQLiteOpenHelper(context, "omni_cards", factory, 3) {
override fun onCreate(db: SQLiteDatabase?) {
db!!.execSQL("CREATE TABLE IF NOT EXISTS cards (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, codeType TEXT, codeValue TEXT, color TEXT)")
db!!.execSQL("CREATE TABLE IF NOT EXISTS cards (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, codeType TEXT, codeValue TEXT, color TEXT, position INTEGER DEFAULT 0, iconPath TEXT)")
}
override fun onUpgrade(
db: SQLiteDatabase?,
oldVersion: Int,
p2: Int
) {
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, p2: Int) {
if (oldVersion < 2) {
db!!.execSQL("ALTER TABLE cards ADD COLUMN color TEXT")
}
if (oldVersion < 3) {
db!!.execSQL("ALTER TABLE cards ADD COLUMN position INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE cards ADD COLUMN iconPath TEXT")
db.execSQL("UPDATE cards SET position = id")
}
}
fun clearDatabase() {
val db = this.writableDatabase
db.execSQL("DELETE FROM cards")
db.close()
}
fun addCards(cards: List<CardInfo>) {
val db = this.writableDatabase
for (card in cards) {
cards.forEachIndexed { index, card ->
val values = ContentValues()
values.put("id", card.id)
values.put("name", card.name)
values.put("codeValue", card.codeValue)
values.put("codeType", card.codeType.name)
values.put("color", card.color)
values.put("iconPath", card.iconPath)
values.put("position", index)
db.insert("cards", null, values)
}
@@ -47,20 +47,25 @@ class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?)
db.close()
}
private fun readCard(cursor: android.database.Cursor): CardInfo {
return CardInfo(
id = cursor.getInt(cursor.getColumnIndexOrThrow("id")),
name = cursor.getString(cursor.getColumnIndexOrThrow("name")),
codeType = BarcodeFormat.valueOf(cursor.getString(cursor.getColumnIndexOrThrow("codeType"))),
codeValue = cursor.getString(cursor.getColumnIndexOrThrow("codeValue")),
color = cursor.getString(cursor.getColumnIndexOrThrow("color")),
position = cursor.getInt(cursor.getColumnIndexOrThrow("position")),
iconPath = cursor.getString(cursor.getColumnIndexOrThrow("iconPath")),
)
}
fun getCards(): List<CardInfo> {
val db = this.readableDatabase
val cursor = db.rawQuery("SELECT * FROM cards", null)
val cursor = db.rawQuery("SELECT * FROM cards ORDER BY position ASC, id ASC", null)
val cards = mutableListOf<CardInfo>()
while (cursor.moveToNext()) {
cards.add(CardInfo(
id = cursor.getInt(cursor.getColumnIndexOrThrow("id")),
name = cursor.getString(cursor.getColumnIndexOrThrow("name")),
codeType = BarcodeFormat.valueOf(cursor.getString(cursor.getColumnIndexOrThrow("codeType"))),
codeValue = cursor.getString(cursor.getColumnIndexOrThrow("codeValue")),
color = cursor.getString(cursor.getColumnIndexOrThrow("color"))
))
cards.add(readCard(cursor))
}
cursor.close()
@@ -71,7 +76,6 @@ class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?)
fun getCard(id: Int): CardInfo? {
val db = this.readableDatabase
val cursor = db.rawQuery("SELECT * FROM cards WHERE id = ?", arrayOf(id.toString()))
if (!cursor.moveToFirst()) {
@@ -81,14 +85,7 @@ class DbHelper(val context: Context, val factory: SQLiteDatabase.CursorFactory?)
return null
}
val cardInfo = CardInfo(
id = cursor.getInt(cursor.getColumnIndexOrThrow("id")),
name = cursor.getString(cursor.getColumnIndexOrThrow("name")),
codeType = BarcodeFormat.valueOf(cursor.getString(cursor.getColumnIndexOrThrow("codeType"))),
codeValue = cursor.getString(cursor.getColumnIndexOrThrow("codeValue")),
color = cursor.getString(cursor.getColumnIndexOrThrow("color"))
)
val cardInfo = readCard(cursor)
cursor.close()
db.close()
@@ -1,77 +1,127 @@
package ru.omni_devel.cards.presentation
import android.content.Intent
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.ButtonDefaults
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.google.zxing.BarcodeFormat
private fun autoGlyph(codeType: BarcodeFormat): ImageVector {
return when (codeType) {
BarcodeFormat.QR_CODE, BarcodeFormat.DATA_MATRIX, BarcodeFormat.AZTEC, BarcodeFormat.PDF_417 -> Icons.Filled.QrCode2
else -> Icons.Filled.QrCodeScanner
}
}
@Composable
private fun rememberIconBitmap(path: String?): ImageBitmap? {
return remember(path) {
if (path == null) return@remember null
runCatching { BitmapFactory.decodeFile(path)?.asImageBitmap() }.getOrNull()
}
}
@Composable
fun CardButton(cardInfo: CardInfo) {
val context = LocalContext.current
Box(modifier = Modifier.fillMaxWidth()) {
Button(
val accent = cardInfo.color?.let { runCatching { Color(it.toColorInt()) }.getOrNull() }
val bitmap = rememberIconBitmap(cardInfo.iconPath)
val base = MaterialTheme.colorScheme.surfaceContainer
val container = if (accent != null) accent.copy(alpha = 0.18f).compositeOver(base) else base
val tile = accent ?: MaterialTheme.colorScheme.secondaryContainer
val onTile = if (accent != null) {
if (accent.luminance() > 0.5f) Color.Black.copy(alpha = 0.8f) else Color.White
} else {
MaterialTheme.colorScheme.onSecondaryContainer
}
Button(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = container
),
onClick = {
val intent = Intent(context, ShowCardActivity::class.java)
intent.putExtra("cardId", cardInfo.id)
context.startActivity(intent)
}
) {
Row(
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
contentPadding = PaddingValues(0.dp),
onClick = {
val intent = Intent(context, ShowCardActivity::class.java)
intent.putExtra("cardId", cardInfo.id)
context.startActivity(intent)
}
verticalAlignment = Alignment.CenterVertically
) {
Row(
Box(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.clip(ButtonDefaults.shape)
.size(30.dp)
.clip(RoundedCornerShape(9.dp))
.background(if (bitmap != null) Color.White else tile),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(32.dp)
.background(
if (cardInfo.color == null)
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.625f)
else
Color(cardInfo.color.toColorInt()).copy(alpha = 0.625f)
)
)
Text(
cardInfo.name,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.weight(1f),
textAlign = TextAlign.Start
)
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit,
colorFilter = if (cardInfo.monochrome) {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
} else {
null
}
)
} else {
Icon(
imageVector = autoGlyph(cardInfo.codeType),
contentDescription = null,
tint = onTile,
modifier = Modifier.size(18.dp)
)
}
}
Text(
cardInfo.name,
modifier = Modifier
.padding(start = 10.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -1,22 +1,25 @@
package ru.omni_devel.cards.presentation
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
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.unit.dp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import ru.omni_devel.cards.R
import ru.omni_devel.cards.presentation.theme.OmniCardsTheme
class MainActivity : ComponentActivity() {
private lateinit var db: DbHelper
@@ -27,36 +30,39 @@ class MainActivity : ComponentActivity() {
db = DbHelper(this, null)
setContent {
App(db.getCards())
OmniCardsTheme {
App(db.getCards())
}
}
}
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun App(cards: List<CardInfo>) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val topBottomPadding = maxHeight * 0.4f
AppScaffold {
if (cards.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.sync_with_mobile_app_to_get_started))
Text(
stringResource(R.string.sync_with_mobile_app_to_get_started),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
} else {
TransformingLazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = topBottomPadding,
bottom = topBottomPadding
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
for (card in cards) {
item {
CardButton(card)
val listState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = listState) { contentPadding ->
TransformingLazyColumn(
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
for (card in cards) {
item {
CardButton(card)
}
}
}
}
@@ -1,16 +1,14 @@
package ru.omni_devel.cards.presentation
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import android.util.Base64
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import kotlinx.serialization.json.Json
import ru.omni_devel.cards.R
import java.io.File
class MessagesReceiver : WearableListenerService() {
private lateinit var db: DbHelper
private val json = Json { ignoreUnknownKeys = true }
override fun onCreate() {
super.onCreate()
@@ -21,21 +19,28 @@ class MessagesReceiver : WearableListenerService() {
override fun onMessageReceived(event: MessageEvent) {
when (event.path) {
"/updateCards" -> {
val json = String(event.data, Charsets.UTF_8)
val jsonText = String(event.data, Charsets.UTF_8)
val cards = Json.decodeFromString<List<CardInfo>>(json)
val cards = json.decodeFromString<List<CardInfo>>(jsonText)
val iconsDir = File(filesDir, "icons")
iconsDir.deleteRecursively()
iconsDir.mkdirs()
val processed = cards.map { c ->
val localPath = c.iconData?.let { data ->
runCatching {
val bytes = Base64.decode(data, Base64.NO_WRAP)
val file = File(iconsDir, "${c.id}.png")
file.writeBytes(bytes)
file.absolutePath
}.getOrNull()
}
CardInfo(c.id, c.name, c.codeType, c.codeValue, c.color, c.position, localPath, c.monochrome)
}
db.clearDatabase()
db.addCards(cards)
Handler(Looper.getMainLooper()).post {
Toast.makeText(this, this.getString(R.string.sync_is_successful), Toast.LENGTH_SHORT).show()
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
startActivity(intent)
}
db.addCards(processed)
}
}
}
@@ -8,22 +8,37 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.google.zxing.BarcodeFormat
import ru.omni_devel.cards.R
import ru.omni_devel.cards.presentation.theme.OmniCardsTheme
class ShowCardActivity : ComponentActivity() {
private lateinit var db: DbHelper
@@ -39,72 +54,103 @@ class ShowCardActivity : ComponentActivity() {
db = DbHelper(this, null)
val cardId = intent.getIntExtra("cardId", 0)
val cardInfo = db.getCard(cardId)
if (cardInfo == null) {
Toast.makeText(this, "cardInfo is null", Toast.LENGTH_LONG).show()
finish()
return
}
setContent {
ShowCardPage(cardInfo)
OmniCardsTheme {
ShowCardPage(cardInfo)
}
}
}
override fun onResume() {
super.onResume()
setBrightness(1.0f)
}
override fun onPause() {
super.onPause()
setBrightness(WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE)
finish()
}
private fun setBrightness(level: Float) {
val params = window.attributes
params.screenBrightness = level
window.attributes = params
}
}
@Composable
fun ShowCardPage(cardInfo: CardInfo) {
val seed = cardInfo.color?.let { runCatching { Color(it.toColorInt()) }.getOrNull() } ?: Color(0xFFC7BFFF)
val codeBg = lerp(seed, Color.White, 0.80f)
val codeModule = lerp(seed, Color.Black, 0.55f)
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
val screenSize = minOf(maxWidth, maxHeight)
val safeSize = (screenSize * 0.65f)
val sizePx = with(LocalDensity.current) { safeSize.roundToPx() }
val bitmap = remember(cardInfo, sizePx) {
try {
generateCode(cardInfo.codeValue, cardInfo.codeType, sizePx, sizePx).asImageBitmap()
} catch (_: Exception) {
null
}
val bitmap = remember(cardInfo, codeModule, codeBg) {
styledCodeBitmap(cardInfo.codeValue, cardInfo.codeType, codeModule.toArgb(), codeBg.toArgb())?.asImageBitmap()
}
val aspect = bitmap?.let { it.width.toFloat() / it.height.toFloat() } ?: 1f
val is1d = cardInfo.codeType != BarcodeFormat.QR_CODE &&
cardInfo.codeType != BarcodeFormat.DATA_MATRIX &&
cardInfo.codeType != BarcodeFormat.AZTEC &&
cardInfo.codeType != BarcodeFormat.PDF_417
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = screenSize * 0.06f)
) {
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = cardInfo.codeValue,
modifier = Modifier.size(safeSize)
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(14.dp))
.padding(horizontal = 14.dp, vertical = 6.dp)
) {
Text(
cardInfo.name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(Modifier.height(10.dp))
if (bitmap != null) {
val codeModifier = when {
is1d -> Modifier.size(screenSize * 0.74f, screenSize * 0.30f)
cardInfo.codeType == BarcodeFormat.PDF_417 -> Modifier.size(screenSize * 0.74f, screenSize * 0.74f / aspect)
else -> Modifier.size(screenSize * 0.56f)
}
Box(
modifier = Modifier
.background(codeBg, RoundedCornerShape(16.dp))
.padding(12.dp)
) {
Image(
bitmap = bitmap,
contentDescription = cardInfo.codeValue,
modifier = codeModifier,
contentScale = ContentScale.FillBounds,
filterQuality = FilterQuality.None
)
}
} else {
Text(
stringResource(R.string.failed_to_generate_card_code),
@@ -2,12 +2,41 @@ package ru.omni_devel.cards.presentation
import android.graphics.Bitmap
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.BarcodeEncoder
import com.google.zxing.datamatrix.encoder.SymbolShapeHint
fun generateCode(value: String, format: BarcodeFormat, width: Int, height: Int): Bitmap {
val writer = MultiFormatWriter()
val matrix = writer.encode(value, format, width, height)
fun styledCodeBitmap(value: String, format: BarcodeFormat, onArgb: Int, bgArgb: Int): Bitmap? {
val is2d = format == BarcodeFormat.QR_CODE || format == BarcodeFormat.DATA_MATRIX ||
format == BarcodeFormat.AZTEC || format == BarcodeFormat.PDF_417
return BarcodeEncoder().createBitmap(matrix)
val matrix = runCatching {
if (!is2d) {
MultiFormatWriter().encode(value, format, 0, 1)
} else {
val hints: Map<EncodeHintType, Any>? = if (format == BarcodeFormat.DATA_MATRIX) {
mapOf(EncodeHintType.DATA_MATRIX_SHAPE to SymbolShapeHint.FORCE_SQUARE)
} else {
null
}
MultiFormatWriter().encode(value, format, 0, 0, hints)
}
}.getOrNull() ?: return null
val rect = matrix.enclosingRectangle ?: intArrayOf(0, 0, matrix.width, matrix.height)
val left = rect[0]
val top = rect[1]
val w = rect[2]
val h = rect[3]
if (w <= 0 || h <= 0) return null
val pixels = IntArray(w * h)
for (y in 0 until h) {
for (x in 0 until w) {
pixels[y * w + x] = if (matrix.get(left + x, top + y)) onArgb else bgArgb
}
}
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bmp.setPixels(pixels, 0, w, 0, 0, w, h)
return bmp
}
@@ -1,17 +1,31 @@
package ru.omni_devel.cards.presentation.theme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.wear.compose.material3.ColorScheme
import androidx.wear.compose.material3.MaterialTheme
private val OmniColorScheme = ColorScheme(
primary = Color(0xFFC7BFFF),
onPrimary = Color(0xFF2A1A8F),
primaryContainer = Color(0xFF4236B6),
onPrimaryContainer = Color(0xFFE4DFFF),
secondary = Color(0xFFC8C3DD),
onSecondary = Color(0xFF302E42),
secondaryContainer = Color(0xFF474459),
onSecondaryContainer = Color(0xFFE5DFF9),
surfaceContainer = Color(0xFF1F1F25),
surfaceContainerHigh = Color(0xFF2A2930),
onSurface = Color(0xFFE4E1E9),
onSurfaceVariant = Color(0xFFC8C5D0),
)
@Composable
fun OmniCardsTheme(
content: @Composable () -> Unit
) {
/**
* Empty theme to customize for your app.
* See: https://developer.android.com/jetpack/compose/designsystems/custom
*/
MaterialTheme(
colorScheme = OmniColorScheme,
content = content
)
}
+5
View File
@@ -0,0 +1,5 @@
<resources>
<string-array name="android_wear_capabilities">
<item>omnicards_wear_app</item>
</string-array>
</resources>