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