This commit is contained in:
wt committed 2026-07-14 16:20:39 +07:00
commit bd90fe1d65
137 files changed
+12107

No files matched your search

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".BalanceApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/balance_icon"
android:label="Баланс"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@drawable/balance_icon"
android:supportsRtl="true"
android:theme="@style/Theme.Balance"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,33 @@
package com.example.balanceandroid
import android.app.Application
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.example.balanceandroid.data.AppDatabase
import com.example.balanceandroid.sync.SecureSessionStore
import com.example.balanceandroid.sync.ServerPreferences
import com.example.balanceandroid.sync.SyncRepository
import com.example.balanceandroid.sync.SyncWorker
import java.util.concurrent.TimeUnit
class BalanceApplication : Application() {
val database by lazy { AppDatabase.get(this) }
val preferences by lazy { ServerPreferences(this) }
val sessions by lazy { SecureSessionStore(this) }
val syncRepository by lazy { SyncRepository(database, preferences, sessions) }
override fun onCreate() {
super.onCreate()
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"balance-periodic-sync",
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
}
@@ -0,0 +1,25 @@
package com.example.balanceandroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.balanceandroid.ui.BalanceRoot
import com.example.balanceandroid.ui.theme.BalanceTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val model: MainViewModel = viewModel(factory = MainViewModel.Factory(application as BalanceApplication))
val state by model.state.collectAsStateWithLifecycle()
BalanceTheme(theme = state.theme) {
BalanceRoot(model, state)
}
}
}
}
@@ -0,0 +1,172 @@
package com.example.balanceandroid
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.balanceandroid.data.BudgetEntity
import com.example.balanceandroid.data.BuiltInCategories
import com.example.balanceandroid.data.CategoryDesign
import com.example.balanceandroid.data.CategoryEntity
import com.example.balanceandroid.data.FinanceDao
import com.example.balanceandroid.data.TransactionEntity
import com.example.balanceandroid.sync.ServerPreferences
import com.example.balanceandroid.sync.ServerSession
import com.example.balanceandroid.sync.SyncRepository
import com.example.balanceandroid.sync.SyncStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
data class FinanceData(
val transactions: List<TransactionEntity> = emptyList(),
val categories: List<CategoryEntity> = emptyList(),
val budgets: List<BudgetEntity> = emptyList(),
)
data class FinanceUiState(
val data: FinanceData = FinanceData(),
val sync: SyncStatus = SyncStatus(),
val session: ServerSession? = null,
val currency: String = "RUB",
val theme: String = "system",
val serverUrl: String = "",
val serverEmail: String = "",
)
class MainViewModel(
private val dao: FinanceDao,
private val preferences: ServerPreferences,
private val syncRepository: SyncRepository,
) : ViewModel() {
private val settingsRevision = MutableStateFlow(0)
private val financeData = combine(
dao.observeTransactions(),
dao.observeCategories(),
dao.observeBudgets(),
) { transactions, categories, budgets -> FinanceData(transactions, categories, budgets) }
val state: StateFlow<FinanceUiState> = combine(
financeData,
syncRepository.status,
syncRepository.session,
settingsRevision,
) { data, sync, session, _ ->
FinanceUiState(
data = data,
sync = sync,
session = session,
currency = preferences.currency,
theme = preferences.theme,
serverUrl = preferences.serverUrl,
serverEmail = preferences.email,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FinanceUiState())
init { viewModelScope.launch { syncRepository.sync() } }
fun categories(kind: String, data: FinanceData = state.value.data): List<CategoryDesign> {
val builtIn = if (kind == "income") BuiltInCategories.incomes else BuiltInCategories.expenses
return builtIn + data.categories.filter { it.kindRawValue == kind }.map {
CategoryDesign(it.name, it.icon, it.emoji, it.colorName, it.kindRawValue)
}
}
fun saveTransaction(
existing: TransactionEntity?, amount: Double, kind: String, category: CategoryDesign,
note: String, date: Long,
) = viewModelScope.launch {
val now = System.currentTimeMillis()
dao.upsertTransaction((existing ?: TransactionEntity(amount = amount, categoryName = category.name)).copy(
amount = amount,
date = date,
note = note.trim(),
categoryName = category.name,
categoryIcon = category.icon,
categoryEmoji = category.emoji,
categoryColorName = category.colorName,
kindRawValue = kind,
updatedAt = now,
))
}
fun deleteTransaction(value: TransactionEntity) = viewModelScope.launch { dao.deleteTransaction(value) }
fun adjustBalance(target: Double, note: String) = viewModelScope.launch {
val current = com.example.balanceandroid.data.FinanceMath.totalBalance(state.value.data.transactions)
val difference = target - current
if (kotlin.math.abs(difference) < 0.005) return@launch
dao.upsertTransaction(TransactionEntity(
amount = kotlin.math.abs(difference),
note = note.ifBlank { "Ручная корректировка баланса" },
categoryName = "Корректировка",
categoryIcon = "tune",
categoryColorName = "indigo",
isBalanceAdjustment = true,
kindRawValue = if (difference > 0) "income" else "expense",
))
}
fun saveCategory(existing: CategoryEntity?, name: String, kind: String, icon: String, emoji: String, color: String) = viewModelScope.launch {
val trimmed = name.trim()
if (trimmed.isEmpty()) return@launch
val now = System.currentTimeMillis()
if (existing != null) {
val oldName = existing.name
val updated = existing.copy(name = trimmed, icon = icon, emoji = emoji, colorName = color, updatedAt = now)
dao.upsertCategory(updated)
dao.upsertTransactions(dao.transactionsForCategory(oldName).map {
it.copy(categoryName = trimmed, categoryIcon = icon, categoryEmoji = emoji, categoryColorName = color, updatedAt = now)
})
dao.upsertBudgets(dao.budgetsForCategory(oldName).map {
it.copy(categoryName = trimmed, categoryIcon = icon, categoryEmoji = emoji, updatedAt = now)
})
} else {
dao.upsertCategory(CategoryEntity(name = trimmed, icon = icon, emoji = emoji, colorName = color, kindRawValue = kind))
}
}
fun deleteCategory(value: CategoryEntity) = viewModelScope.launch { dao.deleteCategory(value) }
fun saveBudget(category: CategoryDesign, amount: Double, monthStart: Long = currentMonthStart()) = viewModelScope.launch {
val existing = state.value.data.budgets.firstOrNull { it.categoryName == category.name && it.monthStart == monthStart }
if (amount <= 0 && existing != null) dao.deleteBudget(existing)
else if (amount > 0) dao.upsertBudget((existing ?: BudgetEntity(
categoryName = category.name,
categoryIcon = category.icon,
categoryEmoji = category.emoji,
limitAmount = amount,
monthStart = monthStart,
)).copy(limitAmount = amount, categoryIcon = category.icon, categoryEmoji = category.emoji, updatedAt = System.currentTimeMillis()))
}
fun setCurrency(value: String) { preferences.currency = value; settingsRevision.value++ }
fun setTheme(value: String) { preferences.theme = value; settingsRevision.value++ }
fun changeServer(value: String) = viewModelScope.launch {
runCatching { syncRepository.changeServer(value) }
.onSuccess { settingsRevision.value++ }
}
fun login(email: String, password: String) = viewModelScope.launch { syncRepository.login(email, password) }
fun register(email: String, password: String) = viewModelScope.launch { syncRepository.register(email, password) }
fun logout() = viewModelScope.launch { syncRepository.logout() }
fun sync() = viewModelScope.launch { syncRepository.sync() }
fun resetSync() = viewModelScope.launch { syncRepository.resetAndSync() }
companion object {
fun currentMonthStart(now: Long = System.currentTimeMillis()): Long = Instant.ofEpochMilli(now)
.atZone(ZoneId.systemDefault()).withDayOfMonth(1).toLocalDate().atStartOfDay(ZoneId.systemDefault())
.toInstant().toEpochMilli()
}
class Factory(private val application: BalanceApplication) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel(
application.database.financeDao(), application.preferences, application.syncRepository,
) as T
}
}
@@ -0,0 +1,27 @@
package com.example.balanceandroid.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [TransactionEntity::class, CategoryEntity::class, BudgetEntity::class, DeletionEntity::class],
version = 1,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun financeDao(): FinanceDao
companion object {
@Volatile private var instance: AppDatabase? = null
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"balance-android.db",
).build().also { instance = it }
}
}
}
@@ -0,0 +1,86 @@
package com.example.balanceandroid.data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "transactions", indices = [Index(value = ["updatedAt"]), Index(value = ["date"])])
data class TransactionEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val amount: Double,
val date: Long = System.currentTimeMillis(),
val note: String = "",
val categoryName: String,
val categoryIcon: String = "circle",
val categoryEmoji: String = "",
val categoryColorName: String = "indigo",
val isBalanceAdjustment: Boolean = false,
val kindRawValue: String = "expense",
val updatedAt: Long = System.currentTimeMillis(),
)
@Entity(tableName = "categories", indices = [Index(value = ["updatedAt"]), Index(value = ["name", "kindRawValue"])])
data class CategoryEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val name: String,
val icon: String = "star",
val emoji: String = "",
val colorName: String = "indigo",
val kindRawValue: String = "expense",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)
@Entity(tableName = "budgets", indices = [Index(value = ["updatedAt"]), Index(value = ["monthStart"])])
data class BudgetEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val categoryName: String,
val categoryIcon: String = "circle",
val categoryEmoji: String = "",
val limitAmount: Double,
val monthStart: Long,
val updatedAt: Long = System.currentTimeMillis(),
)
@Entity(tableName = "deletion_journal", indices = [Index(value = ["updatedAt"])])
data class DeletionEntity(
@PrimaryKey val id: String,
val entity: String,
val recordId: String,
val updatedAt: Long = System.currentTimeMillis(),
) {
companion object {
fun create(entity: String, recordId: String, updatedAt: Long = System.currentTimeMillis()) =
DeletionEntity("$entity:$recordId", entity, recordId, updatedAt)
}
}
data class CategoryDesign(
val name: String,
val icon: String,
val emoji: String = "",
val colorName: String,
val kind: String,
)
object BuiltInCategories {
val expenses = listOf(
CategoryDesign("Продукты", "cart.fill", "", "orange", "expense"),
CategoryDesign("Транспорт", "car.fill", "", "blue", "expense"),
CategoryDesign("Дом", "house.fill", "", "indigo", "expense"),
CategoryDesign("Развлечения", "gamecontroller.fill", "", "pink", "expense"),
CategoryDesign("Здоровье", "cross.case.fill", "", "red", "expense"),
CategoryDesign("Покупки", "bag.fill", "", "purple", "expense"),
CategoryDesign("Подписки", "repeat", "", "cyan", "expense"),
CategoryDesign("Образование", "book.fill", "", "mint", "expense"),
CategoryDesign("Другое", "ellipsis.circle.fill", "", "gray", "expense"),
)
val incomes = listOf(
CategoryDesign("Зарплата", "banknote.fill", "", "green", "income"),
CategoryDesign("Подработка", "laptopcomputer", "", "teal", "income"),
CategoryDesign("Инвестиции", "chart.line.uptrend.xyaxis", "", "blue", "income"),
CategoryDesign("Подарок", "gift.fill", "", "pink", "income"),
CategoryDesign("Другое", "plus.circle.fill", "", "gray", "income"),
)
}
@@ -0,0 +1,73 @@
package com.example.balanceandroid.data
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface FinanceDao {
@Query("SELECT * FROM transactions ORDER BY date DESC")
fun observeTransactions(): Flow<List<TransactionEntity>>
@Query("SELECT * FROM categories ORDER BY createdAt")
fun observeCategories(): Flow<List<CategoryEntity>>
@Query("SELECT * FROM budgets ORDER BY monthStart DESC, categoryName")
fun observeBudgets(): Flow<List<BudgetEntity>>
@Upsert suspend fun upsertTransaction(value: TransactionEntity)
@Upsert suspend fun upsertTransactions(values: List<TransactionEntity>)
@Upsert suspend fun upsertCategory(value: CategoryEntity)
@Upsert suspend fun upsertCategories(values: List<CategoryEntity>)
@Upsert suspend fun upsertBudget(value: BudgetEntity)
@Upsert suspend fun upsertBudgets(values: List<BudgetEntity>)
@Upsert suspend fun upsertDeletion(value: DeletionEntity)
@Query("SELECT * FROM transactions WHERE id = :id LIMIT 1")
suspend fun transaction(id: String): TransactionEntity?
@Query("SELECT * FROM categories WHERE id = :id LIMIT 1")
suspend fun category(id: String): CategoryEntity?
@Query("SELECT * FROM budgets WHERE id = :id LIMIT 1")
suspend fun budget(id: String): BudgetEntity?
@Query("SELECT * FROM transactions WHERE categoryName = :name")
suspend fun transactionsForCategory(name: String): List<TransactionEntity>
@Query("SELECT * FROM budgets WHERE categoryName = :name")
suspend fun budgetsForCategory(name: String): List<BudgetEntity>
@Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteTransactionDirect(id: String)
@Query("DELETE FROM categories WHERE id = :id") suspend fun deleteCategoryDirect(id: String)
@Query("DELETE FROM budgets WHERE id = :id") suspend fun deleteBudgetDirect(id: String)
@Query("DELETE FROM deletion_journal WHERE entity = :entity AND recordId = :recordId AND updatedAt <= :through")
suspend fun clearDeletion(entity: String, recordId: String, through: Long)
@Query("SELECT * FROM transactions WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
suspend fun transactionChanges(after: Long, through: Long, limit: Int, offset: Int): List<TransactionEntity>
@Query("SELECT * FROM categories WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
suspend fun categoryChanges(after: Long, through: Long, limit: Int, offset: Int): List<CategoryEntity>
@Query("SELECT * FROM budgets WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
suspend fun budgetChanges(after: Long, through: Long, limit: Int, offset: Int): List<BudgetEntity>
@Query("SELECT * FROM deletion_journal WHERE updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
suspend fun deletionChanges(through: Long, limit: Int, offset: Int): List<DeletionEntity>
@Transaction
suspend fun deleteTransaction(value: TransactionEntity) {
upsertDeletion(DeletionEntity.create("transaction", value.id))
deleteTransactionDirect(value.id)
}
@Transaction
suspend fun deleteBudget(value: BudgetEntity) {
upsertDeletion(DeletionEntity.create("budget", value.id))
deleteBudgetDirect(value.id)
}
@Transaction
suspend fun deleteCategory(value: CategoryEntity) {
budgetsForCategory(value.name).forEach { deleteBudget(it) }
upsertDeletion(DeletionEntity.create("category", value.id))
deleteCategoryDirect(value.id)
}
}
@@ -0,0 +1,37 @@
package com.example.balanceandroid.data
data class FinanceSummary(val income: Double, val expenses: Double) {
val balance: Double get() = income - expenses
}
data class CategoryTotal(
val name: String,
val icon: String,
val emoji: String,
val colorName: String,
val amount: Double,
)
object FinanceMath {
fun totalBalance(items: List<TransactionEntity>): Double = items.sumOf {
if (it.kindRawValue == "income") it.amount else -it.amount
}
fun summary(items: List<TransactionEntity>, start: Long, endExclusive: Long): FinanceSummary {
val filtered = items.filter { !it.isBalanceAdjustment && it.date >= start && it.date < endExclusive }
return FinanceSummary(
income = filtered.filter { it.kindRawValue == "income" }.sumOf { it.amount },
expenses = filtered.filter { it.kindRawValue == "expense" }.sumOf { it.amount },
)
}
fun spending(items: List<TransactionEntity>, start: Long, endExclusive: Long): List<CategoryTotal> =
items.asSequence()
.filter { it.kindRawValue == "expense" && !it.isBalanceAdjustment && it.date >= start && it.date < endExclusive }
.groupBy { it.categoryName }
.map { (name, values) ->
val first = values.first()
CategoryTotal(name, first.categoryIcon, first.categoryEmoji, first.categoryColorName, values.sumOf { it.amount })
}
.sortedByDescending { it.amount }
}
@@ -0,0 +1,80 @@
package com.example.balanceandroid.sync
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
data class ServerSession(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long,
val userId: String,
val email: String,
)
class SecureSessionStore(context: Context) {
private val preferences = context.getSharedPreferences("balance_secure_session", Context.MODE_PRIVATE)
private val alias = "balance.server.session.key"
fun save(session: ServerSession) {
val source = JSONObject()
.put("accessToken", session.accessToken)
.put("refreshToken", session.refreshToken)
.put("expiresAt", session.expiresAt)
.put("userId", session.userId)
.put("email", session.email)
.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()
}
fun load(): ServerSession? = runCatching {
val iv = Base64.decode(preferences.getString("iv", null), Base64.NO_WRAP)
val encrypted = Base64.decode(preferences.getString("data", null), Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv))
val json = JSONObject(String(cipher.doFinal(encrypted)))
ServerSession(
accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"),
expiresAt = json.getLong("expiresAt"),
userId = json.getString("userId"),
email = json.getString("email"),
)
}.getOrElse {
clear()
null
}
fun clear() {
preferences.edit().clear().apply()
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getKey(alias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(
KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
)
generateKey()
}
}
}
@@ -0,0 +1,111 @@
package com.example.balanceandroid.sync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
class ServerException(message: String, val status: Int = 0) : Exception(message)
data class SyncPage(
val cursor: Long,
val changes: JSONArray,
val hasMore: Boolean,
)
class ServerApi(
private val preferences: ServerPreferences,
private val sessions: SecureSessionStore,
) {
suspend fun register(email: String, password: String): ServerSession = authenticate("v1/auth/register", email, password)
suspend fun login(email: String, password: String): ServerSession = authenticate("v1/auth/login", email, password)
suspend fun logout() {
sessions.load()?.let { session ->
runCatching { post("v1/auth/logout", JSONObject().put("refreshToken", session.refreshToken), null) }
}
sessions.clear()
}
suspend fun sync(cursor: Long, changes: JSONArray, pull: Boolean): SyncPage {
val body = JSONObject()
.put("cursor", cursor)
.put("deviceId", preferences.deviceId)
.put("changes", changes)
.put("pull", pull)
val response = authorizedPost("v1/sync", body)
return SyncPage(
cursor = response.optLong("cursor", cursor),
changes = response.optJSONArray("changes") ?: JSONArray(),
hasMore = response.optBoolean("hasMore", false),
)
}
private suspend fun authenticate(path: String, email: String, password: String): ServerSession {
val response = post(path, JSONObject().put("email", email.trim()).put("password", password), null)
return parseSession(response).also { sessions.save(it) }
}
private suspend fun authorizedPost(path: String, body: JSONObject, retry: Boolean = true): JSONObject {
var session = sessions.load() ?: throw ServerException("Сначала войдите в аккаунт")
if (session.expiresAt - System.currentTimeMillis() < 60_000) session = refresh(session)
return try {
post(path, body, session.accessToken)
} catch (error: ServerException) {
if (error.status == 401 && retry) {
session = refresh(session)
post(path, body, session.accessToken)
} else throw error
}
}
private suspend fun refresh(session: ServerSession): ServerSession {
return try {
parseSession(post("v1/auth/refresh", JSONObject().put("refreshToken", session.refreshToken), null))
.also { sessions.save(it) }
} catch (error: Throwable) {
sessions.clear()
throw ServerException("Сессия истекла. Войдите снова.")
}
}
private fun parseSession(json: JSONObject): ServerSession {
val user = json.getJSONObject("user")
return ServerSession(
accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"),
expiresAt = Instant.parse(json.getString("expiresAt")).toEpochMilli(),
userId = user.getString("id"),
email = user.getString("email"),
)
}
private suspend fun post(path: String, body: JSONObject, token: String?): JSONObject = withContext(Dispatchers.IO) {
val base = preferences.normalizeServer(preferences.serverUrl)
val connection = (URL("$base/$path").openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 15_000
readTimeout = 30_000
doOutput = true
setRequestProperty("Content-Type", "application/json; charset=utf-8")
if (token != null) setRequestProperty("Authorization", "Bearer $token")
}
try {
connection.outputStream.use { it.write(body.toString().toByteArray()) }
val status = connection.responseCode
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
if (status !in 200..299) {
val message = runCatching { JSONObject(text).getJSONObject("error").getString("message") }
.getOrDefault("Ошибка сервера: HTTP $status")
throw ServerException(message, status)
}
if (text.isBlank()) JSONObject() else JSONObject(text)
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,57 @@
package com.example.balanceandroid.sync
import android.content.Context
import java.net.URI
import java.security.MessageDigest
import java.util.UUID
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() }
var email: String
get() = values.getString("server_email", "") ?: ""
set(value) { values.edit().putString("server_email", value).apply() }
var currency: String
get() = values.getString("currency", "RUB") ?: "RUB"
set(value) { values.edit().putString("currency", value).apply() }
var theme: String
get() = values.getString("theme", "system") ?: "system"
set(value) { values.edit().putString("theme", value).apply() }
val deviceId: String
get() = values.getString("device_id", null) ?: UUID.randomUUID().toString().also {
values.edit().putString("device_id", it).apply()
}
fun normalizeServer(input: String): String {
val trimmed = input.trim().trimEnd('/')
val uri = runCatching { URI(trimmed) }.getOrNull()
?: error("Укажите полный адрес сервера")
val host = uri.host?.lowercase() ?: error("Укажите полный адрес сервера")
val scheme = uri.scheme?.lowercase()
require(scheme == "https" || scheme == "http") { "Адрес должен начинаться с https://" }
require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {
"Адрес сервера не должен содержать логин, параметры или фрагмент"
}
if (scheme == "http") {
require(host in setOf("localhost", "127.0.0.1", "10.0.2.2")) {
"Для удалённого сервера требуется HTTPS"
}
}
return trimmed
}
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 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 resetSync(scope: String) {
values.edit().remove("cursor_${hash(scope)}").remove("push_${hash(scope)}").apply()
}
private fun hash(value: String): String = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray()).take(12).joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,290 @@
package com.example.balanceandroid.sync
import androidx.room.withTransaction
import com.example.balanceandroid.data.AppDatabase
import com.example.balanceandroid.data.BudgetEntity
import com.example.balanceandroid.data.CategoryEntity
import com.example.balanceandroid.data.TransactionEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.json.JSONObject
import java.time.Instant
data class SyncStatus(
val running: Boolean = false,
val message: String? = null,
val lastSyncAt: Long? = null,
)
class SyncRepository(
private val database: AppDatabase,
private val preferences: ServerPreferences,
private val sessions: SecureSessionStore,
) {
private val dao = database.financeDao()
private val api = ServerApi(preferences, sessions)
private val mutex = Mutex()
private val mutableStatus = MutableStateFlow(SyncStatus())
private val mutableSession = MutableStateFlow(sessions.load())
val status: StateFlow<SyncStatus> = mutableStatus
val session: StateFlow<ServerSession?> = mutableSession
fun serverUrl(): String = preferences.serverUrl
fun savedEmail(): String = preferences.email
suspend fun changeServer(value: String): String {
return try {
val normalized = preferences.normalizeServer(value)
val changed = normalized != preferences.serverUrl
if (changed) {
runCatching { api.logout() }
sessions.clear()
mutableSession.value = null
preferences.serverUrl = normalized
}
mutableStatus.value = SyncStatus(message = if (changed) "Адрес сохранён. Войдите заново." else "Адрес сохранён")
normalized
} catch (error: Throwable) {
mutableStatus.value = SyncStatus(message = error.message ?: "Некорректный адрес сервера")
throw error
}
}
suspend fun register(email: String, password: String) = authenticate(email) { api.register(email, password) }
suspend fun login(email: String, password: String) = authenticate(email) { api.login(email, password) }
private suspend fun authenticate(email: String, action: suspend () -> ServerSession) {
mutableStatus.value = SyncStatus(running = true)
runCatching { action() }
.onSuccess {
preferences.email = email.trim()
mutableSession.value = it
mutableStatus.value = SyncStatus(message = "Вход выполнен")
sync()
}
.onFailure { mutableStatus.value = SyncStatus(message = it.message ?: "Ошибка входа") }
}
suspend fun logout() {
if (mutableStatus.value.running) return
runCatching { api.logout() }
sessions.clear()
mutableSession.value = null
mutableStatus.value = SyncStatus(message = "Вы вышли из аккаунта")
}
suspend fun resetAndSync() {
val session = sessions.load() ?: return
preferences.resetSync(scope(session))
sync()
}
suspend fun sync(): Boolean = mutex.withLock {
val session = sessions.load() ?: return@withLock false
if (preferences.serverUrl.isBlank()) return@withLock false
mutableSession.value = session
mutableStatus.value = mutableStatus.value.copy(running = true, message = null)
val result = runCatching {
val scope = scope(session)
val cutoff = System.currentTimeMillis()
val lastPush = preferences.lastPush(scope)
val threshold = if (lastPush <= cutoff + 5_000) (lastPush - 5_000).coerceAtLeast(0) else 0
val cursor = preferences.cursor(scope)
pushTransactions(threshold, cutoff, cursor)
pushCategories(threshold, cutoff, cursor)
pushBudgets(threshold, cutoff, cursor)
pushDeletions(cutoff, cursor)
pull(scope, cursor)
preferences.setLastPush(scope, cutoff)
}
return@withLock result.fold(
onSuccess = {
val now = System.currentTimeMillis()
mutableStatus.value = SyncStatus(message = "Синхронизация завершена", lastSyncAt = now)
true
},
onFailure = {
if (it is ServerException && it.status == 401) {
sessions.clear()
mutableSession.value = null
}
mutableStatus.value = SyncStatus(message = it.message ?: "Ошибка синхронизации")
false
},
)
}
private suspend fun pushTransactions(after: Long, through: Long, cursor: Long) {
var offset = 0
while (true) {
val items = dao.transactionChanges(after, through, BATCH_SIZE, offset)
if (items.isEmpty()) break
push(cursor, items.map(::transactionChange))
offset += items.size
if (items.size < BATCH_SIZE) break
}
}
private suspend fun pushCategories(after: Long, through: Long, cursor: Long) {
var offset = 0
while (true) {
val items = dao.categoryChanges(after, through, BATCH_SIZE, offset)
if (items.isEmpty()) break
push(cursor, items.map(::categoryChange))
offset += items.size
if (items.size < BATCH_SIZE) break
}
}
private suspend fun pushBudgets(after: Long, through: Long, cursor: Long) {
var offset = 0
while (true) {
val items = dao.budgetChanges(after, through, BATCH_SIZE, offset)
if (items.isEmpty()) break
push(cursor, items.map(::budgetChange))
offset += items.size
if (items.size < BATCH_SIZE) break
}
}
private suspend fun pushDeletions(through: Long, cursor: Long) {
var offset = 0
while (true) {
val items = dao.deletionChanges(through, BATCH_SIZE, offset)
if (items.isEmpty()) break
push(cursor, items.map {
JSONObject().put("entity", it.entity).put("id", it.recordId)
.put("deleted", true).put("updatedAt", iso(it.updatedAt))
})
offset += items.size
if (items.size < BATCH_SIZE) break
}
}
private suspend fun push(cursor: Long, changes: List<JSONObject>) {
if (changes.isEmpty()) return
val array = JSONArray().apply { changes.forEach { put(it) } }
if (array.toString().toByteArray().size > 1_500_000 && changes.size > 1) {
val middle = changes.size / 2
push(cursor, changes.subList(0, middle))
push(cursor, changes.subList(middle, changes.size))
} else {
api.sync(cursor, array, pull = false)
}
}
private suspend fun pull(scope: String, initialCursor: Long) {
var cursor = initialCursor
do {
val page = api.sync(cursor, JSONArray(), pull = true)
require(page.cursor >= cursor) { "Сервер вернул некорректный курсор" }
database.withTransaction { apply(page.changes) }
preferences.setCursor(scope, page.cursor)
if (!page.hasMore) return
require(page.cursor > cursor) { "Сервер не смог продолжить синхронизацию" }
cursor = page.cursor
} while (true)
}
private suspend fun apply(changes: JSONArray) {
val sorted = (0 until changes.length()).mapNotNull { changes.optJSONObject(it) }
.sortedBy { it.optLong("sequence") }
for (change in sorted) {
val entity = change.optString("entity")
val id = change.optString("id")
val updatedAt = parseTime(change.optString("updatedAt")) ?: continue
if (change.optBoolean("deleted")) {
when (entity) {
"transaction" -> dao.transaction(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteTransactionDirect(id) }
"category" -> dao.category(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteCategoryDirect(id) }
"budget" -> dao.budget(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteBudgetDirect(id) }
}
} else {
val payload = change.optJSONObject("payload") ?: continue
runCatching {
when (entity) {
"transaction" -> applyTransaction(id, updatedAt, payload)
"category" -> applyCategory(id, updatedAt, payload)
"budget" -> applyBudget(id, updatedAt, payload)
}
}
}
dao.clearDeletion(entity, id, updatedAt)
}
}
private suspend fun applyTransaction(id: String, updatedAt: Long, value: JSONObject) {
if ((dao.transaction(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return
dao.upsertTransaction(TransactionEntity(
id = id,
amount = value.getDouble("amount"),
date = requireTime(value.getString("date")),
note = value.optString("note"),
categoryName = value.getString("categoryName"),
categoryIcon = value.optString("categoryIcon", "circle"),
categoryEmoji = value.optString("categoryEmoji"),
categoryColorName = value.optString("categoryColorName", "indigo"),
isBalanceAdjustment = value.optBoolean("isBalanceAdjustment"),
kindRawValue = value.optString("kindRawValue", "expense"),
updatedAt = updatedAt,
))
}
private suspend fun applyCategory(id: String, updatedAt: Long, value: JSONObject) {
if ((dao.category(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return
dao.upsertCategory(CategoryEntity(
id = id,
name = value.getString("name"),
icon = value.optString("icon", "star"),
emoji = value.optString("emoji"),
colorName = value.optString("colorName", "indigo"),
kindRawValue = value.optString("kindRawValue", "expense"),
createdAt = requireTime(value.getString("createdAt")),
updatedAt = updatedAt,
))
}
private suspend fun applyBudget(id: String, updatedAt: Long, value: JSONObject) {
if ((dao.budget(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return
dao.upsertBudget(BudgetEntity(
id = id,
categoryName = value.getString("categoryName"),
categoryIcon = value.optString("categoryIcon", "circle"),
categoryEmoji = value.optString("categoryEmoji"),
limitAmount = value.getDouble("limit"),
monthStart = requireTime(value.getString("monthStart")),
updatedAt = updatedAt,
))
}
private fun transactionChange(value: TransactionEntity) = change("transaction", value.id, value.updatedAt, JSONObject()
.put("amount", value.amount).put("date", iso(value.date)).put("note", value.note)
.put("categoryName", value.categoryName).put("categoryIcon", value.categoryIcon)
.put("categoryEmoji", value.categoryEmoji).put("categoryColorName", value.categoryColorName)
.put("isBalanceAdjustment", value.isBalanceAdjustment).put("kindRawValue", value.kindRawValue))
private fun categoryChange(value: CategoryEntity) = change("category", value.id, value.updatedAt, JSONObject()
.put("name", value.name).put("icon", value.icon).put("emoji", value.emoji)
.put("colorName", value.colorName).put("kindRawValue", value.kindRawValue)
.put("createdAt", iso(value.createdAt)))
private fun budgetChange(value: BudgetEntity) = change("budget", value.id, value.updatedAt, JSONObject()
.put("categoryName", value.categoryName).put("categoryIcon", value.categoryIcon)
.put("categoryEmoji", value.categoryEmoji).put("limit", value.limitAmount)
.put("monthStart", iso(value.monthStart)))
private fun change(entity: String, id: String, updatedAt: Long, payload: JSONObject) = JSONObject()
.put("entity", entity).put("id", id).put("deleted", false)
.put("updatedAt", iso(updatedAt)).put("payload", payload)
private fun scope(session: ServerSession) = "${preferences.serverUrl}|${session.userId}"
private fun iso(value: Long): String = Instant.ofEpochMilli(value).toString()
private fun parseTime(value: String): Long? = runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
private fun requireTime(value: String): Long = Instant.parse(value).toEpochMilli()
companion object { private const val BATCH_SIZE = 100 }
}
@@ -0,0 +1,14 @@
package com.example.balanceandroid.sync
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.example.balanceandroid.BalanceApplication
class SyncWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val app = applicationContext as BalanceApplication
if (app.sessions.load() == null || app.preferences.serverUrl.isBlank()) return Result.success()
return if (app.syncRepository.sync()) Result.success() else Result.retry()
}
}
@@ -0,0 +1,399 @@
package com.example.balanceandroid.ui
import androidx.compose.foundation.background
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.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
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.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Analytics
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Savings
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.balanceandroid.FinanceUiState
import com.example.balanceandroid.MainViewModel
import com.example.balanceandroid.data.CategoryDesign
import com.example.balanceandroid.data.FinanceMath
import com.example.balanceandroid.data.TransactionEntity
import com.example.balanceandroid.ui.theme.BalanceGreen
import com.example.balanceandroid.ui.theme.BalanceIndigo
import com.example.balanceandroid.ui.theme.BalancePurple
import com.example.balanceandroid.ui.theme.BalanceRed
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private data class Destination(val route: String, val title: String, val icon: ImageVector)
private val destinations = listOf(
Destination("overview", "Обзор", Icons.Default.Home),
Destination("analytics", "Аналитика", Icons.Default.Analytics),
Destination("transactions", "Операции", Icons.Default.List),
Destination("budgets", "Бюджеты", Icons.Default.Savings),
Destination("settings", "Настройки", Icons.Default.Settings),
)
@Composable
fun BalanceRoot(model: MainViewModel, state: FinanceUiState) {
val nav = rememberNavController()
val route = nav.currentBackStackEntryAsState().value?.destination?.route ?: "overview"
Scaffold(
bottomBar = {
if (destinations.any { it.route == route }) NavigationBar {
destinations.forEach { item ->
NavigationBarItem(
selected = route == item.route,
onClick = { nav.navigate(item.route) { popUpTo("overview"); launchSingleTop = true } },
icon = { Icon(item.icon, item.title) },
label = { Text(item.title) },
alwaysShowLabel = false,
)
}
}
},
) { padding ->
NavHost(nav, "overview", Modifier.padding(padding)) {
composable("overview") { OverviewScreen(model, state) }
composable("analytics") { AnalyticsScreen(state) }
composable("transactions") { TransactionsScreen(model, state) }
composable("budgets") { BudgetsScreen(model, state) }
composable("settings") { SettingsScreen(model, state, nav) }
composable("categories") { CategoriesScreen(model, state, nav) }
composable("server") { ServerScreen(model, state, nav) }
}
}
}
@Composable
private fun OverviewScreen(model: MainViewModel, state: FinanceUiState) {
var adjustment by remember { mutableStateOf(false) }
val (start, end) = periodBounds(1)
val summary = FinanceMath.summary(state.data.transactions, start, end)
val spending = FinanceMath.spending(state.data.transactions, start, end)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { ScreenTitle("Обзор") }
item {
Column(
Modifier.fillMaxWidth().background(
Brush.linearGradient(listOf(BalanceIndigo, BalancePurple)), RoundedCornerShape(22.dp),
).padding(22.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Общий баланс", color = Color.White.copy(alpha = .82f))
Text(formatMoney(FinanceMath.totalBalance(state.data.transactions), state.currency), color = Color.White, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = { adjustment = true }) {
Icon(Icons.Default.Tune, null); Spacer(Modifier.size(7.dp)); Text("Скорректировать", color = Color.White)
}
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MetricCard("Доходы", formatMoney(summary.income, state.currency), BalanceGreen, Modifier.weight(1f))
MetricCard("Расходы", formatMoney(summary.expenses, state.currency), BalanceRed, Modifier.weight(1f))
}
}
item {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Расходы по категориям", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
SpendingBars(spending, Modifier.fillMaxWidth())
}
}
}
if (state.data.transactions.isNotEmpty()) {
item { Text("Последние операции", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) }
items(state.data.transactions.take(5), key = { it.id }) { TransactionRow(it, state.currency) }
}
}
if (adjustment) BalanceAdjustmentDialog(
current = FinanceMath.totalBalance(state.data.transactions),
currency = state.currency,
onDismiss = { adjustment = false },
onSave = { target, note -> model.adjustBalance(target, note); adjustment = false },
)
}
@Composable
private fun AnalyticsScreen(state: FinanceUiState) {
var months by remember { mutableIntStateOf(1) }
val bounds = periodBounds(months)
val summary = FinanceMath.summary(state.data.transactions, bounds.first, bounds.second)
val spending = FinanceMath.spending(state.data.transactions, bounds.first, bounds.second)
val savings = if (summary.income > 0) summary.balance / summary.income * 100 else 0.0
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
item { ScreenTitle("Аналитика") }
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(1 to "Месяц", 3 to "3 месяца", 12 to "12 месяцев").forEach { (value, title) ->
FilterChip(selected = months == value, onClick = { months = value }, label = { Text(title) })
}
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MetricCard("Доходы", formatMoney(summary.income, state.currency), BalanceGreen, Modifier.weight(1f))
MetricCard("Расходы", formatMoney(summary.expenses, state.currency), BalanceRed, Modifier.weight(1f))
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MetricCard("Результат", formatMoney(summary.balance, state.currency), BalanceIndigo, Modifier.weight(1f))
MetricCard("Сбережения", "%.0f%%".format(savings), BalanceGreen, Modifier.weight(1f))
}
}
item {
Card { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Структура расходов", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
SpendingBars(spending, Modifier.fillMaxWidth())
} }
}
}
}
@Composable
private fun TransactionsScreen(model: MainViewModel, state: FinanceUiState) {
var search by remember { mutableStateOf("") }
var kind by remember { mutableStateOf("all") }
var editing by remember { mutableStateOf<TransactionEntity?>(null) }
var showingEditor by remember { mutableStateOf(false) }
val filtered = state.data.transactions.filter {
(kind == "all" || it.kindRawValue == kind) &&
(search.isBlank() || it.categoryName.contains(search, true) || it.note.contains(search, true))
}
Scaffold(
floatingActionButton = {
ExtendedFloatingActionButton(onClick = { editing = null; showingEditor = true }, icon = { Icon(Icons.Default.Add, null) }, text = { Text("Добавить") })
},
) { padding ->
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
item { ScreenTitle("Операции") }
item { OutlinedTextField(search, { search = it }, Modifier.fillMaxWidth(), label = { Text("Поиск") }, singleLine = true) }
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("all" to "Все", "expense" to "Расходы", "income" to "Доходы").forEach { item ->
FilterChip(kind == item.first, { kind = item.first }, label = { Text(item.second) })
}
}
}
if (filtered.isEmpty()) item { EmptyMessage("Операций пока нет") }
items(filtered, key = { it.id }) { transaction ->
Card(Modifier.fillMaxWidth().clickable { editing = transaction; showingEditor = true }) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
TransactionRow(transaction, state.currency, Modifier.weight(1f))
IconButton({ editing = transaction; showingEditor = true }) { Icon(Icons.Default.Edit, "Изменить") }
IconButton({ model.deleteTransaction(transaction) }) { Icon(Icons.Default.Delete, "Удалить", tint = BalanceRed) }
}
}
}
item { Spacer(Modifier.height(72.dp)) }
}
}
if (showingEditor) TransactionEditorDialog(model, state, editing, onDismiss = { showingEditor = false })
}
@Composable
private fun BudgetsScreen(model: MainViewModel, state: FinanceUiState) {
var editing by remember { mutableStateOf(false) }
val month = MainViewModel.currentMonthStart()
val budgets = state.data.budgets.filter { it.monthStart == month }
val bounds = periodBounds(1)
val spent = FinanceMath.spending(state.data.transactions, bounds.first, bounds.second).associate { it.name to it.amount }
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ScreenTitle("Бюджеты", Modifier.weight(1f))
Button({ editing = true }) { Icon(Icons.Default.Edit, null); Spacer(Modifier.size(6.dp)); Text("Лимиты") }
}
}
if (budgets.isEmpty()) item { EmptyMessage("Лимиты не заданы") }
items(budgets, key = { it.id }) { budget ->
val value = spent[budget.categoryName] ?: 0.0
val progress = (value / budget.limitAmount.coerceAtLeast(1.0)).toFloat().coerceIn(0f, 1f)
Card { Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CategoryBadge(CategoryDesign(budget.categoryName, budget.categoryIcon, budget.categoryEmoji, "indigo", "expense"), size = 36)
Spacer(Modifier.size(10.dp)); Text(budget.categoryName, Modifier.weight(1f), fontWeight = FontWeight.Bold)
Text(formatMoney(value, state.currency))
}
LinearProgressIndicator({ progress }, Modifier.fillMaxWidth())
Text("Лимит: ${formatMoney(budget.limitAmount, state.currency)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
}
}
if (editing) BudgetEditorDialog(model, state, onDismiss = { editing = false })
}
@Composable
private fun SettingsScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { ScreenTitle("Настройки") }
item { SettingsCard("Отображение") {
ChoiceRow("Валюта", state.currency, listOf("RUB", "EUR", "USD", "SEK"), model::setCurrency)
ChoiceRow("Тема", state.theme, listOf("system", "light", "dark"), model::setTheme, label = { when(it) { "light" -> "Светлая"; "dark" -> "Тёмная"; else -> "Системная" } })
} }
item { SettingsCard("Данные") {
SettingsLink("Категории", Icons.Default.Category) { nav.navigate("categories") }
HorizontalDivider()
SettingsLink("Сервер и синхронизация", Icons.Default.Sync) { nav.navigate("server") }
Text("Сохранено операций: ${state.data.transactions.size}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
item { SettingsCard("Приватность") {
Text("Данные хранятся локально. Токены сервера шифруются ключом Android Keystore.")
} }
item { Text("Версия 1.0.0", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
@Composable
private fun CategoriesScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) {
var editing by remember { mutableStateOf<com.example.balanceandroid.data.CategoryEntity?>(null) }
var showEditor by remember { mutableStateOf(false) }
Scaffold(floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { editing = null; showEditor = true },
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Категория") }
)
}) { padding ->
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
item { BackTitle("Категории") { nav.popBackStack() } }
if (state.data.categories.isEmpty()) item { EmptyMessage("Своих категорий пока нет") }
items(state.data.categories, key = { it.id }) { category ->
Card(Modifier.fillMaxWidth().clickable { editing = category; showEditor = true }) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
CategoryBadge(CategoryDesign(category.name, category.icon, category.emoji, category.colorName, category.kindRawValue))
Spacer(Modifier.size(12.dp)); Column(Modifier.weight(1f)) { Text(category.name, fontWeight = FontWeight.Bold); Text(if(category.kindRawValue == "income") "Доход" else "Расход", style = MaterialTheme.typography.bodySmall) }
IconButton({ editing = category; showEditor = true }) { Icon(Icons.Default.Edit, null) }
IconButton({ model.deleteCategory(category) }) { Icon(Icons.Default.Delete, null, tint = BalanceRed) }
}
}
}
item { Spacer(Modifier.height(72.dp)) }
}
}
if (showEditor) CategoryEditorDialog(model, state, editing, onDismiss = { showEditor = false })
}
@Composable
private fun ServerScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) {
var server by remember(state.serverUrl) { mutableStateOf(state.serverUrl) }
var email by remember(state.session?.email, state.serverEmail) { mutableStateOf(state.session?.email ?: state.serverEmail) }
var password by remember { mutableStateOf("") }
val savedServerSelected = state.serverUrl.isNotBlank() && server.trim().trimEnd('/') == state.serverUrl
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { BackTitle("Сервер") { nav.popBackStack() } }
item { SettingsCard("Адрес") {
OutlinedTextField(server, { server = it }, Modifier.fillMaxWidth(), label = { Text("https://balance.example.com") }, singleLine = true)
Button({ model.changeServer(server) }, enabled = !state.sync.running) { Text("Сохранить адрес") }
Text("Для эмулятора локальный сервер доступен как http://10.0.2.2:8080", style = MaterialTheme.typography.bodySmall)
} }
item { SettingsCard("Аккаунт") {
if (state.session == null) {
OutlinedTextField(email, { email = it }, Modifier.fillMaxWidth(), label = { Text("Email") }, singleLine = true)
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Пароль") }, singleLine = true, visualTransformation = PasswordVisualTransformation())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button({ model.login(email, password) }, enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.isNotBlank()) { Text("Войти") }
OutlinedButton({ model.register(email, password) }, enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.length >= 8) { Text("Регистрация") }
}
if (!savedServerSelected) Text("Сначала сохраните адрес сервера", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
} else {
Text("Выполнен вход: ${state.session.email}", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button({ model.sync() }, enabled = !state.sync.running) { Icon(Icons.Default.Sync, null); Spacer(Modifier.size(6.dp)); Text("Синхронизировать") }
OutlinedButton({ model.logout() }, enabled = !state.sync.running) { Text("Выйти") }
}
OutlinedButton({ model.resetSync() }, enabled = !state.sync.running) { Text("Полная пересинхронизация") }
}
if (state.sync.running) LinearProgressIndicator(Modifier.fillMaxWidth())
state.sync.message?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
} }
}
}
@Composable private fun ScreenTitle(text: String, modifier: Modifier = Modifier) = Text(text, modifier, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
@Composable private fun BackTitle(text: String, back: () -> Unit) = Row(verticalAlignment = Alignment.CenterVertically) { IconButton(back) { Icon(Icons.Default.ArrowBack, "Назад") }; ScreenTitle(text) }
@Composable private fun EmptyMessage(text: String) = Box(Modifier.fillMaxWidth().padding(30.dp), contentAlignment = Alignment.Center) { Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) }
@Composable
private fun TransactionRow(value: TransactionEntity, currency: String, modifier: Modifier = Modifier) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
CategoryBadge(CategoryDesign(value.categoryName, value.categoryIcon, value.categoryEmoji, value.categoryColorName, value.kindRawValue), size = 38)
Spacer(Modifier.size(10.dp)); Column(Modifier.weight(1f)) {
Text(value.categoryName, fontWeight = FontWeight.SemiBold)
Text(value.note.ifBlank { Instant.ofEpochMilli(value.date).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("d MMM")) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
Text((if(value.kindRawValue == "income") "+" else "−") + formatMoney(value.amount, currency), color = if(value.kindRawValue == "income") BalanceGreen else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold)
}
}
@Composable private fun SettingsCard(title: String, content: @Composable ColumnScope.() -> Unit) = Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(title, fontWeight = FontWeight.Bold); content() } }
@Composable private fun SettingsLink(title: String, icon: ImageVector, action: () -> Unit) = Row(Modifier.fillMaxWidth().clickable(onClick = action).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null); Spacer(Modifier.size(12.dp)); Text(title, Modifier.weight(1f)); Text("›", style = MaterialTheme.typography.titleLarge) }
@Composable
private fun ChoiceRow(title: String, current: String, values: List<String>, onSelect: (String) -> Unit, label: (String) -> String = { it }) {
var expanded by remember { mutableStateOf(false) }
Box {
Row(Modifier.fillMaxWidth().clickable { expanded = true }.padding(vertical = 7.dp)) { Text(title, Modifier.weight(1f)); Text(label(current), color = MaterialTheme.colorScheme.primary) }
androidx.compose.material3.DropdownMenu(expanded, { expanded = false }) {
values.forEach { value -> androidx.compose.material3.DropdownMenuItem({ Text(label(value)) }, { onSelect(value); expanded = false }) }
}
}
}
@@ -0,0 +1,229 @@
package com.example.balanceandroid.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Bed
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CardGiftcard
import androidx.compose.material.icons.filled.Checkroom
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.FitnessCenter
import androidx.compose.material.icons.filled.Flight
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.Laptop
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.LocalFireDepartment
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
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PedalBike
import androidx.compose.material.icons.filled.Pets
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.Print
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Restaurant
import androidx.compose.material.icons.filled.School
import androidx.compose.material.icons.filled.Sell
import androidx.compose.material.icons.filled.ShoppingBag
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.Work
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
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.unit.dp
import androidx.compose.ui.unit.sp
import com.example.balanceandroid.data.CategoryDesign
import com.example.balanceandroid.data.CategoryTotal
import java.text.NumberFormat
import java.time.Instant
import java.time.ZoneId
import java.util.Currency
import java.util.Locale
fun formatMoney(value: Double, currency: String): String = runCatching {
NumberFormat.getCurrencyInstance(Locale.getDefault()).apply {
this.currency = Currency.getInstance(currency)
maximumFractionDigits = if (value % 1.0 == 0.0) 0 else 2
}.format(value)
}.getOrElse { "%.2f %s".format(value, currency) }
fun colorFor(name: String): Color = when (name) {
"red" -> Color(0xFFE05252)
"orange" -> Color(0xFFF28C38)
"yellow" -> Color(0xFFE0AC24)
"green" -> Color(0xFF16A36A)
"mint" -> Color(0xFF31B99A)
"teal" -> Color(0xFF159EAF)
"cyan" -> Color(0xFF209BCC)
"blue" -> Color(0xFF3979E9)
"purple" -> Color(0xFF8B5CF6)
"pink" -> Color(0xFFE35C9A)
"brown" -> Color(0xFF9B6A4A)
"gray" -> Color(0xFF7A7A85)
"coral" -> Color(0xFFFA6161)
"peach" -> Color(0xFFFF9461)
"gold" -> Color(0xFFE6A314)
"lime" -> Color(0xFF85C729)
"forest" -> Color(0xFF1A7A4D)
"turquoise" -> Color(0xFF0DABA1)
"sky" -> Color(0xFF2E9EE8)
"navy" -> Color(0xFF1F4085)
"lavender" -> Color(0xFF8C66D9)
"magenta" -> Color(0xFFD1298C)
"slate" -> Color(0xFF4D5C73)
else -> Color(0xFF5B5BD6)
}
fun iconFor(name: String): ImageVector = when (name) {
"star", "star.fill" -> Icons.Default.Star
"heart.fill", "health" -> Icons.Default.Favorite
"sparkles" -> Icons.Default.AutoAwesome
"bolt.fill" -> Icons.Default.Bolt
"flame.fill" -> Icons.Default.LocalFireDepartment
"drop.fill" -> Icons.Default.WaterDrop
"cart", "cart.fill" -> Icons.Default.LocalGroceryStore
"bag", "bag.fill" -> Icons.Default.ShoppingBag
"creditcard.fill" -> Icons.Default.CreditCard
"money", "banknote.fill" -> Icons.Default.AccountBalanceWallet
"gift", "gift.fill" -> Icons.Default.CardGiftcard
"tag.fill" -> Icons.Default.Sell
"cup.and.saucer.fill" -> Icons.Default.Coffee
"fork.knife" -> Icons.Default.Restaurant
"takeoutbag.and.cup.and.straw.fill" -> Icons.Default.Fastfood
"birthday.cake.fill" -> Icons.Default.Cake
"car", "car.fill", "bus.fill", "tram.fill" -> Icons.Default.DirectionsCar
"bicycle" -> Icons.Default.PedalBike
"airplane" -> Icons.Default.Flight
"fuelpump.fill" -> Icons.Default.LocalGasStation
"home", "house.fill" -> Icons.Default.Home
"bed.double.fill" -> Icons.Default.Bed
"lightbulb.fill" -> Icons.Default.Lightbulb
"wifi" -> Icons.Default.Wifi
"phone.fill" -> Icons.Default.Phone
"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
"dumbbell.fill" -> Icons.Default.FitnessCenter
"cross.case.fill", "stethoscope" -> Icons.Default.MedicalServices
"pills.fill" -> Icons.Default.Medication
"book.fill" -> Icons.Default.MenuBook
"school", "graduationcap.fill" -> Icons.Default.School
"laptopcomputer" -> Icons.Default.Laptop
"briefcase.fill" -> Icons.Default.Work
"gamecontroller.fill" -> Icons.Default.SportsEsports
"music.note" -> Icons.Default.MusicNote
"movie", "film.fill" -> Icons.Default.Movie
"camera.fill" -> Icons.Default.CameraAlt
"paintpalette.fill" -> Icons.Default.Palette
"tshirt.fill" -> Icons.Default.Checkroom
"hammer.fill", "wrench.and.screwdriver.fill" -> Icons.Default.Construction
"shippingbox.fill" -> Icons.Default.Inventory2
"globe.europe.africa.fill" -> Icons.Default.Public
"chart", "chart.line.uptrend.xyaxis" -> Icons.Default.BarChart
"tune" -> Icons.Default.Tune
"plus.circle.fill" -> Icons.Default.Add
"repeat", "ellipsis.circle.fill", "more" -> Icons.Default.MoreHoriz
else -> Icons.Default.MoreHoriz
}
@Composable
fun CategoryBadge(category: CategoryDesign, modifier: Modifier = Modifier, size: Int = 42) {
Box(
modifier.size(size.dp).background(colorFor(category.colorName).copy(alpha = .16f), RoundedCornerShape(13.dp)),
contentAlignment = Alignment.Center,
) {
if (category.emoji.isNotBlank()) Text(category.emoji, fontSize = (size * .46f).sp)
else Icon(iconFor(category.icon), null, tint = colorFor(category.colorName), modifier = Modifier.size((size * .48f).dp))
}
}
@Composable
fun MetricCard(title: String, value: String, tint: Color, modifier: Modifier = Modifier) {
Card(modifier, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(title, style = MaterialTheme.typography.labelMedium, color = tint)
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1)
}
}
}
@Composable
fun SpendingBars(items: List<CategoryTotal>, modifier: Modifier = Modifier) {
if (items.isEmpty()) {
Box(modifier.height(170.dp), contentAlignment = Alignment.Center) { Text("Нет расходов", color = MaterialTheme.colorScheme.onSurfaceVariant) }
return
}
val maximum = items.maxOf { it.amount }.coerceAtLeast(1.0)
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
items.take(7).forEach { item ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (item.emoji.isBlank()) item.name else "${item.emoji} ${item.name}", Modifier.weight(1f), maxLines = 1)
Text("%.0f%%".format(item.amount / items.sumOf { it.amount } * 100), style = MaterialTheme.typography.labelMedium)
}
Canvas(Modifier.fillMaxWidth().height(8.dp)) {
drawRoundRect(Color.Gray.copy(alpha = .18f), cornerRadius = CornerRadius(size.height / 2))
drawRoundRect(
colorFor(item.colorName),
size = Size(size.width * (item.amount / maximum).toFloat(), size.height),
cornerRadius = CornerRadius(size.height / 2),
)
}
}
}
}
fun periodBounds(months: Int, now: Long = System.currentTimeMillis()): Pair<Long, Long> {
val zone = ZoneId.systemDefault()
val date = Instant.ofEpochMilli(now).atZone(zone)
val currentStart = date.withDayOfMonth(1).toLocalDate().atStartOfDay(zone)
val start = currentStart.minusMonths((months - 1).toLong())
val end = currentStart.plusMonths(1)
return start.toInstant().toEpochMilli() to end.toInstant().toEpochMilli()
}
@@ -0,0 +1,275 @@
package com.example.balanceandroid.ui
import android.app.DatePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.balanceandroid.FinanceUiState
import com.example.balanceandroid.MainViewModel
import com.example.balanceandroid.data.CategoryDesign
import com.example.balanceandroid.data.CategoryEntity
import com.example.balanceandroid.data.TransactionEntity
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun TransactionEditorDialog(
model: MainViewModel,
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()) }
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()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if(existing == null) "Новая операция" else "Редактирование") },
text = {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(kind == "expense", { kind = "expense"; categoryName = model.categories("expense").first().name }, label = { Text("Расход") })
FilterChip(kind == "income", { kind = "income"; categoryName = model.categories("income").first().name }, label = { Text("Доход") })
}
OutlinedTextField(amount, { amount = it }, Modifier.fillMaxWidth(), label = { Text("Сумма") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true)
DialogChoice("Категория", categoryName, categories.map { it.name }) { categoryName = it }
DateChoice(date) { date = it }
OutlinedTextField(note, { note = it }, Modifier.fillMaxWidth(), label = { Text("Заметка") })
}
},
confirmButton = {
Button({ model.saveTransaction(existing, amount.replace(',', '.').toDoubleOrNull() ?: 0.0, kind, selected, note, date); onDismiss() }, enabled = (amount.replace(',', '.').toDoubleOrNull() ?: 0.0) > 0) { Text("Сохранить") }
},
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
)
}
@Composable
fun CategoryEditorDialog(
model: MainViewModel,
state: FinanceUiState,
existing: CategoryEntity?,
onDismiss: () -> Unit,
) {
var name by remember(existing?.id) { mutableStateOf(existing?.name.orEmpty()) }
var kind by remember(existing?.id) { mutableStateOf(existing?.kindRawValue ?: "expense") }
var icon by remember(existing?.id) { mutableStateOf(existing?.icon ?: "star") }
var emoji by remember(existing?.id) { mutableStateOf(existing?.emoji.orEmpty()) }
var color by remember(existing?.id) { mutableStateOf(existing?.colorName ?: "indigo") }
var useEmoji by remember(existing?.id) { mutableStateOf(existing?.emoji?.isNotBlank() == true) }
val duplicate = state.data.categories.any { it.id != existing?.id && it.kindRawValue == kind && it.name.equals(name.trim(), true) }
val icons = listOf(
"star.fill", "heart.fill", "sparkles", "bolt.fill", "flame.fill", "drop.fill",
"cart.fill", "bag.fill", "creditcard.fill", "banknote.fill", "gift.fill", "tag.fill",
"cup.and.saucer.fill", "fork.knife", "takeoutbag.and.cup.and.straw.fill", "birthday.cake.fill",
"car.fill", "bus.fill", "tram.fill", "bicycle", "airplane", "fuelpump.fill",
"house.fill", "bed.double.fill", "lightbulb.fill", "wifi", "phone.fill", "printer.fill",
"pawprint.fill", "leaf.fill", "figure.walk", "figure.run", "dumbbell.fill", "cross.case.fill",
"pills.fill", "stethoscope", "book.fill", "graduationcap.fill", "laptopcomputer", "briefcase.fill",
"gamecontroller.fill", "music.note", "film.fill", "camera.fill", "paintpalette.fill", "tshirt.fill",
"hammer.fill", "wrench.and.screwdriver.fill", "shippingbox.fill", "globe.europe.africa.fill",
)
val emojis = listOf(
"🍔", "☕️", "🛒", "🚕", "✈️", "🏠", "💡", "📱", "💻", "🎮",
"🎬", "🎵", "📚", "🎓", "💊", "🏋️", "🐶", "🐱", "👕", "🎁",
"💰", "💳", "📈", "🔧", "🌿", "❤️", "⭐️", "🎯", "🧾", "🏖️",
)
val colors = listOf(
"red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue",
"indigo", "purple", "pink", "gray", "coral", "peach", "gold", "lime",
"forest", "turquoise", "sky", "navy", "lavender", "magenta", "brown", "slate",
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if(existing == null) "Новая категория" else "Редактирование") },
text = {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Название") }, singleLine = true)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(kind == "expense", { if(existing == null) kind = "expense" }, label = { Text("Расход") })
FilterChip(kind == "income", { if(existing == null) kind = "income" }, label = { Text("Доход") })
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(!useEmoji, { useEmoji = false }, label = { Text("Иконка") })
FilterChip(useEmoji, { useEmoji = true }, label = { Text("Эмодзи") })
}
if (useEmoji) {
OutlinedTextField(emoji, { emoji = it }, Modifier.fillMaxWidth(), label = { Text("Эмодзи") }, singleLine = true)
EmojiChoice(emoji, emojis) { emoji = it }
} else IconChoice(icon, color, kind, icons) { icon = it }
ColorChoice(color, colors) { color = it }
if (duplicate) Text("Категория с таким названием уже существует", color = MaterialTheme.colorScheme.error)
}
},
confirmButton = {
Button({ model.saveCategory(existing, name, kind, icon, if(useEmoji) emoji.trim() else "", color); onDismiss() }, enabled = name.isNotBlank() && !duplicate && (!useEmoji || emoji.isNotBlank())) { Text("Сохранить") }
},
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
)
}
@Composable
private fun IconChoice(current: String, color: String, kind: String, values: List<String>, onSelect: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
Text("Иконка", style = MaterialTheme.typography.labelLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(values, key = { it }) { value ->
Box(
Modifier
.border(if (value == current) 2.dp else 0.dp, MaterialTheme.colorScheme.primary, CircleShape)
.padding(3.dp)
.clickable { onSelect(value) }
) {
CategoryBadge(CategoryDesign("", value, "", color, kind), size = 40)
}
}
}
}
}
@Composable
private fun EmojiChoice(current: String, values: List<String>, onSelect: (String) -> Unit) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(values, key = { it }) { value ->
Box(
Modifier
.size(44.dp)
.border(if (value == current) 2.dp else 1.dp, if (value == current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape)
.clickable { onSelect(value) },
contentAlignment = Alignment.Center,
) { Text(value) }
}
}
}
@Composable
private fun ColorChoice(current: String, values: List<String>, onSelect: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
Text("Цвет", style = MaterialTheme.typography.labelLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
items(values, key = { it }) { value ->
Box(
Modifier
.size(34.dp)
.border(if (value == current) 3.dp else 1.dp, if (value == current) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, CircleShape)
.padding(4.dp)
.background(colorFor(value), CircleShape)
.clickable { onSelect(value) }
)
}
}
}
}
@Composable
fun BudgetEditorDialog(model: MainViewModel, state: FinanceUiState, onDismiss: () -> Unit) {
val categories = model.categories("expense")
val month = MainViewModel.currentMonthStart()
val limits = remember(state.data.budgets) { mutableStateMapOf<String, String>().apply {
categories.forEach { category ->
put(category.name, state.data.budgets.firstOrNull { it.categoryName == category.name && it.monthStart == month }?.limitAmount?.toString().orEmpty())
}
} }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Месячные лимиты") },
text = {
Column(Modifier.heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(9.dp)) {
categories.forEach { category ->
Row(verticalAlignment = Alignment.CenterVertically) {
CategoryBadge(category, size = 34); Spacer(Modifier.size(9.dp)); Text(category.name, Modifier.weight(1f), maxLines = 1)
OutlinedTextField(limits[category.name].orEmpty(), { limits[category.name] = it }, Modifier.fillMaxWidth(.38f), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true)
}
}
}
},
confirmButton = { Button({ categories.forEach { model.saveBudget(it, limits[it.name].orEmpty().replace(',', '.').toDoubleOrNull() ?: 0.0, month) }; onDismiss() }) { Text("Сохранить") } },
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
)
}
@Composable
fun BalanceAdjustmentDialog(current: Double, currency: String, onDismiss: () -> Unit, onSave: (Double, String) -> Unit) {
var target by remember { mutableStateOf(current.toString()) }
var note by remember { mutableStateOf("") }
val value = target.replace(',', '.').toDoubleOrNull()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Корректировка баланса") },
text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Расчётный баланс: ${formatMoney(current, currency)}")
OutlinedTextField(target, { target = it }, Modifier.fillMaxWidth(), label = { Text("Фактический баланс") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true)
OutlinedTextField(note, { note = it }, Modifier.fillMaxWidth(), label = { Text("Комментарий") })
Text("Корректировка не влияет на аналитику.", style = MaterialTheme.typography.bodySmall)
} },
confirmButton = { Button({ value?.let { onSave(it, note) } }, enabled = value != null && kotlin.math.abs(value - current) >= .005) { Text("Сохранить") } },
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
)
}
@Composable
private fun DialogChoice(title: String, current: String, values: List<String>, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton({ expanded = true }, Modifier.fillMaxWidth()) { Text("$title: ${current.ifBlank { "Выбрать" }}", Modifier.weight(1f)) }
DropdownMenu(expanded, { expanded = false }) {
values.forEach { value -> DropdownMenuItem({ Text(value) }, { onSelect(value); expanded = false }) }
}
}
}
@Composable
private fun DateChoice(value: Long, onChange: (Long) -> Unit) {
val context = LocalContext.current
val zone = ZoneId.systemDefault()
val date = Instant.ofEpochMilli(value).atZone(zone).toLocalDate()
OutlinedButton({
DatePickerDialog(context, { _, year, month, day ->
onChange(LocalDate.of(year, month + 1, day).atStartOfDay(zone).toInstant().toEpochMilli())
}, date.year, date.monthValue - 1, date.dayOfMonth).show()
}, Modifier.fillMaxWidth()) {
Text("Дата: ${date.format(DateTimeFormatter.ofPattern("d MMMM yyyy"))}")
}
}
@@ -0,0 +1,53 @@
package com.example.balanceandroid.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
val BalanceIndigo = Color(0xFF5B5BD6)
val BalancePurple = Color(0xFF8B5CF6)
val BalanceGreen = Color(0xFF16A36A)
val BalanceRed = Color(0xFFE05252)
private val LightColors = lightColorScheme(
primary = BalanceIndigo,
secondary = BalancePurple,
tertiary = BalanceGreen,
background = Color(0xFFF7F7FB),
surface = Color.White,
surfaceVariant = Color(0xFFECECF5),
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFB8B5FF),
secondary = Color(0xFFD0BCFF),
tertiary = Color(0xFF6DD7A8),
background = Color(0xFF111116),
surface = Color(0xFF1A1A22),
surfaceVariant = Color(0xFF292936),
)
@Composable
fun BalanceTheme(theme: String, content: @Composable () -> Unit) {
val dark = when (theme) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
val view = LocalView.current
val activity = view.context as? Activity
if (!view.isInEditMode && activity != null) {
SideEffect {
val controller = WindowCompat.getInsetsController(activity.window, view)
controller.isAppearanceLightStatusBars = !dark
}
}
MaterialTheme(colorScheme = if (dark) DarkColors else LightColors, content = content)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Баланс</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.Balance" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="balance_secure_session.xml" />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="balance_secure_session.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="balance_secure_session.xml" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,33 @@
package com.example.balanceandroid
import com.example.balanceandroid.data.FinanceMath
import com.example.balanceandroid.data.TransactionEntity
import org.junit.Assert.assertEquals
import org.junit.Test
class FinanceMathTest {
@Test
fun summarySeparatesIncomeExpensesAndAdjustments() {
val items = listOf(
TransactionEntity(amount = 1000.0, date = 100, categoryName = "Зарплата", kindRawValue = "income"),
TransactionEntity(amount = 250.0, date = 200, categoryName = "Продукты", kindRawValue = "expense"),
TransactionEntity(amount = 50.0, date = 300, categoryName = "Корректировка", kindRawValue = "income", isBalanceAdjustment = true),
)
val summary = FinanceMath.summary(items, 0, 1000)
assertEquals(1000.0, summary.income, 0.001)
assertEquals(250.0, summary.expenses, 0.001)
assertEquals(750.0, summary.balance, 0.001)
assertEquals(800.0, FinanceMath.totalBalance(items), 0.001)
}
@Test
fun spendingGroupsCategories() {
val items = listOf(
TransactionEntity(amount = 100.0, date = 100, categoryName = "Дом", categoryColorName = "indigo"),
TransactionEntity(amount = 200.0, date = 200, categoryName = "Дом", categoryColorName = "indigo"),
)
val spending = FinanceMath.spending(items, 0, 1000)
assertEquals(1, spending.size)
assertEquals(300.0, spending.first().amount, 0.001)
}
}