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