diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..813f6cc 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index fdf8d99..1dbfe25 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,13 @@ + + + + - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22f5a9b..a42c2f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,11 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.devtools.ksp) + alias(libs.plugins.compose.compiler) + + id("kotlin-kapt") + alias(libs.plugins.hilt.android) } android { @@ -40,7 +45,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = "1.5.1" + kotlinCompilerExtensionVersion = "1.5.15" } packaging { resources { @@ -50,7 +55,11 @@ android { } dependencies { + implementation(libs.hilt.android) + kapt(libs.hilt.android.compiler) + + ksp(libs.androidx.room.compiler) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -59,7 +68,8 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation(libs.androidx.datastore) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -67,4 +77,9 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file +} + +// Allow references to generated code +kapt { + correctErrorTypes = true +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 804086b..f0dd42a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + tools:targetApi="35"> + Unit +) { + val total by viewModel.totalBalance.collectAsState(initial = 0) + val wallets by viewModel.wallets.collectAsState(initial = listOf()) Column { BalanceView(total) WalletList( wallets = wallets, - onAdd = { viewModel.addWallet(it) } + onClick = { + onClick(it) + }, + onAdd = { + viewModel.addWallet(it) + } ) } } @@ -117,170 +140,129 @@ fun BalanceView(balance: Int) { } } -@Composable -fun AddWalletBox( - onAdd: (String) -> Unit -) { - var name by remember { mutableStateOf("") } - - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 2.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.width(16.dp)) - BasicTextField( - value = name, - onValueChange = { - name = it - }, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - singleLine = true, - textStyle = TextStyle.Default - ) - IconButton( - onClick = { - if (name.isNotBlank()) { - onAdd(name) - name = "" - } - }, - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Add wallet" - ) - } - } - } - } -} - +@OptIn(ExperimentalLayoutApi::class) @Composable fun WalletList( wallets: List, + onClick: (Wallet) -> Unit, onAdd: (String) -> Unit ) { - LazyColumn( - modifier = Modifier.width(500.dp) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + maxItemsInEachRow = 3 ) { - items(wallets) { wallet -> - WalletCard(wallet) - } - item { - AddWalletBox(onAdd) + wallets.forEach { wallet -> + WalletCard( + wallet = wallet, + onClick = { + onClick(wallet) + } + ) } + AddWalletButton( + onAdd = onAdd + ) } } @Composable -fun WalletCard(wallet: Wallet) { - var expanded by rememberSaveable { mutableStateOf(false) } - val balance by wallet.balance.collectAsState() - +fun WalletCard( + wallet: Wallet, + onClick: () -> Unit +) { Button( - onClick = { - expanded = !expanded - }, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 2.dp), + modifier = Modifier.padding(8.dp), + onClick = onClick, colors = ButtonDefaults.buttonColors().copy( containerColor = CardDefaults.cardColors().containerColor, contentColor = CardDefaults.cardColors().contentColor ), - contentPadding = PaddingValues(8.dp, 2.dp) + shape = RoundedCornerShape(30), + contentPadding = PaddingValues(16.dp, 4.dp) ) { - Column { - AnimatedVisibility(visible = !expanded) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 6.dp) - ) { - Text(wallet.name) - Spacer(modifier = Modifier - .fillMaxWidth() - .weight(1f)) - Text("%.2f zł".format(balance / 100.0)) - } - } - AnimatedVisibility(visible = expanded) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(wallet.name) - Text( - text = "%.2f zł".format(balance / 100.0), - fontSize = 32.sp - ) - UnitButtons( - onClick = { - wallet.fund(it) - } - ) - } - } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(wallet.label) + Text(formatCurrency(wallet.balance)) } } } -@OptIn(ExperimentalLayoutApi::class) @Composable -fun UnitButtons( - onClick: (Int) -> Unit +fun AddWalletButton( + onAdd: (String) -> Unit ) { - val increments = listOf(1, 2, 5, 10, 20, 50, 100, 200, 500) - var add by rememberSaveable { mutableStateOf(true) } + var label by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val angle by animateFloatAsState( + targetValue = if (expanded && label.isBlank()) 45f else 0f, + label = "Add button rotation" + ) + val focusRequester = remember { FocusRequester() } - Row { - TextButton( - onClick = { add = false }, - modifier = Modifier - .size(48.dp) - .aspectRatio(1f), - border = if (!add) BorderStroke(1.dp, Color.Red) else null, + Card( + modifier = Modifier + .padding(8.dp) + .height(48.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "-", - fontSize = 24.sp - ) - } - Spacer(Modifier.width(5.dp)) - TextButton( - onClick = { add = true }, - modifier = Modifier - .size(48.dp) - .aspectRatio(1f), - border = if (add) BorderStroke(1.dp, Color.Green) else null, + AnimatedVisibility( + visible = expanded ) { - Text( - text = "+", - fontSize = 24.sp - ) - } - } - FlowRow { - increments.forEach { - TextButton(onClick = { - onClick(if (add) it else -it) - }) { - Text(formatCurrency(it)) + BasicTextField( + value = label, + onValueChange = { label = it }, + modifier = Modifier + .padding(start = 16.dp) + .focusRequester(focusRequester), + textStyle = LocalTextStyle.current, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + expanded = false + onAdd(label) + label = "" + } + ), + singleLine = true + ) + LaunchedEffect(expanded) { + focusRequester.requestFocus() + } + } + Button( + modifier = Modifier.fillMaxHeight(), + onClick = { + if (!expanded) { + expanded = true + } else { + if (label.isBlank()) { + expanded = false + } else { + onAdd(label) + expanded = false + } + label = "" + } + }, + colors = ButtonDefaults.buttonColors().copy( + containerColor = CardDefaults.cardColors().containerColor, + contentColor = CardDefaults.cardColors().contentColor + ), + shape = RoundedCornerShape(30), + contentPadding = PaddingValues(16.dp, 4.dp) + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add wallet", + modifier = Modifier.rotate(angle) + ) } } } diff --git a/app/src/main/java/eu/m724/coincounter/MainViewModel.kt b/app/src/main/java/eu/m724/coincounter/MainViewModel.kt index 5884569..eeb11ec 100644 --- a/app/src/main/java/eu/m724/coincounter/MainViewModel.kt +++ b/app/src/main/java/eu/m724/coincounter/MainViewModel.kt @@ -1,66 +1,34 @@ package eu.m724.coincounter -import android.app.Application -import androidx.core.content.edit -import androidx.lifecycle.AndroidViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.m724.coincounter.data.entity.Wallet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject -class MainViewModel( - application: Application -) : AndroidViewModel(application) { - private val _totalBalance: MutableStateFlow = MutableStateFlow(0) - val totalBalance: StateFlow = _totalBalance.asStateFlow() +@HiltViewModel +class MainViewModel @Inject constructor( + private val repository: WalletRepository +) : ViewModel() { + val wallets: Flow> = repository.getAllWallets() - private val _wallets: MutableStateFlow> = MutableStateFlow(listOf()) - val wallets: StateFlow> = _wallets.asStateFlow() + private val _openEvent = MutableSharedFlow() + val openEvent = _openEvent.asSharedFlow() - private val dataPreferences = application.getSharedPreferences("data", Application.MODE_PRIVATE) - private val walletsPreferences = application.getSharedPreferences("wallets", Application.MODE_PRIVATE) - - private var highestId = 0 - - fun init() { - val savedWallets = dataPreferences.getStringSet("walletIds", null) - - if (savedWallets != null) { - val savedWalletIds = savedWallets.map { it.toInt() }.sorted() - val w = arrayListOf() - - savedWalletIds.forEach { id -> - val wallet = Wallet.fromPreferences(id, walletsPreferences) { wa, mo -> onWalletUpdate(wa, mo) } - w.add(wallet) - _totalBalance.value += wallet.balance.value - if (wallet.id > highestId) - highestId = wallet.id - } - - _wallets.value = w - } else { - _wallets.value = listOf(Wallet.default { wa, mo -> onWalletUpdate(wa, mo) }) - _totalBalance.value = 2000 - } - } - - fun saveAll() { - dataPreferences.edit(true) { - putStringSet("walletIds", wallets.value.map { it.id.toString() }.toSet()) - } - - - walletsPreferences.edit(true) { - wallets.value.forEach { - it.toPreferences(this) - } - } + val totalBalance: Flow = wallets.map { wallets -> + wallets.sumOf { it.balance } } fun addWallet(name: String) { - _wallets.value += Wallet(++highestId, name, 0) { wa, mo -> onWalletUpdate(wa, mo) } - } - - private fun onWalletUpdate(wallet: Wallet, money: Int) { - _totalBalance.value += money + val wallet = Wallet(label = name) + viewModelScope.launch { + val id = repository.insertWallet(wallet) + _openEvent.emit(id) + } } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/MyApplication.kt b/app/src/main/java/eu/m724/coincounter/MyApplication.kt new file mode 100644 index 0000000..e2080de --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/MyApplication.kt @@ -0,0 +1,12 @@ +package eu.m724.coincounter + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MyApplication : Application() { + + override fun onCreate() { + super.onCreate() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/Wallet.kt b/app/src/main/java/eu/m724/coincounter/Wallet.kt deleted file mode 100644 index e548b2b..0000000 --- a/app/src/main/java/eu/m724/coincounter/Wallet.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eu.m724.coincounter - -import android.content.SharedPreferences -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -class Wallet( - val id: Int, - val name: String, - balance: Int, - val onUpdate: (Wallet, Int) -> Unit -) { - companion object { - fun default(onUpdate: (Wallet, Int) -> Unit): Wallet { - return Wallet(0, "Primary", 2000, onUpdate) - } - - fun fromPreferences(id: Int, sharedPreferences: SharedPreferences, onUpdate: (Wallet, Int) -> Unit): Wallet { - return Wallet( - id, - sharedPreferences.getString("${id}_name", "Wallet")!!, - sharedPreferences.getInt("${id}_balance", 0), - onUpdate - ) - } - } - - fun toPreferences(editor: SharedPreferences.Editor) { - editor.putString("${id}_name", name) - editor.putInt("${id}_balance", balance.value) - } - - private val _balance: MutableStateFlow = MutableStateFlow(balance) - val balance: StateFlow = _balance.asStateFlow() - - fun fund(money: Int) { - _balance.value += money - onUpdate(this, money) - } -} diff --git a/app/src/main/java/eu/m724/coincounter/WalletActivity.kt b/app/src/main/java/eu/m724/coincounter/WalletActivity.kt new file mode 100644 index 0000000..4dfaeda --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/WalletActivity.kt @@ -0,0 +1,291 @@ +package eu.m724.coincounter + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Create +import androidx.compose.material3.Card +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import dagger.hilt.android.AndroidEntryPoint +import eu.m724.coincounter.ui.theme.CoinCounterTheme +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +@AndroidEntryPoint +class WalletActivity : ComponentActivity() { + private val viewModel: WalletViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val walletId = intent.getLongExtra("walletId", 0) + viewModel.init(walletId) + + enableEdgeToEdge() + setContent { + CoinCounterTheme { + var transactionDialogShown by rememberSaveable { mutableStateOf(false) } + if (transactionDialogShown) { + TransactionDialog( + onDismiss = { transactionDialogShown = false }, + onConfirm = { label, value -> + transactionDialogShown = false + viewModel.transact(value, label) + } + ) + } + Scaffold( + modifier = Modifier.fillMaxSize(), + floatingActionButton = { + FloatingActionButton(onClick = { + transactionDialogShown = true + }) { + Icon(Icons.Filled.Create, "New transaction") + } + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + ) { + App( + viewModel = viewModel, + finish = { finish() } + ) + } + } + } + } + } +} + +@Composable +fun App( + viewModel: WalletViewModel, + finish: () -> Unit +) { + val walletState by viewModel.wallet.collectAsState(initial = null) + val wallet = walletState + + var actionsVisible by rememberSaveable { mutableStateOf(false) } + + if (actionsVisible) { + ActionsDialog( + onDismiss = { actionsVisible = false }, + onDelete = { + viewModel.delete() + finish() + } + ) + } + + if (wallet != null) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(50.dp)) + Text( + text = wallet.label, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onLongPress = { + actionsVisible = true + } + ) + } + ) + + Spacer(modifier = Modifier.height(4.dp)) + Card { + Row( + modifier = Modifier.padding(16.dp, 8.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = "%.2f".format(wallet.balance / 100.0), + fontSize = 32.sp + ) + Text( + text = "zł", + fontSize = 14.sp + ) + } + } + Spacer(modifier = Modifier.height(50.dp)) + + TransactionList(viewModel = viewModel) + } + } +} + +@Composable +fun TransactionList(viewModel: WalletViewModel) { + val transactions by viewModel.transactions.collectAsState(initial = listOf()) + + LazyColumn { + items(transactions) { + Card( + modifier = Modifier + .width(200.dp) + .padding(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp, 2.dp), + horizontalArrangement = Arrangement.Center + ) { + Text(it.label ?: it.id.toString()) + + Spacer(modifier = Modifier + .fillMaxWidth() + .weight(1f)) + + Text( + text = "%.2f".format(it.value / 100.0), + color = if (it.value < 0) MaterialTheme.colorScheme.error else Color.Unspecified + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) + .format(Date(it.timestamp)) + ) + } + } + } + } +} + + +@Composable +fun TransactionDialog( + onDismiss: () -> Unit, + onConfirm: (String, Int) -> Unit +) { + var label by rememberSaveable { mutableStateOf("") } + var value by rememberSaveable { mutableStateOf("0") } + + Dialog( + onDismissRequest = { + label = "" + onDismiss() + } + ) { + Card( + modifier = Modifier + .fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row { + TextField( + value = label, + onValueChange = { label = it }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + supportingText = { Text("Label") }, + singleLine = true + ) + Spacer(modifier = Modifier.width(6.dp)) + TextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + supportingText = { Text("Value") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { + label = "" + onDismiss() + }) { + Text(text = "Cancel") + } + TextButton(onClick = { + onConfirm(label, (value.toFloat() * 100).toInt()) + }) { + Text("Create transaction") + } + } + } + } + } +} + +@Composable +fun ActionsDialog( + onDismiss: () -> Unit, + onDelete: () -> Unit +) { + Dialog( + onDismissRequest = { + onDismiss() + } + ) { + Card( + modifier = Modifier + .fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + TextButton(onClick = onDelete) { + Text("Delete wallet") + } + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/WalletRepository.kt b/app/src/main/java/eu/m724/coincounter/WalletRepository.kt new file mode 100644 index 0000000..aaf8ec2 --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/WalletRepository.kt @@ -0,0 +1,37 @@ +package eu.m724.coincounter + +import android.content.Context +import androidx.room.Room +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.m724.coincounter.data.AppDatabase +import eu.m724.coincounter.data.dao.TransactionDao +import eu.m724.coincounter.data.dao.WalletDao +import eu.m724.coincounter.data.entity.Transaction +import eu.m724.coincounter.data.entity.Wallet +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WalletRepository @Inject constructor( + @ApplicationContext private val applicationContext: Context +) { + private val database: AppDatabase = Room.databaseBuilder( + applicationContext, + AppDatabase::class.java, + "database-v1" + ).build() + private val walletDao: WalletDao = database.walletDao() + private val transactionDao: TransactionDao = database.transactionDao() + + fun getAllWallets(): Flow> = walletDao.getAllWallets() + fun getWalletById(id: Long): Flow = walletDao.getWalletById(id) + + suspend fun insertWallet(wallet: Wallet): Long = walletDao.insertWallet(wallet) + fun updateWallet(wallet: Wallet) = walletDao.updateWallet(wallet) + fun deleteWallet(wallet: Wallet) = walletDao.deleteWallet(wallet) + + fun getWalletTransactions(walletId: Long) = transactionDao.getWalletTransactions(walletId) + + fun insertTransaction(transaction: Transaction) = transactionDao.insertTransaction(transaction) +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/WalletViewModel.kt b/app/src/main/java/eu/m724/coincounter/WalletViewModel.kt new file mode 100644 index 0000000..9e17164 --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/WalletViewModel.kt @@ -0,0 +1,53 @@ +package eu.m724.coincounter + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.m724.coincounter.data.entity.Transaction +import eu.m724.coincounter.data.entity.Wallet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.properties.Delegates + +@HiltViewModel +class WalletViewModel @Inject constructor( + private val repository: WalletRepository +) : ViewModel() { + private var walletId by Delegates.notNull() + + lateinit var wallet: Flow + lateinit var transactions: Flow> + + fun init(walletId: Long) { + this.walletId = walletId + wallet = repository.getWalletById(walletId) + transactions = repository.getWalletTransactions(walletId) + } + + fun delete() { + viewModelScope.launch(Dispatchers.IO) { + repository.deleteWallet(wallet.first()) + } + } + + fun transact(value: Int, label: String?) { + val transaction = Transaction( + walletId = walletId, + value = value, + label = label + ) + + viewModelScope.launch(Dispatchers.IO) { + val wallet = wallet.first() + wallet.let { + repository.updateWallet( + wallet.copy(balance = wallet.balance + value) + ) + } + repository.insertTransaction(transaction) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/data/AppDatabase.kt b/app/src/main/java/eu/m724/coincounter/data/AppDatabase.kt new file mode 100644 index 0000000..896329e --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/data/AppDatabase.kt @@ -0,0 +1,14 @@ +package eu.m724.coincounter.data + +import androidx.room.Database +import androidx.room.RoomDatabase +import eu.m724.coincounter.data.dao.TransactionDao +import eu.m724.coincounter.data.dao.WalletDao +import eu.m724.coincounter.data.entity.Transaction +import eu.m724.coincounter.data.entity.Wallet + +@Database(entities = [Wallet::class, Transaction::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun walletDao(): WalletDao + abstract fun transactionDao(): TransactionDao +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/data/dao/TransactionDao.kt b/app/src/main/java/eu/m724/coincounter/data/dao/TransactionDao.kt new file mode 100644 index 0000000..2855d3d --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/data/dao/TransactionDao.kt @@ -0,0 +1,26 @@ +package eu.m724.coincounter.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import eu.m724.coincounter.data.entity.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +interface TransactionDao { + @Query("SELECT * FROM transactions WHERE walletId = :walletId AND timestamp > :after ORDER BY timestamp DESC LIMIT :limit") + fun getWalletTransactions(walletId: Long, after: Long = 0, limit: Int = 10): Flow> + + @Insert + fun insertTransaction(transaction: Transaction) + + @Insert + fun insertTransactions(transactions: List) + + @Delete + fun deleteTransaction(transaction: Transaction) + + @Insert + fun deleteTransactions(transactions: List) +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/data/dao/WalletDao.kt b/app/src/main/java/eu/m724/coincounter/data/dao/WalletDao.kt new file mode 100644 index 0000000..5218838 --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/data/dao/WalletDao.kt @@ -0,0 +1,36 @@ +package eu.m724.coincounter.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import eu.m724.coincounter.data.entity.Wallet +import kotlinx.coroutines.flow.Flow + +@Dao +interface WalletDao { + @Query("SELECT * FROM wallets") + fun getAllWallets(): Flow> + + @Query("SELECT * FROM wallets WHERE id = (:id)") + fun getWalletById(id: Long): Flow + + @Insert + suspend fun insertWallet(wallet: Wallet): Long + + @Insert + suspend fun insertWallets(wallets: List): LongArray + + @Update + fun updateWallet(wallet: Wallet) + + @Update + fun updateWallets(wallets: List) + + @Delete + fun deleteWallet(wallet: Wallet) + + @Delete + fun deleteWallets(wallet: List) +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/data/entity/Transaction.kt b/app/src/main/java/eu/m724/coincounter/data/entity/Transaction.kt new file mode 100644 index 0000000..e2a3c35 --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/data/entity/Transaction.kt @@ -0,0 +1,40 @@ +package eu.m724.coincounter.data.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +/** + * represents adding or removing money from a wallet, + * with optional metadata + */ +@Entity( + tableName = "transactions", + foreignKeys = [ + ForeignKey( + entity = Wallet::class, + parentColumns = ["id"], + childColumns = ["walletId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class Transaction( + /** used for internal reference */ + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** + * the wallet id that money was used from + */ + val walletId: Long, + + /** how much cents added (positive) or removed (negative) */ + val value: Int, + + /** label of the transaction, set by user */ + val label: String? = null, + + /** timestamp in unix milllis when the transaction was performed */ + val timestamp: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/coincounter/data/entity/Wallet.kt b/app/src/main/java/eu/m724/coincounter/data/entity/Wallet.kt new file mode 100644 index 0000000..7770b33 --- /dev/null +++ b/app/src/main/java/eu/m724/coincounter/data/entity/Wallet.kt @@ -0,0 +1,17 @@ +package eu.m724.coincounter.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "wallets") +data class Wallet( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** the wallet label set by user */ + var label: String = "Wallet", + + /** balance in cents + * for example 123 cents = 1.23 euro or any other currency */ + var balance: Int = 0 +) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a915b7b..d5fa150 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Coin Counter MainActivity + WalletActivity \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index f74b04b..fbdde38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.devtools.ksp) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.hilt.android) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d6d8b3..1faad84 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,22 @@ [versions] -agp = "8.5.1" -datastore = "1.1.1" -kotlin = "1.9.0" -coreKtx = "1.10.1" +agp = "8.5.2" +kotlin = "2.0.10" +coreKtx = "1.13.1" junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" -lifecycleRuntimeKtx = "2.6.1" -activityCompose = "1.8.0" -composeBom = "2024.04.01" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +lifecycleRuntimeKtx = "2.8.4" +activityCompose = "1.9.1" +composeBom = "2024.06.00" +room = "2.6.1" +ksp = "2.0.10-1.0.24" +hilt = "2.44" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -26,8 +30,13 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt"} +hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt"}