commit 3893e4a09910fb5ca15105a3409673568f663a3e Author: alex Date: Tue Feb 24 17:52:45 2026 +0500 Base app working diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df5a881 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Built artifacts +bin/ +gen/ +out/ +build/ +app/build/ + +# Gradle +.gradle/ +.navigation/ +gradlew.bat +local.properties + +# Kotlin +.kotlin/ +kotlin-daemon-embeddable-*.jar + +# Android Studio / IntelliJ +.idea/ +*.iml +*.ipr +*.iws +/captures/ +.externalNativeBuild/ +.cxx/ +cmake_install.cmake +CMakeCache.txt + +# Secrets & Keys (IMPORTANT) +*.jks +*.keystore +*.p12 +*.key +*.pub +google-services.json + +# Build outputs +*.apk +*.ap_ +*.aab +*.dex + +# OS generated files +.DS_Store +Thumbs.db + +# Project specific +/remote_project/ + +# Memory dumps +*.hprof diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..8a53ff9 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.budget" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.budget" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.bundles.base) + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.bundles.network) + + debugImplementation(libs.bundles.compose.debug) + testImplementation(libs.junit) + androidTestImplementation(libs.bundles.android.test) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6f6142d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/budget/MainActivity.kt b/app/src/main/java/com/example/budget/MainActivity.kt new file mode 100644 index 0000000..19edf21 --- /dev/null +++ b/app/src/main/java/com/example/budget/MainActivity.kt @@ -0,0 +1,184 @@ +package com.example.budget + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType +import androidx.navigation.compose.* +import androidx.navigation.navArgument +import com.example.budget.network.ApiClient +import com.example.budget.network.SessionManager +import com.example.budget.ui.auth.AuthScreen +import com.example.budget.ui.auth.AuthViewModel +import com.example.budget.ui.main.MainScreen +import com.example.budget.ui.planning.PlanningScreen +import com.example.budget.ui.stats.StatsScreen +import com.example.budget.ui.transaction.AddEditTransactionScreen + +sealed class Screen(val route: String, val title: String, val icon: ImageVector) { + object Main : Screen("main", "Обзор", Icons.AutoMirrored.Filled.List) + object Stats : Screen("stats", "Анализ", Icons.Default.PieChart) + object Planning : Screen("planning", "Планы", Icons.Default.DateRange) + object Profile : Screen("profile", "Профиль", Icons.Default.Person) +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sessionManager = SessionManager(this) + ApiClient.init(sessionManager) + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BudgetNavHost(sessionManager) + } + } + } + } +} + +@Composable +fun BudgetNavHost(sessionManager: SessionManager) { + val navController = rememberNavController() + val hasToken = sessionManager.hasToken() + val startDestination = if (hasToken) "home_scaffold" else "auth" + + NavHost(navController = navController, startDestination = startDestination) { + composable("auth") { + val authViewModel: AuthViewModel = viewModel() + authViewModel.init(sessionManager) + AuthScreen( + onLoginSuccess = { + navController.navigate("home_scaffold") { + popUpTo("auth") { inclusive = true } + } + }, + viewModel = authViewModel + ) + } + + composable("home_scaffold") { + HomeScaffold(navController, sessionManager) + } + + composable( + route = "add_edit_transaction?transactionId={transactionId}", + arguments = listOf( + navArgument("transactionId") { + type = NavType.IntType + defaultValue = -1 + } + ) + ) { backStackEntry -> + val transactionId = backStackEntry.arguments?.getInt("transactionId")?.takeIf { it != -1 } + AddEditTransactionScreen( + transactionId = transactionId, + onNavigateBack = { navController.popBackStack() } + ) + } + } +} + +@Composable +fun HomeScaffold(rootNavController: androidx.navigation.NavController, sessionManager: SessionManager) { + val bottomNavController = rememberNavController() + val items = listOf(Screen.Main, Screen.Stats, Screen.Planning, Screen.Profile) + + Scaffold( + bottomBar = { + NavigationBar { + val navBackStackEntry by bottomNavController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + items.forEach { screen -> + NavigationBarItem( + icon = { Icon(screen.icon, contentDescription = null) }, + label = { Text(screen.title) }, + selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + onClick = { + bottomNavController.navigate(screen.route) { + popUpTo(bottomNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = bottomNavController, + startDestination = Screen.Main.route, + modifier = Modifier.padding(innerPadding) + ) { + composable(Screen.Main.route) { + MainScreen( + onAddTransaction = { + rootNavController.navigate("add_edit_transaction") + }, + onEditTransaction = { id -> + rootNavController.navigate("add_edit_transaction?transactionId=$id") + } + ) + } + composable(Screen.Stats.route) { + StatsScreen() + } + composable(Screen.Planning.route) { + PlanningScreen() + } + composable(Screen.Profile.route) { + ProfileScreen(onLogout = { + sessionManager.clearSession() + rootNavController.navigate("auth") { + popUpTo("home_scaffold") { inclusive = true } + } + }) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen(onLogout: () -> Unit) { + Scaffold( + topBar = { TopAppBar(title = { Text("Профиль") }) } + ) { padding -> + androidx.compose.foundation.layout.Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize(), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + Button( + onClick = onLogout, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("Выйти из аккаунта") + } + } + } +} diff --git a/app/src/main/java/com/example/budget/network/ApiClient.kt b/app/src/main/java/com/example/budget/network/ApiClient.kt new file mode 100644 index 0000000..b382413 --- /dev/null +++ b/app/src/main/java/com/example/budget/network/ApiClient.kt @@ -0,0 +1,52 @@ +package com.example.budget.network + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Interceptor +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit + +object ApiClient { + + private const val BASE_URL = "https://tube.buhit.uz/" + + private var sessionManager: SessionManager? = null + + fun init(manager: SessionManager) { + sessionManager = manager + } + + private val json = Json { + ignoreUnknownKeys = true + } + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val authInterceptor = Interceptor { chain -> + val originalRequest = chain.request() + val builder: Request.Builder = originalRequest.newBuilder() + sessionManager?.getAuthToken()?.let { + builder.header("Authorization", "Bearer $it") + } + chain.proceed(builder.build()) + } + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .addInterceptor(authInterceptor) + .build() + + val apiService: BudgetApiService by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(BudgetApiService::class.java) + } +} diff --git a/app/src/main/java/com/example/budget/network/BudgetApiService.kt b/app/src/main/java/com/example/budget/network/BudgetApiService.kt new file mode 100644 index 0000000..92ed385 --- /dev/null +++ b/app/src/main/java/com/example/budget/network/BudgetApiService.kt @@ -0,0 +1,50 @@ +package com.example.budget.network + +import retrofit2.http.* + +interface BudgetApiService { + + @POST("/api/auth/login") + suspend fun login(@Body request: LoginRequest): TokenResponse + + // Transactions + @GET("/api/transactions") + suspend fun getTransactions(@Query("limit") limit: Int = 100): List + + @GET("/api/transactions/{id}") + suspend fun getTransaction(@Path("id") id: Int): TransactionResponse + + @POST("/api/transactions") + suspend fun createTransaction(@Body transaction: TransactionCreate): TransactionResponse + + @PATCH("/api/transactions/{id}") + suspend fun updateTransaction(@Path("id") id: Int, @Body transaction: TransactionUpdate): TransactionResponse + + @DELETE("/api/transactions/{id}") + suspend fun deleteTransaction(@Path("id") id: Int) + + @GET("/api/transactions/stats") + suspend fun getStats( + @Query("start_date") startDate: String? = null, + @Query("end_date") endDate: String? = null + ): StatsResponse + + // Categories + @GET("/api/categories") + suspend fun getCategories(@Query("type") type: String? = null): List + + // Wallets + @GET("/api/wallets") + suspend fun getWallets(): List + + @POST("/api/wallets/transfer") + suspend fun transferMoney(@Body request: WalletTransferRequest) + + // Debts + @GET("/api/debts") + suspend fun getDebts(): List + + // Planned Payments + @GET("/api/planned_payments") + suspend fun getPlannedPayments(): List +} diff --git a/app/src/main/java/com/example/budget/network/NetworkModels.kt b/app/src/main/java/com/example/budget/network/NetworkModels.kt new file mode 100644 index 0000000..c3a4ba0 --- /dev/null +++ b/app/src/main/java/com/example/budget/network/NetworkModels.kt @@ -0,0 +1,138 @@ +package com.example.budget.network + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName + +@Serializable +data class LoginRequest( + val username: String, + val password: String +) + +@Serializable +data class TokenResponse( + val token: String, + val user: UserResponse? = null +) + +@Serializable +data class UserResponse( + val id: Int, + val username: String? = null, + @SerialName("telegram_id") val telegramId: Long? = null +) + +@Serializable +data class TransactionResponse( + val id: Int, + val amount: Double, + val type: String, // "expense" or "income" + val date: String, + val comment: String? = null, + val currency: String, + @SerialName("category_id") val categoryId: Int? = null, + @SerialName("wallet_id") val walletId: Int? = null, + @SerialName("category_name") val categoryName: String? = null, + @SerialName("tag_name") val tagName: String? = null +) + +@Serializable +data class TransactionCreate( + val amount: Double, + @SerialName("category_id") val categoryId: Int? = null, + val type: String, + val date: String, + val comment: String? = null, + val currency: String = "UZS", + @SerialName("wallet_id") val walletId: Int? = null +) + +@Serializable +data class TransactionUpdate( + val amount: Double? = null, + @SerialName("category_id") val categoryId: Int? = null, + val type: String? = null, + val date: String? = null, + val comment: String? = null, + val currency: String? = null, + @SerialName("wallet_id") val walletId: Int? = null +) + +@Serializable +data class CategoryResponse( + val id: Int, + val name: String, + val type: String +) + +@Serializable +data class WalletResponse( + val id: Int, + val name: String, + val balance: Double, + val currency: String, + @SerialName("is_default") val isDefault: Boolean = false +) + +@Serializable +data class WalletTransferRequest( + @SerialName("from_wallet_id") val fromWalletId: Int, + @SerialName("to_wallet_id") val toWalletId: Int, + val amount: Double +) + +@Serializable +data class DebtResponse( + val id: Int, + val name: String, + val type: String, // "credit" or "debt" + val amount: Double, + val currency: String, + @SerialName("due_date") val dueDate: String? = null, + @SerialName("is_paid") val isPaid: Boolean = false +) + +@Serializable +data class PlannedPaymentResponse( + val id: Int, + val name: String, + val amount: Double, + val currency: String, + val frequency: String, + @SerialName("next_payment_date") val nextPaymentDate: String, + @SerialName("category_id") val categoryId: Int? = null +) + +@Serializable +data class StatsResponse( + @SerialName("total_balance_by_currency") val totalBalance: Map, + @SerialName("period_by_currency") val periodStats: Map = emptyMap(), + @SerialName("by_category") val byCategory: List = emptyList(), + @SerialName("upcoming_payments") val upcomingPayments: List = emptyList() +) + +@Serializable +data class PeriodStats( + @SerialName("total_income") val totalIncome: Double, + @SerialName("total_expense") val totalExpense: Double, + val balance: Double +) + +@Serializable +data class CategoryStat( + @SerialName("category_id") val categoryId: Int?, + @SerialName("category_name") val categoryName: String, + val amount: Double, + val type: String, + val currency: String +) + +@Serializable +data class UpcomingPayment( + val id: String, + val name: String, + val amount: Double, + val currency: String, + @SerialName("due_date") val dueDate: String, + val type: String // "debt" or "planned_payment" +) diff --git a/app/src/main/java/com/example/budget/network/SessionManager.kt b/app/src/main/java/com/example/budget/network/SessionManager.kt new file mode 100644 index 0000000..586d971 --- /dev/null +++ b/app/src/main/java/com/example/budget/network/SessionManager.kt @@ -0,0 +1,28 @@ +package com.example.budget.network + +import android.content.Context +import android.content.SharedPreferences + +class SessionManager(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences("budget_prefs", Context.MODE_PRIVATE) + + companion object { + private const val AUTH_TOKEN = "auth_token" + } + + fun saveAuthToken(token: String) { + prefs.edit().putString(AUTH_TOKEN, token).apply() + } + + fun getAuthToken(): String? { + return prefs.getString(AUTH_TOKEN, null) + } + + fun clearSession() { + prefs.edit().remove(AUTH_TOKEN).apply() + } + + fun hasToken(): Boolean { + return getAuthToken() != null + } +} diff --git a/app/src/main/java/com/example/budget/ui/auth/AuthScreen.kt b/app/src/main/java/com/example/budget/ui/auth/AuthScreen.kt new file mode 100644 index 0000000..7e1ef56 --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/auth/AuthScreen.kt @@ -0,0 +1,76 @@ +package com.example.budget.ui.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun AuthScreen( + onLoginSuccess: () -> Unit, + viewModel: AuthViewModel = viewModel() +) { + var telegramId by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + val authState by viewModel.authState.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = "Авторизация", style = MaterialTheme.typography.headlineMedium) + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = telegramId, + onValueChange = { telegramId = it }, + label = { Text("Telegram ID") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Пароль") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (authState is AuthState.Loading) { + CircularProgressIndicator() + } else { + Button( + onClick = { viewModel.login(telegramId, password) }, + modifier = Modifier.fillMaxWidth(), + enabled = telegramId.isNotBlank() && password.isNotBlank() + ) { + Text("Войти") + } + } + + if (authState is AuthState.Error) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = (authState as AuthState.Error).message, + color = MaterialTheme.colorScheme.error + ) + } + + LaunchedEffect(authState) { + if (authState is AuthState.Success) { + onLoginSuccess() + } + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/auth/AuthViewModel.kt b/app/src/main/java/com/example/budget/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..600da5e --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/auth/AuthViewModel.kt @@ -0,0 +1,43 @@ +package com.example.budget.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.budget.network.ApiClient +import com.example.budget.network.LoginRequest +import com.example.budget.network.SessionManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed class AuthState { + object Idle : AuthState() + object Loading : AuthState() + data class Success(val token: String) : AuthState() + data class Error(val message: String) : AuthState() +} + +class AuthViewModel : ViewModel() { + + private val _authState = MutableStateFlow(AuthState.Idle) + val authState: StateFlow = _authState + + // SessionManager будет инжектироваться через Hilt в будущем + private var sessionManager: SessionManager? = null + + fun init(manager: SessionManager) { + sessionManager = manager + } + + fun login(telegramId: String, password: String) { + viewModelScope.launch { + _authState.value = AuthState.Loading + try { + val response = ApiClient.apiService.login(LoginRequest(telegramId, password)) + sessionManager?.saveAuthToken(response.token) + _authState.value = AuthState.Success(response.token) + } catch (e: Exception) { + _authState.value = AuthState.Error(e.message ?: "Ошибка авторизации") + } + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/main/MainScreen.kt b/app/src/main/java/com/example/budget/ui/main/MainScreen.kt new file mode 100644 index 0000000..e63488d --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/main/MainScreen.kt @@ -0,0 +1,118 @@ +package com.example.budget.ui.main + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.budget.network.TransactionResponse + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + onAddTransaction: () -> Unit, + onEditTransaction: (Int) -> Unit, + viewModel: MainViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadData() + } + + Scaffold( + topBar = { + TopAppBar(title = { Text("Мой Бюджет") }) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAddTransaction) { + Icon(Icons.Default.Add, contentDescription = "Добавить транзакцию") + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + when (val currentState = state) { + is MainState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is MainState.Success -> { + BalanceHeader(currentState.stats.totalBalance) + TransactionList(currentState.transactions, onEditTransaction) + } + is MainState.Error -> { + Text("Ошибка: ${currentState.message}", color = Color.Red, modifier = Modifier.padding(16.dp)) + } + } + } + } +} + +@Composable +fun BalanceHeader(balances: Map) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Общий баланс:", style = MaterialTheme.typography.titleMedium) + balances.forEach { (currency, amount) -> + Text( + text = String.format("%,.2f %s", amount, currency), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@Composable +fun TransactionList(transactions: List, onEditTransaction: (Int) -> Unit) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(transactions) { transaction -> + TransactionItem(transaction) { + onEditTransaction(transaction.id) + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + } + } +} + +@Composable +fun TransactionItem(transaction: TransactionResponse, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(text = transaction.categoryName ?: "Без категории", fontWeight = FontWeight.SemiBold) + Text(text = transaction.date, style = MaterialTheme.typography.bodySmall) + } + val color = if (transaction.type == "income") Color(0xFF4CAF50) else Color(0xFFE91E63) + val prefix = if (transaction.type == "income") "+" else "-" + Text( + text = String.format("%s%,.2f %s", prefix, transaction.amount, transaction.currency), + color = color, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } +} diff --git a/app/src/main/java/com/example/budget/ui/main/MainViewModel.kt b/app/src/main/java/com/example/budget/ui/main/MainViewModel.kt new file mode 100644 index 0000000..dadd59d --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/main/MainViewModel.kt @@ -0,0 +1,36 @@ +package com.example.budget.ui.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.budget.network.ApiClient +import com.example.budget.network.TransactionResponse +import com.example.budget.network.StatsResponse +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed class MainState { + object Loading : MainState() + data class Success(val transactions: List, val stats: StatsResponse) : MainState() + data class Error(val message: String) : MainState() +} + +class MainViewModel : ViewModel() { + + private val _state = MutableStateFlow(MainState.Loading) + val state: StateFlow = _state + + fun loadData() { + viewModelScope.launch { + _state.value = MainState.Loading + try { + // Токен добавляется автоматически через ApiClient.authInterceptor + val transactions = ApiClient.apiService.getTransactions() + val stats = ApiClient.apiService.getStats() + _state.value = MainState.Success(transactions, stats) + } catch (e: Exception) { + _state.value = MainState.Error(e.message ?: "Ошибка загрузки данных") + } + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/planning/PlanningScreen.kt b/app/src/main/java/com/example/budget/ui/planning/PlanningScreen.kt new file mode 100644 index 0000000..edf676a --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/planning/PlanningScreen.kt @@ -0,0 +1,129 @@ +package com.example.budget.ui.planning + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.budget.network.DebtResponse +import com.example.budget.network.PlannedPaymentResponse + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlanningScreen(viewModel: PlanningViewModel = viewModel()) { + val state by viewModel.state.collectAsState() + var selectedTab by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + viewModel.loadData() + } + + Scaffold( + topBar = { + TopAppBar(title = { Text("Планирование") }) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + TabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text("Долги", modifier = Modifier.padding(16.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text("Платежи", modifier = Modifier.padding(16.dp)) + } + } + + when (val s = state) { + is PlanningState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is PlanningState.Error -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Ошибка: ${s.message}", color = MaterialTheme.colorScheme.error) + } + } + is PlanningState.Success -> { + if (selectedTab == 0) { + DebtList(s.debts) + } else { + PlannedPaymentList(s.plannedPayments) + } + } + } + } + } +} + +@Composable +fun DebtList(debts: List) { + if (debts.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Нет долгов") + } + } else { + LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(debts) { debt -> + DebtItem(debt) + } + } + } +} + +@Composable +fun DebtItem(debt: DebtResponse) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(debt.name, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text( + "${debt.amount} ${debt.currency}", + fontWeight = FontWeight.Bold, + color = if (debt.type == "credit") Color(0xFF4CAF50) else Color(0xFFE91E63) + ) + } + Text("Тип: ${if (debt.type == "credit") "Мне должны" else "Я должен"}") + debt.dueDate?.let { Text("Срок: $it") } + if (debt.isPaid) { + Text("Оплачено", color = Color(0xFF4CAF50), fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +fun PlannedPaymentList(payments: List) { + if (payments.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Нет плановых платежей") + } + } else { + LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(payments) { payment -> + PlannedPaymentItem(payment) + } + } + } +} + +@Composable +fun PlannedPaymentItem(payment: PlannedPaymentResponse) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(payment.name, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text("${payment.amount} ${payment.currency}", fontWeight = FontWeight.Bold) + } + Text("Частота: ${payment.frequency}") + Text("След. платеж: ${payment.nextPaymentDate}") + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/planning/PlanningViewModel.kt b/app/src/main/java/com/example/budget/ui/planning/PlanningViewModel.kt new file mode 100644 index 0000000..e1f09ee --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/planning/PlanningViewModel.kt @@ -0,0 +1,35 @@ +package com.example.budget.ui.planning + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.budget.network.ApiClient +import com.example.budget.network.DebtResponse +import com.example.budget.network.PlannedPaymentResponse +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed class PlanningState { + object Loading : PlanningState() + data class Success(val debts: List, val plannedPayments: List) : PlanningState() + data class Error(val message: String) : PlanningState() +} + +class PlanningViewModel : ViewModel() { + + private val _state = MutableStateFlow(PlanningState.Loading) + val state: StateFlow = _state + + fun loadData() { + viewModelScope.launch { + _state.value = PlanningState.Loading + try { + val debts = ApiClient.apiService.getDebts() + val plannedPayments = ApiClient.apiService.getPlannedPayments() + _state.value = PlanningState.Success(debts, plannedPayments) + } catch (e: Exception) { + _state.value = PlanningState.Error(e.message ?: "Ошибка загрузки данных") + } + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/stats/StatsScreen.kt b/app/src/main/java/com/example/budget/ui/stats/StatsScreen.kt new file mode 100644 index 0000000..2fe261e --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/stats/StatsScreen.kt @@ -0,0 +1,161 @@ +package com.example.budget.ui.stats + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.budget.network.CategoryStat +import com.example.budget.network.UpcomingPayment + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatsScreen(viewModel: StatsViewModel = viewModel()) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadStats() + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Анализ") }) } + ) { padding -> + when (val s = state) { + is StatsState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is StatsState.Error -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Ошибка: ${s.message}", color = MaterialTheme.colorScheme.error) + Button(onClick = { viewModel.loadStats() }) { + Text("Повторить") + } + } + } + } + is StatsState.Success -> { + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text("Расходы по категориям", style = MaterialTheme.typography.titleLarge) + } + + val expenses = s.stats.byCategory.filter { it.type == "expense" } + if (expenses.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Text( + "Нет данных о расходах за этот месяц", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(expenses) { stat -> + CategoryStatItem(stat) + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Text("Ближайшие платежи", style = MaterialTheme.typography.titleLarge) + } + + if (s.stats.upcomingPayments.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Text( + "Нет запланированных платежей", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(s.stats.upcomingPayments) { payment -> + UpcomingPaymentItem(payment) + } + } + + item { + Button( + onClick = { viewModel.loadStats() }, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp) + ) { + Text("Обновить") + } + } + } + } + } + } +} + +@Composable +fun CategoryStatItem(stat: CategoryStat) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stat.categoryName, fontWeight = FontWeight.Medium) + Text( + "${String.format("%,.2f", stat.amount)} ${stat.currency}", + color = Color(0xFFE91E63), + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +fun UpcomingPaymentItem(payment: UpcomingPayment) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (payment.type == "debt") + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(payment.name, fontWeight = FontWeight.Bold) + Text( + "${String.format("%,.2f", payment.amount)} ${payment.currency}", + fontWeight = FontWeight.Bold + ) + } + Text( + "Дата: ${payment.dueDate} • ${if (payment.type == "debt") "Долг" else "План"}", + style = MaterialTheme.typography.bodySmall + ) + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/stats/StatsViewModel.kt b/app/src/main/java/com/example/budget/ui/stats/StatsViewModel.kt new file mode 100644 index 0000000..49872a4 --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/stats/StatsViewModel.kt @@ -0,0 +1,46 @@ +package com.example.budget.ui.stats + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.budget.network.ApiClient +import com.example.budget.network.StatsResponse +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* + +sealed class StatsState { + object Loading : StatsState() + data class Success(val stats: StatsResponse) : StatsState() + data class Error(val message: String) : StatsState() +} + +class StatsViewModel : ViewModel() { + + private val _state = MutableStateFlow(StatsState.Loading) + val state: StateFlow = _state + + fun loadStats() { + viewModelScope.launch { + _state.value = StatsState.Loading + try { + val calendar = Calendar.getInstance() + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + + // First day of current month + calendar.set(Calendar.DAY_OF_MONTH, 1) + val startDate = sdf.format(calendar.time) + + // Last day of current month + calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH)) + val endDate = sdf.format(calendar.time) + + val stats = ApiClient.apiService.getStats(startDate, endDate) + _state.value = StatsState.Success(stats) + } catch (e: Exception) { + _state.value = StatsState.Error(e.message ?: "Ошибка загрузки статистики") + } + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/transaction/AddEditTransactionScreen.kt b/app/src/main/java/com/example/budget/ui/transaction/AddEditTransactionScreen.kt new file mode 100644 index 0000000..2d2a92a --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/transaction/AddEditTransactionScreen.kt @@ -0,0 +1,203 @@ +package com.example.budget.ui.transaction + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditTransactionScreen( + transactionId: Int? = null, + onNavigateBack: () -> Unit, + viewModel: AddEditTransactionViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val categories by viewModel.categories.collectAsState() + val wallets by viewModel.wallets.collectAsState() + val existingTransaction by viewModel.existingTransaction.collectAsState() + + var amount by remember { mutableStateOf("") } + var type by remember { mutableStateOf("expense") } + var selectedCategoryId by remember { mutableStateOf(null) } + var selectedWalletId by remember { mutableStateOf(null) } + var comment by remember { mutableStateOf("") } + var date by remember { mutableStateOf(SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())) } + + var categoryExpanded by remember { mutableStateOf(false) } + var walletExpanded by remember { mutableStateOf(false) } + + LaunchedEffect(transactionId) { + if (transactionId != null) { + viewModel.loadTransaction(transactionId) + } + } + + LaunchedEffect(existingTransaction) { + existingTransaction?.let { + amount = it.amount.toString() + type = it.type + selectedCategoryId = it.categoryId + selectedWalletId = it.walletId + comment = it.comment ?: "" + date = it.date + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (transactionId == null) "Новая транзакция" else "Редактирование") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Назад") + } + }, + actions = { + if (transactionId != null) { + IconButton(onClick = { viewModel.deleteTransaction(transactionId) }) { + Icon(Icons.Default.Delete, contentDescription = "Удалить") + } + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Type Toggle + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + FilterChip( + selected = type == "expense", + onClick = { type = "expense" }, + label = { Text("Расход") } + ) + Spacer(modifier = Modifier.width(8.dp)) + FilterChip( + selected = type == "income", + onClick = { type = "income" }, + label = { Text("Доход") } + ) + } + + OutlinedTextField( + value = amount, + onValueChange = { if (it.isEmpty() || it.toDoubleOrNull() != null) amount = it }, + label = { Text("Сумма") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + + // Category Dropdown + ExposedDropdownMenuBox( + expanded = categoryExpanded, + onExpandedChange = { categoryExpanded = !categoryExpanded } + ) { + OutlinedTextField( + value = categories.find { it.id == selectedCategoryId }?.name ?: "Выберите категорию", + onValueChange = {}, + readOnly = true, + label = { Text("Категория") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = categoryExpanded, + onDismissRequest = { categoryExpanded = false } + ) { + categories.filter { it.type == type }.forEach { category -> + DropdownMenuItem( + text = { Text(category.name) }, + onClick = { + selectedCategoryId = category.id + categoryExpanded = false + } + ) + } + } + } + + // Wallet Dropdown + ExposedDropdownMenuBox( + expanded = walletExpanded, + onExpandedChange = { walletExpanded = !walletExpanded } + ) { + OutlinedTextField( + value = wallets.find { it.id == selectedWalletId }?.name ?: "Выберите кошелек", + onValueChange = {}, + readOnly = true, + label = { Text("Кошелек") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = walletExpanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = walletExpanded, + onDismissRequest = { walletExpanded = false } + ) { + wallets.forEach { wallet -> + DropdownMenuItem( + text = { Text("${wallet.name} (${wallet.currency})") }, + onClick = { + selectedWalletId = wallet.id + walletExpanded = false + } + ) + } + } + } + + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Комментарий") }, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + viewModel.saveTransaction( + id = transactionId, + amount = amount.toDoubleOrNull() ?: 0.0, + type = type, + categoryId = selectedCategoryId, + walletId = selectedWalletId, + comment = comment, + date = date + ) + }, + modifier = Modifier.fillMaxWidth(), + enabled = amount.isNotBlank() && state !is AddEditState.Loading + ) { + Text(if (state is AddEditState.Loading) "Сохранение..." else "Сохранить") + } + + if (state is AddEditState.Error) { + Text( + text = (state as AddEditState.Error).message, + color = MaterialTheme.colorScheme.error + ) + } + + LaunchedEffect(state) { + if (state is AddEditState.Success) { + onNavigateBack() + } + } + } + } +} diff --git a/app/src/main/java/com/example/budget/ui/transaction/AddEditTransactionViewModel.kt b/app/src/main/java/com/example/budget/ui/transaction/AddEditTransactionViewModel.kt new file mode 100644 index 0000000..32826ba --- /dev/null +++ b/app/src/main/java/com/example/budget/ui/transaction/AddEditTransactionViewModel.kt @@ -0,0 +1,115 @@ +package com.example.budget.ui.transaction + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.budget.network.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* + +sealed class AddEditState { + object Idle : AddEditState() + object Loading : AddEditState() + object Success : AddEditState() + data class Error(val message: String) : AddEditState() +} + +class AddEditTransactionViewModel : ViewModel() { + + private val _state = MutableStateFlow(AddEditState.Idle) + val state: StateFlow = _state + + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories + + private val _wallets = MutableStateFlow>(emptyList()) + val wallets: StateFlow> = _wallets + + private val _existingTransaction = MutableStateFlow(null) + val existingTransaction: StateFlow = _existingTransaction + + init { + loadMetadata() + } + + private fun loadMetadata() { + viewModelScope.launch { + try { + _categories.value = ApiClient.apiService.getCategories() + _wallets.value = ApiClient.apiService.getWallets() + } catch (e: Exception) { + // Handle metadata load error + } + } + } + + fun loadTransaction(id: Int) { + viewModelScope.launch { + _state.value = AddEditState.Loading + try { + val transaction = ApiClient.apiService.getTransaction(id) + _existingTransaction.value = transaction + _state.value = AddEditState.Idle + } catch (e: Exception) { + _state.value = AddEditState.Error(e.message ?: "Ошибка загрузки транзакции") + } + } + } + + fun saveTransaction( + id: Int?, + amount: Double, + type: String, + categoryId: Int?, + walletId: Int?, + comment: String, + date: String + ) { + viewModelScope.launch { + _state.value = AddEditState.Loading + try { + if (id == null) { + ApiClient.apiService.createTransaction( + TransactionCreate( + amount = amount, + type = type, + categoryId = categoryId, + walletId = walletId, + comment = comment, + date = date + ) + ) + } else { + ApiClient.apiService.updateTransaction( + id, + TransactionUpdate( + amount = amount, + type = type, + categoryId = categoryId, + walletId = walletId, + comment = comment, + date = date + ) + ) + } + _state.value = AddEditState.Success + } catch (e: Exception) { + _state.value = AddEditState.Error(e.message ?: "Ошибка сохранения") + } + } + } + + fun deleteTransaction(id: Int) { + viewModelScope.launch { + _state.value = AddEditState.Loading + try { + ApiClient.apiService.deleteTransaction(id) + _state.value = AddEditState.Success + } catch (e: Exception) { + _state.value = AddEditState.Error(e.message ?: "Ошибка удаления") + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..798feb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..d79b0e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..fcba678 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #3DDC84 + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a5072b3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..035673b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,53 @@ +[versions] +activity-compose = "1.8.2" +android-gradle-plugin = "8.13.2" +appcompat = "1.6.1" +compose-bom = "2024.02.01" +core-ktx = "1.12.0" +espresso-core = "3.5.1" +junit = "4.13.2" +junit-ext = "1.1.5" +kotlin = "1.9.22" +lifecycle-runtime-ktx = "2.7.0" +material3 = "1.2.0" +navigation-compose = "2.7.7" +retrofit = "2.9.0" +okhttp = "4.12.0" +serialization = "1.6.3" + +[libraries] +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" } +junit = { module = "junit:junit", version.ref = "junit" } +junit-ext = { module = "androidx.test.ext:junit", version.ref = "junit-ext" } +lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } +lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime-ktx" } +material3 = { module = "androidx.compose.material3:material3" } +material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version = "1.6.3"} +navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +ui = { module = "androidx.compose.ui:ui" } +ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } + +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-serialization = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version = "1.0.0" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } + +[bundles] +base = ["core-ktx", "lifecycle-runtime-ktx", "activity-compose", "lifecycle-viewmodel-compose"] +compose = ["ui", "ui-graphics", "ui-tooling-preview", "material3", "material-icons-extended"] +compose-debug = ["ui-tooling", "ui-test-manifest"] +android-test = ["junit-ext", "espresso-core"] +network = ["retrofit", "retrofit-serialization", "okhttp-logging", "kotlinx-serialization-json", "navigation-compose"] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..36e4933 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 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 + +# 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 + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +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 + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ba03468 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "BudgetApp" +include(":app")