4.1.1
This commit is contained in:
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 }
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user