added preview and some up
Android CI / Build and Test (push) Failing after 20m13s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt
2026-09-26 03:19:15 +07:00
parent c4c8f7a7cb
commit a715eca947
11 changed files with 1140 additions and 159 deletions
@@ -0,0 +1,313 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "e5e368788e85c41e4ad977ca43851521",
"entities": [
{
"tableName": "transactions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `amount` REAL NOT NULL, `date` INTEGER NOT NULL, `note` TEXT NOT NULL, `categoryName` TEXT NOT NULL, `categoryIcon` TEXT NOT NULL, `categoryEmoji` TEXT NOT NULL, `categoryColorName` TEXT NOT NULL, `isBalanceAdjustment` INTEGER NOT NULL, `kindRawValue` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "amount",
"columnName": "amount",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryName",
"columnName": "categoryName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryIcon",
"columnName": "categoryIcon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryEmoji",
"columnName": "categoryEmoji",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryColorName",
"columnName": "categoryColorName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isBalanceAdjustment",
"columnName": "isBalanceAdjustment",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kindRawValue",
"columnName": "kindRawValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_transactions_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_transactions_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_date` ON `${TABLE_NAME}` (`date`)"
}
]
},
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `emoji` TEXT NOT NULL, `colorName` TEXT NOT NULL, `kindRawValue` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emoji",
"columnName": "emoji",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "colorName",
"columnName": "colorName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kindRawValue",
"columnName": "kindRawValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_categories_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_categories_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_categories_name_kindRawValue",
"unique": false,
"columnNames": [
"name",
"kindRawValue"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_categories_name_kindRawValue` ON `${TABLE_NAME}` (`name`, `kindRawValue`)"
}
]
},
{
"tableName": "budgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `categoryName` TEXT NOT NULL, `categoryIcon` TEXT NOT NULL, `categoryEmoji` TEXT NOT NULL, `limitAmount` REAL NOT NULL, `monthStart` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryName",
"columnName": "categoryName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryIcon",
"columnName": "categoryIcon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryEmoji",
"columnName": "categoryEmoji",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "limitAmount",
"columnName": "limitAmount",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "monthStart",
"columnName": "monthStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_budgets_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_budgets_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_budgets_monthStart",
"unique": false,
"columnNames": [
"monthStart"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_budgets_monthStart` ON `${TABLE_NAME}` (`monthStart`)"
}
]
},
{
"tableName": "deletion_journal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entity` TEXT NOT NULL, `recordId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entity",
"columnName": "entity",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordId",
"columnName": "recordId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_deletion_journal_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_deletion_journal_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e5e368788e85c41e4ad977ca43851521')"
]
}
}
@@ -10,6 +10,7 @@ import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import androidx.core.content.edit
data class ServerSession(
val accessToken: String,
@@ -33,10 +34,10 @@ class SecureSessionStore(context: Context) {
.toString().toByteArray()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
preferences.edit()
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.putString("data", Base64.encodeToString(cipher.doFinal(source), Base64.NO_WRAP))
.apply()
preferences.edit {
putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.putString("data", Base64.encodeToString(cipher.doFinal(source), Base64.NO_WRAP))
}
}
fun load(): ServerSession? = runCatching {
@@ -58,7 +59,7 @@ class SecureSessionStore(context: Context) {
}
fun clear() {
preferences.edit().clear().apply()
preferences.edit { clear() }
}
private fun secretKey(): SecretKey {
@@ -66,7 +66,7 @@ class ServerApi(
return try {
parseSession(post("v1/auth/refresh", JSONObject().put("refreshToken", session.refreshToken), null))
.also { sessions.save(it) }
} catch (error: Throwable) {
} catch (_: Throwable) {
sessions.clear()
throw ServerException("Сессия истекла. Войдите снова.")
}
@@ -4,26 +4,27 @@ import android.content.Context
import java.net.URI
import java.security.MessageDigest
import java.util.UUID
import androidx.core.content.edit
class ServerPreferences(context: Context) {
private val values = context.getSharedPreferences("balance_settings", Context.MODE_PRIVATE)
var serverUrl: String
get() = values.getString("server_url", "") ?: ""
set(value) { values.edit().putString("server_url", value).apply() }
set(value) { values.edit { putString("server_url", value) } }
var email: String
get() = values.getString("server_email", "") ?: ""
set(value) { values.edit().putString("server_email", value).apply() }
set(value) { values.edit { putString("server_email", value) } }
var currency: String
get() = values.getString("currency", "RUB") ?: "RUB"
set(value) { values.edit().putString("currency", value).apply() }
set(value) { values.edit { putString("currency", value) } }
var theme: String
get() = values.getString("theme", "system") ?: "system"
set(value) { values.edit().putString("theme", value).apply() }
set(value) { values.edit { putString("theme", value) } }
val deviceId: String
get() = values.getString("device_id", null) ?: UUID.randomUUID().toString().also {
values.edit().putString("device_id", it).apply()
values.edit { putString("device_id", it) }
}
fun normalizeServer(input: String): String {
@@ -45,11 +46,21 @@ class ServerPreferences(context: Context) {
}
fun cursor(scope: String): Long = values.getLong("cursor_${hash(scope)}", 0).coerceAtLeast(0)
fun setCursor(scope: String, cursor: Long) = values.edit().putLong("cursor_${hash(scope)}", cursor).apply()
fun setCursor(scope: String, cursor: Long) = values.edit {
putLong(
"cursor_${hash(scope)}",
cursor
)
}
fun lastPush(scope: String): Long = values.getLong("push_${hash(scope)}", 0)
fun setLastPush(scope: String, value: Long) = values.edit().putLong("push_${hash(scope)}", value).apply()
fun setLastPush(scope: String, value: Long) = values.edit {
putLong(
"push_${hash(scope)}",
value
)
}
fun resetSync(scope: String) {
values.edit().remove("cursor_${hash(scope)}").remove("push_${hash(scope)}").apply()
values.edit { remove("cursor_${hash(scope)}").remove("push_${hash(scope)}") }
}
private fun hash(value: String): String = MessageDigest.getInstance("SHA-256")
@@ -32,8 +32,8 @@ class SyncRepository(
val status: StateFlow<SyncStatus> = mutableStatus
val session: StateFlow<ServerSession?> = mutableSession
fun serverUrl(): String = preferences.serverUrl
fun savedEmail(): String = preferences.email
// fun serverUrl(): String = preferences.serverUrl
// fun savedEmail(): String = preferences.email
suspend fun changeServer(value: String): String {
return try {
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoAwesome
@@ -26,11 +29,9 @@ import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Construction
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.DirectionsCar
import androidx.compose.material.icons.filled.DirectionsRun
import androidx.compose.material.icons.filled.DirectionsWalk
import androidx.compose.material.icons.filled.Eco
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Fastfood
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Flight
import androidx.compose.material.icons.filled.Home
@@ -42,7 +43,6 @@ import androidx.compose.material.icons.filled.LocalGasStation
import androidx.compose.material.icons.filled.LocalGroceryStore
import androidx.compose.material.icons.filled.MedicalServices
import androidx.compose.material.icons.filled.Medication
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.MusicNote
@@ -75,10 +75,13 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.balanceandroid.data.CategoryDesign
import com.example.balanceandroid.data.CategoryTotal
import com.example.balanceandroid.ui.theme.BalanceGreen
import com.example.balanceandroid.ui.theme.BalanceTheme
import java.text.NumberFormat
import java.time.Instant
import java.time.ZoneId
@@ -148,12 +151,12 @@ fun iconFor(name: String): ImageVector = when (name) {
"printer.fill" -> Icons.Default.Print
"pawprint.fill" -> Icons.Default.Pets
"leaf.fill" -> Icons.Default.Eco
"figure.walk" -> Icons.Default.DirectionsWalk
"figure.run" -> Icons.Default.DirectionsRun
"figure.walk" -> Icons.AutoMirrored.Filled.DirectionsWalk
"figure.run" -> Icons.AutoMirrored.Filled.DirectionsRun
"dumbbell.fill" -> Icons.Default.FitnessCenter
"cross.case.fill", "stethoscope" -> Icons.Default.MedicalServices
"pills.fill" -> Icons.Default.Medication
"book.fill" -> Icons.Default.MenuBook
"book.fill" -> Icons.AutoMirrored.Filled.MenuBook
"school", "graduationcap.fill" -> Icons.Default.School
"laptopcomputer" -> Icons.Default.Laptop
"briefcase.fill" -> Icons.Default.Work
@@ -227,3 +230,34 @@ fun periodBounds(months: Int, now: Long = System.currentTimeMillis()): Pair<Long
val end = currentStart.plusMonths(1)
return start.toInstant().toEpochMilli() to end.toInstant().toEpochMilli()
}
@Preview(name = "Metric Card", showBackground = true)
@Composable
fun MetricCardPreview() {
BalanceTheme(theme = "dark") {
MetricCard("Доходы", "120 000 ₽", BalanceGreen)
}
}
@Preview(name = "Category Badge", showBackground = true)
@Composable
fun CategoryBadgePreview() {
BalanceTheme(theme = "dark") {
CategoryBadge(CategoryDesign("Продукты", "cart.fill", "🛒", "orange", "expense"))
}
}
@Preview(name = "Spending Bars", showBackground = true)
@Composable
fun SpendingBarsPreview() {
BalanceTheme(theme = "dark") {
SpendingBars(
listOf(
CategoryTotal("Продукты", "cart.fill", "🛒", "orange", 12500.0),
CategoryTotal("Кафе", "fork.knife", "☕", "indigo", 4200.0),
CategoryTotal("Транспорт", "car.fill", "🚗", "purple", 3100.0),
)
)
}
}
@@ -31,6 +31,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,14 +54,14 @@ import java.time.format.DateTimeFormatter
@Composable
fun TransactionEditorDialog(
model: MainViewModel,
state: FinanceUiState,
// state: FinanceUiState,
existing: TransactionEntity?,
onDismiss: () -> Unit,
) {
var amount by remember(existing?.id) { mutableStateOf(existing?.amount?.toString().orEmpty()) }
var kind by remember(existing?.id) { mutableStateOf(existing?.kindRawValue ?: "expense") }
var note by remember(existing?.id) { mutableStateOf(existing?.note.orEmpty()) }
var date by remember(existing?.id) { mutableStateOf(existing?.date ?: System.currentTimeMillis()) }
var date by remember(existing?.id) { mutableLongStateOf(existing?.date ?: System.currentTimeMillis()) }
val categories = model.categories(kind)
var categoryName by remember(existing?.id) { mutableStateOf(existing?.categoryName ?: categories.first().name) }
val selected = categories.firstOrNull { it.name == categoryName } ?: categories.first()
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
id("com.android.application") version "8.13.0" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
+10
View File
@@ -6,3 +6,13 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
kotlin.daemon.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=512m
org.gradle.workers.max=2
org.gradle.parallel=false
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME