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

No files matched your search

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

After

Width:  |  Height:  |  Size: 260 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Баланс</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.Balance" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="balance_secure_session.xml" />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="balance_secure_session.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="balance_secure_session.xml" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,33 @@
package com.example.balanceandroid
import com.example.balanceandroid.data.FinanceMath
import com.example.balanceandroid.data.TransactionEntity
import org.junit.Assert.assertEquals
import org.junit.Test
class FinanceMathTest {
@Test
fun summarySeparatesIncomeExpensesAndAdjustments() {
val items = listOf(
TransactionEntity(amount = 1000.0, date = 100, categoryName = "Зарплата", kindRawValue = "income"),
TransactionEntity(amount = 250.0, date = 200, categoryName = "Продукты", kindRawValue = "expense"),
TransactionEntity(amount = 50.0, date = 300, categoryName = "Корректировка", kindRawValue = "income", isBalanceAdjustment = true),
)
val summary = FinanceMath.summary(items, 0, 1000)
assertEquals(1000.0, summary.income, 0.001)
assertEquals(250.0, summary.expenses, 0.001)
assertEquals(750.0, summary.balance, 0.001)
assertEquals(800.0, FinanceMath.totalBalance(items), 0.001)
}
@Test
fun spendingGroupsCategories() {
val items = listOf(
TransactionEntity(amount = 100.0, date = 100, categoryName = "Дом", categoryColorName = "indigo"),
TransactionEntity(amount = 200.0, date = 200, categoryName = "Дом", categoryColorName = "indigo"),
)
val spending = FinanceMath.spending(items, 0, 1000)
assertEquals(1, spending.size)
assertEquals(300.0, spending.first().amount, 0.001)
}
}
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.13.0" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
}
+8
View File
@@ -0,0 +1,8 @@
android.useAndroidX=true
kotlin.code.style=official
kapt.incremental.apt=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
kotlin.daemon.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=512m
org.gradle.workers.max=2
org.gradle.parallel=false
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+359
View File
@@ -0,0 +1,359 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
java_major_version () {
java_version=$( "$1" -version 2>&1 | sed -n '1s/.*version "\([^"]*\)".*/\1/p' )
case $java_version in
1.*)
java_version=${java_version#1.}
java_major=${java_version%%[._-]*}
;;
*)
java_major=${java_version%%[._-]*}
;;
esac
case $java_major in
'' | *[!0-9]*) printf '%s\n' 0 ;;
*) printf '%s\n' "$java_major" ;;
esac
}
compatible_java_home () {
[ -x "$1/bin/java" ] || return 1
candidate_major=$( java_major_version "$1/bin/java" )
[ "$candidate_major" -ge 17 ] && [ "$candidate_major" -le 23 ]
}
find_macos_java_home () {
for candidate in \
"/Applications/Android Studio.app/Contents/jbr/Contents/Home" \
"$HOME/Applications/Android Studio.app/Contents/jbr/Contents/Home" \
"/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home" \
"$HOME/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home"
do
if compatible_java_home "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
done
if [ -x /usr/libexec/java_home ]; then
for requested_version in 17 21 22 23
do
candidate=$( /usr/libexec/java_home -v "$requested_version" 2>/dev/null ) || continue
if compatible_java_home "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
done
fi
return 1
}
android_sdk_is_valid () {
[ -n "$1" ] && [ -d "$1" ] && {
[ -d "$1/platforms" ] ||
[ -d "$1/platform-tools" ] ||
[ -d "$1/cmdline-tools" ]
}
}
find_android_sdk_home () {
for candidate in \
"$ANDROID_HOME" \
"$ANDROID_SDK_ROOT" \
"$HOME/Android/Sdk" \
"$HOME/Android/sdk" \
"$HOME/Library/Android/sdk" \
"/opt/android-sdk" \
"/usr/local/lib/android/sdk"
do
if android_sdk_is_valid "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
SELECTED_ANDROID_SDK=$( find_android_sdk_home ) || SELECTED_ANDROID_SDK=
if [ -n "$SELECTED_ANDROID_SDK" ]; then
if [ "$ANDROID_HOME" != "$SELECTED_ANDROID_SDK" ] || [ "$ANDROID_SDK_ROOT" != "$SELECTED_ANDROID_SDK" ]; then
warn "Using Android SDK from $SELECTED_ANDROID_SDK"
fi
ANDROID_HOME=$SELECTED_ANDROID_SDK
ANDROID_SDK_ROOT=$SELECTED_ANDROID_SDK
export ANDROID_HOME ANDROID_SDK_ROOT
else
warn "Android SDK was not found. Install SDK Platform 36 in Android Studio, or set ANDROID_HOME."
fi
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. On macOS, an obsolete
# JAVA_HOME is automatically replaced with Android Studio's bundled JBR.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
else
JAVACMD=java
fi
CURRENT_JAVA_MAJOR=0
if [ -x "$JAVACMD" ] || command -v "$JAVACMD" >/dev/null 2>&1; then
CURRENT_JAVA_MAJOR=$( java_major_version "$JAVACMD" )
fi
if [ "$CURRENT_JAVA_MAJOR" -lt 17 ] || [ "$CURRENT_JAVA_MAJOR" -gt 23 ]; then
if "$darwin"; then
SELECTED_JAVA_HOME=$( find_macos_java_home ) || SELECTED_JAVA_HOME=
if [ -n "$SELECTED_JAVA_HOME" ]; then
JAVA_HOME=$SELECTED_JAVA_HOME
export JAVA_HOME
JAVACMD=$JAVA_HOME/bin/java
CURRENT_JAVA_MAJOR=$( java_major_version "$JAVACMD" )
warn "Using JDK $CURRENT_JAVA_MAJOR from $JAVA_HOME"
fi
fi
fi
if [ ! -x "$JAVACMD" ] && ! command -v "$JAVACMD" >/dev/null 2>&1; then
die "ERROR: No compatible Java installation was found.
Install Android Studio or JDK 17, then set JAVA_HOME to that JDK."
fi
if [ "$CURRENT_JAVA_MAJOR" -lt 17 ] || [ "$CURRENT_JAVA_MAJOR" -gt 23 ]; then
die "ERROR: This project requires JDK 17 through 23, but Java $CURRENT_JAVA_MAJOR is active.
On macOS with Android Studio installed, run:
export JAVA_HOME=\"/Applications/Android Studio.app/Contents/jbr/Contents/Home\"
Then run ./gradlew again."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+100
View File
@@ -0,0 +1,100 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find the Android SDK in the environment or the standard Windows location.
if not defined ANDROID_HOME if defined ANDROID_SDK_ROOT set "ANDROID_HOME=%ANDROID_SDK_ROOT%"
if not defined ANDROID_HOME if exist "%LOCALAPPDATA%\Android\Sdk" set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
if defined ANDROID_HOME set "ANDROID_SDK_ROOT=%ANDROID_HOME%"
if not defined ANDROID_HOME echo WARNING: Android SDK was not found. Install SDK Platform 36 or set ANDROID_HOME. 1>&2
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
# Copy this file to local.properties only when the SDK is installed in a
# non-standard directory. Use forward slashes, including on Windows.
# Linux: sdk.dir=/home/USER/Android/Sdk
# macOS: sdk.dir=/Users/USER/Library/Android/sdk
# Windows: sdk.dir=C:/Users/USER/AppData/Local/Android/Sdk
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "BalanceAndroid"
include(":app")