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