From e62b7f4741b3e40f9bfe58a1b4cf776a40e8b191 Mon Sep 17 00:00:00 2001
From: Fimkov <123477029+fimkov@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:06:49 +0700
Subject: [PATCH] feat: full md3 redesign and few tweaks
fully redesigned android and wearos app
auto sync with wearos
enabled minify
---
mobile/build.gradle.kts | 7 +-
mobile/proguard-rules.pro | 12 +-
mobile/src/main/AndroidManifest.xml | 36 +-
.../ru/omni_devel/cards/BackupActivity.kt | 150 ----
.../java/ru/omni_devel/cards/BackupScreen.kt | 155 +++++
.../java/ru/omni_devel/cards/BrandCatalog.kt | 38 +
.../main/java/ru/omni_devel/cards/CardInfo.kt | 13 +-
.../java/ru/omni_devel/cards/CardsScreen.kt | 533 ++++++++++++++
.../java/ru/omni_devel/cards/ColorReveal.kt | 96 +++
.../main/java/ru/omni_devel/cards/DbHelper.kt | 107 +--
.../ru/omni_devel/cards/EditCardActivity.kt | 317 ---------
.../ru/omni_devel/cards/EditCardScreen.kt | 653 ++++++++++++++++++
.../main/java/ru/omni_devel/cards/Elements.kt | 300 +++-----
.../java/ru/omni_devel/cards/MainActivity.kt | 256 ++++---
.../ru/omni_devel/cards/NavigationPageData.kt | 9 -
.../ru/omni_devel/cards/ScanResultView.kt | 462 +++++++++++++
.../ru/omni_devel/cards/ScannerActivity.kt | 100 +++
.../ru/omni_devel/cards/ShowCardActivity.kt | 117 ----
.../ru/omni_devel/cards/ShowCardScreen.kt | 212 ++++++
.../java/ru/omni_devel/cards/StyledQrCode.kt | 583 ++++++++++++++++
.../main/java/ru/omni_devel/cards/Utils.kt | 264 ++++++-
.../ru/omni_devel/cards/ui/theme/Color.kt | 58 +-
.../ru/omni_devel/cards/ui/theme/Shape.kt | 13 +
.../ru/omni_devel/cards/ui/theme/Theme.kt | 77 ++-
.../java/ru/omni_devel/cards/ui/theme/Type.kt | 41 +-
.../src/main/res/drawable/ic_arrow_back.xml | 9 +
mobile/src/main/res/drawable/ic_flash_off.xml | 9 +
mobile/src/main/res/drawable/ic_flash_on.xml | 9 +
.../src/main/res/layout/activity_scanner.xml | 55 ++
mobile/src/main/res/values-ru/strings.xml | 21 +-
mobile/src/main/res/values/strings.xml | 21 +-
mobile/src/main/res/xml/file_paths.xml | 4 +
wear/build.gradle.kts | 7 +-
wear/proguard-rules.pro | 12 +-
.../omni_devel/cards/presentation/CardInfo.kt | 10 +-
.../omni_devel/cards/presentation/DbHelper.kt | 57 +-
.../omni_devel/cards/presentation/Elements.kt | 140 ++--
.../cards/presentation/MainActivity.kt | 46 +-
.../cards/presentation/MessagesReciever.kt | 41 +-
.../cards/presentation/ShowCardActivity.kt | 94 ++-
.../ru/omni_devel/cards/presentation/Utils.kt | 39 +-
.../cards/presentation/theme/Theme.kt | 24 +-
wear/src/main/res/values/wear.xml | 5 +
43 files changed, 4013 insertions(+), 1199 deletions(-)
delete mode 100644 mobile/src/main/java/ru/omni_devel/cards/BackupActivity.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/BackupScreen.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/BrandCatalog.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/CardsScreen.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/ColorReveal.kt
delete mode 100644 mobile/src/main/java/ru/omni_devel/cards/EditCardActivity.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/EditCardScreen.kt
delete mode 100644 mobile/src/main/java/ru/omni_devel/cards/NavigationPageData.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/ScanResultView.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/ScannerActivity.kt
delete mode 100644 mobile/src/main/java/ru/omni_devel/cards/ShowCardActivity.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/ShowCardScreen.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/StyledQrCode.kt
create mode 100644 mobile/src/main/java/ru/omni_devel/cards/ui/theme/Shape.kt
create mode 100644 mobile/src/main/res/drawable/ic_arrow_back.xml
create mode 100644 mobile/src/main/res/drawable/ic_flash_off.xml
create mode 100644 mobile/src/main/res/drawable/ic_flash_on.xml
create mode 100644 mobile/src/main/res/layout/activity_scanner.xml
create mode 100644 mobile/src/main/res/xml/file_paths.xml
create mode 100644 wear/src/main/res/values/wear.xml
diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts
index 2883ead..9721492 100644
--- a/mobile/build.gradle.kts
+++ b/mobile/build.gradle.kts
@@ -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")
}
\ No newline at end of file
diff --git a/mobile/proguard-rules.pro b/mobile/proguard-rules.pro
index 481bb43..72aeb12 100644
--- a/mobile/proguard-rules.pro
+++ b/mobile/proguard-rules.pro
@@ -18,4 +18,14 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.**
+-keepclassmembers @kotlinx.serialization.Serializable class ** {
+ *** Companion;
+ *** serializer(...);
+}
+-keepclasseswithmembers class **$$serializer { *; }
+-keepclassmembers enum com.google.zxing.BarcodeFormat { *; }
+
diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml
index 83ff95f..a428658 100644
--- a/mobile/src/main/AndroidManifest.xml
+++ b/mobile/src/main/AndroidManifest.xml
@@ -1,28 +1,22 @@
+
+
+
+
-
-
-
@@ -30,6 +24,22 @@
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/mobile/src/main/java/ru/omni_devel/cards/BackupActivity.kt b/mobile/src/main/java/ru/omni_devel/cards/BackupActivity.kt
deleted file mode 100644
index 7424bac..0000000
--- a/mobile/src/main/java/ru/omni_devel/cards/BackupActivity.kt
+++ /dev/null
@@ -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(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>(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))
- }
- }
-}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/BackupScreen.kt b/mobile/src/main/java/ru/omni_devel/cards/BackupScreen.kt
new file mode 100644
index 0000000..0e6e8ce
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/BackupScreen.kt
@@ -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(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>(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))
+ }
+ }
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/BrandCatalog.kt b/mobile/src/main/java/ru/omni_devel/cards/BrandCatalog.kt
new file mode 100644
index 0000000..bba81f3
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/BrandCatalog.kt
@@ -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"),
+)
diff --git a/mobile/src/main/java/ru/omni_devel/cards/CardInfo.kt b/mobile/src/main/java/ru/omni_devel/cards/CardInfo.kt
index eebe6d3..12b6157 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/CardInfo.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/CardInfo.kt
@@ -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,
+)
diff --git a/mobile/src/main/java/ru/omni_devel/cards/CardsScreen.kt b/mobile/src/main/java/ru/omni_devel/cards/CardsScreen.kt
new file mode 100644
index 0000000..1a950cf
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/CardsScreen.kt
@@ -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,
+ 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(null) }
+ var dragCard by remember { mutableStateOf(null) }
+ var dragOffsetY by remember { mutableFloatStateOf(0f) }
+ var dragHeightPx by remember { mutableFloatStateOf(0f) }
+ var overDelete by remember { mutableStateOf(false) }
+ var pendingDelete by remember { mutableStateOf(null) }
+
+ var boxCoords by remember { mutableStateOf(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)
+ }
+ }
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ColorReveal.kt b/mobile/src/main/java/ru/omni_devel/cards/ColorReveal.kt
new file mode 100644
index 0000000..e9eee24
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/ColorReveal.kt
@@ -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(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)
+ }
+ }
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/DbHelper.kt b/mobile/src/main/java/ru/omni_devel/cards/DbHelper.kt
index 0b67f0d..225a84a 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/DbHelper.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/DbHelper.kt
@@ -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 {
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()
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,33 +112,49 @@ 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) {
+ 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) {
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)
}
db.close()
}
-}
\ No newline at end of file
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/EditCardActivity.kt b/mobile/src/main/java/ru/omni_devel/cards/EditCardActivity.kt
deleted file mode 100644
index 0cf992b..0000000
--- a/mobile/src/main/java/ru/omni_devel/cards/EditCardActivity.kt
+++ /dev/null
@@ -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(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)
- }
-}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/EditCardScreen.kt b/mobile/src/main/java/ru/omni_devel/cards/EditCardScreen.kt
new file mode 100644
index 0000000..d16aaef
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/EditCardScreen.kt
@@ -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() }
+
+ 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
+ )
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/Elements.kt b/mobile/src/main/java/ru/omni_devel/cards/Elements.kt
index 31f0afb..4a88e63 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/Elements.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/Elements.kt
@@ -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? = 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)
+ )
+ }
}
}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/MainActivity.kt b/mobile/src/main/java/ru/omni_devel/cards/MainActivity.kt
index 7119e56..20baec9 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/MainActivity.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/MainActivity.kt
@@ -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()
-
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,
- onDeleteCard: (Int) -> Unit,
- getDbFun: () -> DbHelper
-) {
- val context = LocalContext.current
- val uriHandler = LocalUriHandler.current
+fun AppRoot(db: DbHelper) {
+ val cards = remember { mutableStateListOf() }
+ val backStack = remember { mutableStateListOf(Screen.Cards) }
+ val revealedCards = remember { mutableSetOf() }
- 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,
+ revealedCards: MutableSet,
+ 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,
+ )
}
}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/NavigationPageData.kt b/mobile/src/main/java/ru/omni_devel/cards/NavigationPageData.kt
deleted file mode 100644
index d504f93..0000000
--- a/mobile/src/main/java/ru/omni_devel/cards/NavigationPageData.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package ru.omni_devel.cards
-
-import android.app.Activity
-
-data class NavigationPageData(
- val name: String,
- val pageClass: Class? = null,
- val onClick: (() -> Unit)? = null
-)
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ScanResultView.kt b/mobile/src/main/java/ru/omni_devel/cards/ScanResultView.kt
new file mode 100644
index 0000000..df5ab16
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/ScanResultView.kt
@@ -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, 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, scaleFactor: Int) {
+ val vw = width.toFloat()
+ val vh = height.toFloat()
+ val mapped = ArrayList(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
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ScannerActivity.kt b/mobile/src/main/java/ru/omni_devel/cards/ScannerActivity.kt
new file mode 100644
index 0000000..1e7b807
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/ScannerActivity.kt
@@ -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(R.id.back_button).setOnClickListener { finish() }
+
+ val torch = findViewById(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)
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ShowCardActivity.kt b/mobile/src/main/java/ru/omni_devel/cards/ShowCardActivity.kt
deleted file mode 100644
index f805a5a..0000000
--- a/mobile/src/main/java/ru/omni_devel/cards/ShowCardActivity.kt
+++ /dev/null
@@ -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
- )
- }
- }
- }
-}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ShowCardScreen.kt b/mobile/src/main/java/ru/omni_devel/cards/ShowCardScreen.kt
new file mode 100644
index 0000000..9203b4f
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/ShowCardScreen.kt
@@ -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
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/StyledQrCode.kt b/mobile/src/main/java/ru/omni_devel/cards/StyledQrCode.kt
new file mode 100644
index 0000000..967b323
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/StyledQrCode.kt
@@ -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(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
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/Utils.kt b/mobile/src/main/java/ru/omni_devel/cards/Utils.kt
index 69f4bf0..c220a4c 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/Utils.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/Utils.kt
@@ -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? = 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): 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) {
+ }
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Color.kt b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Color.kt
index 326e98f..b9cf1cc 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Color.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Color.kt
@@ -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)
\ No newline at end of file
+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)
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Shape.kt b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Shape.kt
new file mode 100644
index 0000000..9fe4550
--- /dev/null
+++ b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Shape.kt
@@ -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),
+)
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Theme.kt b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Theme.kt
index 94aca9c..068c1f7 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Theme.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Theme.kt
@@ -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
)
-}
\ No newline at end of file
+}
diff --git a/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Type.kt b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Type.kt
index dc47ec7..5c067ba 100644
--- a/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Type.kt
+++ b/mobile/src/main/java/ru/omni_devel/cards/ui/theme/Type.kt
@@ -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
- )
- */
-)
\ No newline at end of file
+ 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),
+)
diff --git a/mobile/src/main/res/drawable/ic_arrow_back.xml b/mobile/src/main/res/drawable/ic_arrow_back.xml
new file mode 100644
index 0000000..5023514
--- /dev/null
+++ b/mobile/src/main/res/drawable/ic_arrow_back.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mobile/src/main/res/drawable/ic_flash_off.xml b/mobile/src/main/res/drawable/ic_flash_off.xml
new file mode 100644
index 0000000..4810e54
--- /dev/null
+++ b/mobile/src/main/res/drawable/ic_flash_off.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mobile/src/main/res/drawable/ic_flash_on.xml b/mobile/src/main/res/drawable/ic_flash_on.xml
new file mode 100644
index 0000000..b6b7d09
--- /dev/null
+++ b/mobile/src/main/res/drawable/ic_flash_on.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mobile/src/main/res/layout/activity_scanner.xml b/mobile/src/main/res/layout/activity_scanner.xml
new file mode 100644
index 0000000..cb925e6
--- /dev/null
+++ b/mobile/src/main/res/layout/activity_scanner.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/src/main/res/values-ru/strings.xml b/mobile/src/main/res/values-ru/strings.xml
index 3edbc59..babe998 100644
--- a/mobile/src/main/res/values-ru/strings.xml
+++ b/mobile/src/main/res/values-ru/strings.xml
@@ -9,7 +9,7 @@
Синхронизировать с WearOS
Синхронизация в процессе…
Успешная синхронизация
- Ошибка синхронизации. Проверьте подключение к часам
+ Не удалось. Установите OmniCards на часы и проверьте подключение
Резервное копирование
Резервное копирование
@@ -39,4 +39,23 @@
Имя карты должно быть короче 32 символов
Не удалось сгенерировать код карты. Проверьте данные
+
+ Карт: %d
+ Карта удалена
+ Поделиться
+ Карта лояльности
+ Иконка
+ Авто
+ Авто, по типу кода
+ Своя иконка
+ Из галереи
+ Изменить
+ Не удалось загрузить иконку
+ Поиск сервиса…
+ Цвет карты
+ Удалить
+ Монохром иконки
+ Удалить карту?
+ Сканирование
+ Вспышка
\ No newline at end of file
diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml
index efbc9b2..cdbf15e 100644
--- a/mobile/src/main/res/values/strings.xml
+++ b/mobile/src/main/res/values/strings.xml
@@ -14,7 +14,7 @@
Sync with WearOS
Sync in progress…
Sync successful
- Sync failed. Check your watch connection
+ Sync failed. Install OmniCards on your watch and check the connection
Backup
Backup
@@ -44,4 +44,23 @@
Card name must be shorter than 32 characters
Failed to generate card code. Check the card data
+
+ %d cards
+ Card deleted
+ Share
+ Loyalty card
+ Icon
+ Auto
+ Auto, by code type
+ Custom icon
+ From gallery
+ Change
+ Failed to load icon
+ Search service…
+ Card color
+ Delete
+ Monochrome icon
+ Delete card?
+ Scan card
+ Flash
\ No newline at end of file
diff --git a/mobile/src/main/res/xml/file_paths.xml b/mobile/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..14946c8
--- /dev/null
+++ b/mobile/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
index 385a844..6a947f2 100644
--- a/wear/build.gradle.kts
+++ b/wear/build.gradle.kts
@@ -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)
diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro
index 481bb43..72aeb12 100644
--- a/wear/proguard-rules.pro
+++ b/wear/proguard-rules.pro
@@ -18,4 +18,14 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.**
+-keepclassmembers @kotlinx.serialization.Serializable class ** {
+ *** Companion;
+ *** serializer(...);
+}
+-keepclasseswithmembers class **$$serializer { *; }
+-keepclassmembers enum com.google.zxing.BarcodeFormat { *; }
+
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/CardInfo.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/CardInfo.kt
index f60bd50..a3e65bd 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/CardInfo.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/CardInfo.kt
@@ -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?
-)
\ No newline at end of file
+ val color: String?,
+ val position: Int = 0,
+ val iconPath: String? = null,
+ val monochrome: Boolean = false,
+ val iconData: String? = null,
+)
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/DbHelper.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/DbHelper.kt
index f5d726c..496a9e9 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/DbHelper.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/DbHelper.kt
@@ -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) {
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 {
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()
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,17 +85,10 @@ 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()
return cardInfo
}
-}
\ No newline at end of file
+}
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/Elements.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/Elements.kt
index 4c14ef1..b52efd6 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/Elements.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/Elements.kt
@@ -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
+ )
}
}
-}
\ No newline at end of file
+}
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/MainActivity.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/MainActivity.kt
index eeb1a77..e72af75 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/MainActivity.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/MainActivity.kt
@@ -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) {
- 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)
+ }
}
}
}
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/MessagesReciever.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/MessagesReciever.kt
index 9b655b3..cd6368d 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/MessagesReciever.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/MessagesReciever.kt
@@ -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,22 +19,29 @@ 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>(json)
+ val cards = json.decodeFromString>(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)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/ShowCardActivity.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/ShowCardActivity.kt
index bce8d3a..efbd81b 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/ShowCardActivity.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/ShowCardActivity.kt
@@ -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),
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/Utils.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/Utils.kt
index 3eace92..fa7f99d 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/Utils.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/Utils.kt
@@ -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? = 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
}
diff --git a/wear/src/main/java/ru/omni_devel/cards/presentation/theme/Theme.kt b/wear/src/main/java/ru/omni_devel/cards/presentation/theme/Theme.kt
index 1ea92ab..504b816 100644
--- a/wear/src/main/java/ru/omni_devel/cards/presentation/theme/Theme.kt
+++ b/wear/src/main/java/ru/omni_devel/cards/presentation/theme/Theme.kt
@@ -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
)
-}
\ No newline at end of file
+}
diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml
new file mode 100644
index 0000000..19f541d
--- /dev/null
+++ b/wear/src/main/res/values/wear.xml
@@ -0,0 +1,5 @@
+
+
+ - omnicards_wear_app
+
+