diff --git a/BalanceAndroid/app/schemas/com.example.balanceandroid.data.AppDatabase/1.json b/BalanceAndroid/app/schemas/com.example.balanceandroid.data.AppDatabase/1.json new file mode 100644 index 0000000..437cc52 --- /dev/null +++ b/BalanceAndroid/app/schemas/com.example.balanceandroid.data.AppDatabase/1.json @@ -0,0 +1,313 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "e5e368788e85c41e4ad977ca43851521", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `amount` REAL NOT NULL, `date` INTEGER NOT NULL, `note` TEXT NOT NULL, `categoryName` TEXT NOT NULL, `categoryIcon` TEXT NOT NULL, `categoryEmoji` TEXT NOT NULL, `categoryColorName` TEXT NOT NULL, `isBalanceAdjustment` INTEGER NOT NULL, `kindRawValue` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryName", + "columnName": "categoryName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryIcon", + "columnName": "categoryIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryEmoji", + "columnName": "categoryEmoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryColorName", + "columnName": "categoryColorName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isBalanceAdjustment", + "columnName": "isBalanceAdjustment", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kindRawValue", + "columnName": "kindRawValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transactions_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_transactions_date", + "unique": false, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_date` ON `${TABLE_NAME}` (`date`)" + } + ] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `emoji` TEXT NOT NULL, `colorName` TEXT NOT NULL, `kindRawValue` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorName", + "columnName": "colorName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kindRawValue", + "columnName": "kindRawValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_categories_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_categories_name_kindRawValue", + "unique": false, + "columnNames": [ + "name", + "kindRawValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_name_kindRawValue` ON `${TABLE_NAME}` (`name`, `kindRawValue`)" + } + ] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `categoryName` TEXT NOT NULL, `categoryIcon` TEXT NOT NULL, `categoryEmoji` TEXT NOT NULL, `limitAmount` REAL NOT NULL, `monthStart` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryName", + "columnName": "categoryName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryIcon", + "columnName": "categoryIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryEmoji", + "columnName": "categoryEmoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "limitAmount", + "columnName": "limitAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "monthStart", + "columnName": "monthStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_budgets_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_budgets_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_budgets_monthStart", + "unique": false, + "columnNames": [ + "monthStart" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_budgets_monthStart` ON `${TABLE_NAME}` (`monthStart`)" + } + ] + }, + { + "tableName": "deletion_journal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entity` TEXT NOT NULL, `recordId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entity", + "columnName": "entity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_deletion_journal_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_deletion_journal_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e5e368788e85c41e4ad977ca43851521')" + ] + } +} \ No newline at end of file diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt index d66f790..ae278ca 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt @@ -10,6 +10,7 @@ import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec +import androidx.core.content.edit data class ServerSession( val accessToken: String, @@ -33,10 +34,10 @@ class SecureSessionStore(context: Context) { .toString().toByteArray() val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, secretKey()) - preferences.edit() - .putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) - .putString("data", Base64.encodeToString(cipher.doFinal(source), Base64.NO_WRAP)) - .apply() + preferences.edit { + putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .putString("data", Base64.encodeToString(cipher.doFinal(source), Base64.NO_WRAP)) + } } fun load(): ServerSession? = runCatching { @@ -58,7 +59,7 @@ class SecureSessionStore(context: Context) { } fun clear() { - preferences.edit().clear().apply() + preferences.edit { clear() } } private fun secretKey(): SecretKey { diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt index 02fd6a6..2e39a78 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt @@ -66,7 +66,7 @@ class ServerApi( return try { parseSession(post("v1/auth/refresh", JSONObject().put("refreshToken", session.refreshToken), null)) .also { sessions.save(it) } - } catch (error: Throwable) { + } catch (_: Throwable) { sessions.clear() throw ServerException("Сессия истекла. Войдите снова.") } diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt index 9f058a0..8183fdb 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt @@ -4,26 +4,27 @@ import android.content.Context import java.net.URI import java.security.MessageDigest import java.util.UUID +import androidx.core.content.edit class ServerPreferences(context: Context) { private val values = context.getSharedPreferences("balance_settings", Context.MODE_PRIVATE) var serverUrl: String get() = values.getString("server_url", "") ?: "" - set(value) { values.edit().putString("server_url", value).apply() } + set(value) { values.edit { putString("server_url", value) } } var email: String get() = values.getString("server_email", "") ?: "" - set(value) { values.edit().putString("server_email", value).apply() } + set(value) { values.edit { putString("server_email", value) } } var currency: String get() = values.getString("currency", "RUB") ?: "RUB" - set(value) { values.edit().putString("currency", value).apply() } + set(value) { values.edit { putString("currency", value) } } var theme: String get() = values.getString("theme", "system") ?: "system" - set(value) { values.edit().putString("theme", value).apply() } + set(value) { values.edit { putString("theme", value) } } val deviceId: String get() = values.getString("device_id", null) ?: UUID.randomUUID().toString().also { - values.edit().putString("device_id", it).apply() + values.edit { putString("device_id", it) } } fun normalizeServer(input: String): String { @@ -45,11 +46,21 @@ class ServerPreferences(context: Context) { } fun cursor(scope: String): Long = values.getLong("cursor_${hash(scope)}", 0).coerceAtLeast(0) - fun setCursor(scope: String, cursor: Long) = values.edit().putLong("cursor_${hash(scope)}", cursor).apply() + fun setCursor(scope: String, cursor: Long) = values.edit { + putLong( + "cursor_${hash(scope)}", + cursor + ) + } fun lastPush(scope: String): Long = values.getLong("push_${hash(scope)}", 0) - fun setLastPush(scope: String, value: Long) = values.edit().putLong("push_${hash(scope)}", value).apply() + fun setLastPush(scope: String, value: Long) = values.edit { + putLong( + "push_${hash(scope)}", + value + ) + } fun resetSync(scope: String) { - values.edit().remove("cursor_${hash(scope)}").remove("push_${hash(scope)}").apply() + values.edit { remove("cursor_${hash(scope)}").remove("push_${hash(scope)}") } } private fun hash(value: String): String = MessageDigest.getInstance("SHA-256") diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt index d7f36e6..4520a65 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt @@ -32,8 +32,8 @@ class SyncRepository( val status: StateFlow = mutableStatus val session: StateFlow = mutableSession - fun serverUrl(): String = preferences.serverUrl - fun savedEmail(): String = preferences.email +// fun serverUrl(): String = preferences.serverUrl +// fun savedEmail(): String = preferences.email suspend fun changeServer(value: String): String { return try { diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt index 9721d7f..e497610 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt @@ -18,14 +18,14 @@ 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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List 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 @@ -45,6 +45,7 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -59,49 +60,83 @@ 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.tooling.preview.Preview 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.FinanceData import com.example.balanceandroid.FinanceUiState import com.example.balanceandroid.MainViewModel +import com.example.balanceandroid.data.BudgetEntity import com.example.balanceandroid.data.CategoryDesign +import com.example.balanceandroid.data.CategoryEntity 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 com.example.balanceandroid.ui.theme.BalanceTheme 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("transactions", "Операции", Icons.AutoMirrored.Filled.List), Destination("budgets", "Бюджеты", Icons.Default.Savings), Destination("settings", "Настройки", Icons.Default.Settings), ) @Composable fun BalanceRoot(model: MainViewModel, state: FinanceUiState) { + BalanceRootContent(state = state, model = model) +} + +@Composable +fun BalanceRootContent( + state: FinanceUiState, + model: MainViewModel? = null, +) { 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, - ) + if (destinations.any { it.route == route }) { + NavigationBar { + destinations.forEach { item -> + val selected = route == item.route + NavigationBarItem( + selected = selected, + onClick = { + nav.navigate(item.route) { + popUpTo("overview"); launchSingleTop = true + } + }, + icon = { + Icon( + imageVector = item.icon, + contentDescription = item.title, + modifier = Modifier.size(22.dp), + ) + }, + alwaysShowLabel = true, + label = { + Text( + text = item.title, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } } } }, @@ -119,7 +154,7 @@ fun BalanceRoot(model: MainViewModel, state: FinanceUiState) { } @Composable -private fun OverviewScreen(model: MainViewModel, state: FinanceUiState) { +fun OverviewScreen(model: MainViewModel? = null, state: FinanceUiState) { var adjustment by remember { mutableStateOf(false) } val (start, end) = periodBounds(1) val summary = FinanceMath.summary(state.data.transactions, start, end) @@ -132,38 +167,75 @@ private fun OverviewScreen(model: MainViewModel, state: FinanceUiState) { item { ScreenTitle("Обзор") } item { Column( - Modifier.fillMaxWidth().background( - Brush.linearGradient(listOf(BalanceIndigo, BalancePurple)), RoundedCornerShape(22.dp), - ).padding(22.dp), + 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) + 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) + 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)) + 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) + 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) } + 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( + if (adjustment && model != null) BalanceAdjustmentDialog( current = FinanceMath.totalBalance(state.data.transactions), currency = state.currency, onDismiss = { adjustment = false }, @@ -172,139 +244,291 @@ private fun OverviewScreen(model: MainViewModel, state: FinanceUiState) { } @Composable -private fun AnalyticsScreen(state: FinanceUiState) { +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)) { + 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) }) + 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)) + 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)) + 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()) - } } + 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) { +fun TransactionsScreen(model: MainViewModel? = null, state: FinanceUiState) { var search by remember { mutableStateOf("") } var kind by remember { mutableStateOf("all") } var editing by remember { mutableStateOf(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)) + (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("Добавить") }) + 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)) { + 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 { + 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) }) + 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 }) { + 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) } + 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 }) + if (showingEditor && model != null) TransactionEditorDialog( + model, +// state, + editing, + onDismiss = { showingEditor = false }) } @Composable -private fun BudgetsScreen(model: MainViewModel, state: FinanceUiState) { +fun BudgetsScreen(model: MainViewModel? = null, 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)) { + 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("Лимиты") } + 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)) + 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 + ) } - 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 }) + if (editing && model != null) 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)) { +fun SettingsScreen( + model: MainViewModel? = null, + state: FinanceUiState, + nav: NavHostController = rememberNavController() +) { + 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) } + item { + SettingsCard("Отображение") { + ChoiceRow( + "Валюта", + state.currency, + listOf("RUB", "EUR", "USD", "SEK"), + onSelect = { model?.setCurrency(it) }) + ChoiceRow( + "Тема", + state.theme, + listOf("system", "light", "dark"), + onSelect = { model?.setTheme(it) }, + 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(null) } +fun CategoriesScreen( + model: MainViewModel? = null, + state: FinanceUiState, + nav: NavHostController = rememberNavController() +) { + var editing by remember { mutableStateOf(null) } var showEditor by remember { mutableStateOf(false) } Scaffold(floatingActionButton = { ExtendedFloatingActionButton( @@ -313,87 +537,474 @@ private fun CategoriesScreen(model: MainViewModel, state: FinanceUiState, nav: N text = { Text("Категория") } ) }) { padding -> - LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + 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 }) { + 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) } + 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 }) + if (showEditor && model != null) CategoryEditorDialog( + model, + state, + editing, + onDismiss = { showEditor = false }) } @Composable -private fun ServerScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) { +fun ServerScreen( + model: MainViewModel? = null, + state: FinanceUiState, + nav: NavHostController = rememberNavController() +) { 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 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)) { + 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("Регистрация") } + 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 (!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("Выйти") } + if (state.sync.running) LinearProgressIndicator(Modifier.fillMaxWidth()) + state.sync.message?.let { + Text( + it, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - 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 ScreenTitle(text: String, modifier: Modifier = Modifier) = Text( + text, + modifier, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold +) @Composable -private fun ChoiceRow(title: String, current: String, values: List, onSelect: (String) -> Unit, label: (String) -> String = { it }) { +private fun BackTitle(text: String, back: () -> Unit) = + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(back) { + Icon( + Icons.AutoMirrored.Filled.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, + 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) } + 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 }) } + values.forEach { value -> + androidx.compose.material3.DropdownMenuItem( + { Text(label(value)) }, + { onSelect(value); expanded = false }) + } } } } + +val SampleFinanceData = FinanceData( + transactions = listOf( + TransactionEntity( + id = "1", + date = System.currentTimeMillis(), + amount = 4500.0, + kindRawValue = "expense", + categoryName = "Продукты", + categoryEmoji = "🛒", + categoryColorName = "orange", + note = "Супермаркет" + ), + TransactionEntity( + id = "2", + date = System.currentTimeMillis() - 86400000, + amount = 120000.0, + kindRawValue = "income", + categoryName = "Зарплата", + categoryEmoji = "💰", + categoryColorName = "green", + note = "Аванс за месяц" + ), + TransactionEntity( + id = "3", + date = System.currentTimeMillis() - 172800000, + amount = 1200.0, + kindRawValue = "expense", + categoryName = "Кафе", + categoryEmoji = "☕", + categoryColorName = "indigo", + note = "Обед" + ), + TransactionEntity( + id = "4", + date = System.currentTimeMillis() - 259200000, + amount = 3500.0, + kindRawValue = "expense", + categoryName = "Транспорт", + categoryEmoji = "🚗", + categoryColorName = "purple", + note = "Бензин" + ), + ), + categories = listOf( + CategoryEntity( + id = "c1", + name = "Продукты", + kindRawValue = "expense", + icon = "cart.fill", + emoji = "🛒", + colorName = "orange" + ), + CategoryEntity( + id = "c2", + name = "Кафе", + kindRawValue = "expense", + icon = "fork.knife", + emoji = "☕", + colorName = "indigo" + ), + CategoryEntity( + id = "c3", + name = "Транспорт", + kindRawValue = "expense", + icon = "car.fill", + emoji = "🚗", + colorName = "purple" + ), + CategoryEntity( + id = "c4", + name = "Зарплата", + kindRawValue = "income", + icon = "banknote.fill", + emoji = "💰", + colorName = "green" + ), + ), + budgets = listOf( + BudgetEntity( + id = "b1", + categoryName = "Продукты", + limitAmount = 25000.0, + monthStart = MainViewModel.currentMonthStart(), + categoryEmoji = "🛒" + ), + BudgetEntity( + id = "b2", + categoryName = "Кафе", + limitAmount = 10000.0, + monthStart = MainViewModel.currentMonthStart(), + categoryEmoji = "☕" + ), + ), +) + +val SampleFinanceUiState = FinanceUiState( + data = SampleFinanceData, + currency = "RUB", + theme = "dark", + serverUrl = "https://balance.example.com", + serverEmail = "user@example.com", +) + +@Preview(name = "App UI (Dark)", group = "Screens", showBackground = true) +@Composable +fun BalanceRootDarkPreview() { + BalanceTheme(theme = "dark") { + Surface { + BalanceRootContent(state = SampleFinanceUiState) + } + } +} + +@Preview(name = "App UI (Light)", group = "Screens", showBackground = true) +@Composable +fun BalanceRootLightPreview() { + BalanceTheme(theme = "light") { + Surface { + BalanceRootContent(state = SampleFinanceUiState.copy(theme = "light")) + } + } +} + +@Preview(name = "Overview Screen", group = "Screens", showBackground = true) +@Composable +fun OverviewScreenPreview() { + BalanceTheme(theme = "dark") { + Surface { + OverviewScreen(state = SampleFinanceUiState) + } + } +} + +@Preview(name = "Analytics Screen", group = "Screens", showBackground = true) +@Composable +fun AnalyticsScreenPreview() { + BalanceTheme(theme = "dark") { + Surface { + AnalyticsScreen(state = SampleFinanceUiState) + } + } +} + +@Preview(name = "Transactions Screen", group = "Screens", showBackground = true) +@Composable +fun TransactionsScreenPreview() { + BalanceTheme(theme = "dark") { + Surface { + TransactionsScreen(state = SampleFinanceUiState) + } + } +} + +@Preview(name = "Budgets Screen", group = "Screens", showBackground = true) +@Composable +fun BudgetsScreenPreview() { + BalanceTheme(theme = "dark") { + Surface { + BudgetsScreen(state = SampleFinanceUiState) + } + } +} + +@Preview(name = "Settings Screen", group = "Screens", showBackground = true) +@Composable +fun SettingsScreenPreview() { + BalanceTheme(theme = "dark") { + Surface { + SettingsScreen(state = SampleFinanceUiState) + } + } +} + +@Preview(name = "Categories Screen", group = "Screens", showBackground = true) +@Composable +fun CategoriesScreenPreview() { + BalanceTheme(theme = "dark") { + Surface { + CategoriesScreen(state = SampleFinanceUiState) + } + } +} + +@Preview(name = "Server Screen", group = "Screens", showBackground = true) +@Composable +fun ServerScreenPreview() { + BalanceTheme(theme = "dark") { + Surface { + ServerScreen(state = SampleFinanceUiState) + } + } +} + diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt index 0fc22bb..60cfa90 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt @@ -12,6 +12,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.DirectionsRun +import androidx.compose.material.icons.automirrored.filled.DirectionsWalk +import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.AccountBalanceWallet import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AutoAwesome @@ -26,11 +29,9 @@ import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Construction import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.DirectionsCar -import androidx.compose.material.icons.filled.DirectionsRun -import androidx.compose.material.icons.filled.DirectionsWalk import androidx.compose.material.icons.filled.Eco -import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Fastfood +import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.Flight import androidx.compose.material.icons.filled.Home @@ -42,7 +43,6 @@ import androidx.compose.material.icons.filled.LocalGasStation import androidx.compose.material.icons.filled.LocalGroceryStore import androidx.compose.material.icons.filled.MedicalServices import androidx.compose.material.icons.filled.Medication -import androidx.compose.material.icons.filled.MenuBook import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.MusicNote @@ -75,10 +75,13 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.balanceandroid.data.CategoryDesign import com.example.balanceandroid.data.CategoryTotal +import com.example.balanceandroid.ui.theme.BalanceGreen +import com.example.balanceandroid.ui.theme.BalanceTheme import java.text.NumberFormat import java.time.Instant import java.time.ZoneId @@ -148,12 +151,12 @@ fun iconFor(name: String): ImageVector = when (name) { "printer.fill" -> Icons.Default.Print "pawprint.fill" -> Icons.Default.Pets "leaf.fill" -> Icons.Default.Eco - "figure.walk" -> Icons.Default.DirectionsWalk - "figure.run" -> Icons.Default.DirectionsRun + "figure.walk" -> Icons.AutoMirrored.Filled.DirectionsWalk + "figure.run" -> Icons.AutoMirrored.Filled.DirectionsRun "dumbbell.fill" -> Icons.Default.FitnessCenter "cross.case.fill", "stethoscope" -> Icons.Default.MedicalServices "pills.fill" -> Icons.Default.Medication - "book.fill" -> Icons.Default.MenuBook + "book.fill" -> Icons.AutoMirrored.Filled.MenuBook "school", "graduationcap.fill" -> Icons.Default.School "laptopcomputer" -> Icons.Default.Laptop "briefcase.fill" -> Icons.Default.Work @@ -227,3 +230,34 @@ fun periodBounds(months: Int, now: Long = System.currentTimeMillis()): Pair Unit, ) { var amount by remember(existing?.id) { mutableStateOf(existing?.amount?.toString().orEmpty()) } var kind by remember(existing?.id) { mutableStateOf(existing?.kindRawValue ?: "expense") } var note by remember(existing?.id) { mutableStateOf(existing?.note.orEmpty()) } - var date by remember(existing?.id) { mutableStateOf(existing?.date ?: System.currentTimeMillis()) } + var date by remember(existing?.id) { mutableLongStateOf(existing?.date ?: System.currentTimeMillis()) } val categories = model.categories(kind) var categoryName by remember(existing?.id) { mutableStateOf(existing?.categoryName ?: categories.first().name) } val selected = categories.firstOrNull { it.name == categoryName } ?: categories.first() diff --git a/BalanceAndroid/build.gradle.kts b/BalanceAndroid/build.gradle.kts index db09d62..d5099dd 100644 --- a/BalanceAndroid/build.gradle.kts +++ b/BalanceAndroid/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "8.13.0" apply false + id("com.android.application") version "9.2.1" apply false id("org.jetbrains.kotlin.android") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false diff --git a/BalanceAndroid/gradle.properties b/BalanceAndroid/gradle.properties index a907044..d57acb0 100644 --- a/BalanceAndroid/gradle.properties +++ b/BalanceAndroid/gradle.properties @@ -6,3 +6,13 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 kotlin.daemon.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=512m org.gradle.workers.max=2 org.gradle.parallel=false +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties b/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties index 37f853b..c61a118 100644 --- a/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties +++ b/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME