newer version

This commit is contained in:
Minecon724 2024-08-10 17:08:24 +02:00
parent a7c9957512
commit b5610ae7b0
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
19 changed files with 757 additions and 273 deletions

View file

@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-08-09T09:07:46.136113340Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/user/.android/avd/Pixel_Fold_API_34.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View file

@ -1,6 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
</component>
<component name="KotlinCommonCompilerArguments">
<option name="apiVersion" value="2.0" />
<option name="languageVersion" value="2.0" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
<option name="version" value="2.0.10" />
</component>
</project>

View file

@ -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)
@ -68,3 +78,8 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
// Allow references to generated code
kapt {
correctErrorTypes = true
}

View file

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
@ -12,7 +13,12 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CoinCounter"
tools:targetApi="31">
tools:targetApi="35">
<activity
android:name=".WalletActivity"
android:exported="false"
android:label="@string/title_activity_wallet"
android:theme="@style/Theme.CoinCounter" />
<activity
android:name=".MainActivity"
android:exported="true"

View file

@ -1,11 +1,13 @@
package eu.m724.coincounter
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -13,17 +15,15 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
@ -31,33 +31,42 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.text.TextStyle
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import eu.m724.coincounter.data.entity.Wallet
import eu.m724.coincounter.ui.theme.CoinCounterTheme
import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private lateinit var viewModel: MainViewModel
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = MainViewModel(application)
viewModel.init()
lifecycleScope.launch {
viewModel.openEvent.collect {
openWallet(it)
}
}
enableEdgeToEdge()
setContent {
@ -66,29 +75,43 @@ class MainActivity : ComponentActivity() {
Box(
modifier = Modifier.padding(innerPadding)
) {
App(viewModel)
App(
viewModel = viewModel,
onClick = {
openWallet(it.id)
}
)
}
}
}
}
}
override fun onStop() {
super.onStop()
viewModel.saveAll()
private fun openWallet(walletId: Long) {
val intent = Intent(application.applicationContext, WalletActivity::class.java)
intent.putExtra("walletId", walletId)
startActivity(intent)
}
}
@Composable
fun App(viewModel: MainViewModel) {
val total by viewModel.totalBalance.collectAsState()
val wallets by viewModel.wallets.collectAsState()
fun App(
viewModel: MainViewModel,
onClick: (Wallet) -> 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<Wallet>,
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)
wallets.forEach { wallet ->
WalletCard(
wallet = wallet,
onClick = {
onClick(wallet)
}
item {
AddWalletBox(onAdd)
)
}
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)
}
)
}
}
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 },
Card(
modifier = Modifier
.size(48.dp)
.aspectRatio(1f),
border = if (!add) BorderStroke(1.dp, Color.Red) else null,
.padding(8.dp)
.height(48.dp)
) {
Text(
text = "-",
fontSize = 24.sp
)
}
Spacer(Modifier.width(5.dp))
TextButton(
onClick = { add = true },
Row(
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = expanded
) {
BasicTextField(
value = label,
onValueChange = { label = it },
modifier = Modifier
.size(48.dp)
.aspectRatio(1f),
border = if (add) BorderStroke(1.dp, Color.Green) else null,
) {
Text(
text = "+",
fontSize = 24.sp
.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()
}
}
FlowRow {
increments.forEach {
TextButton(onClick = {
onClick(if (add) it else -it)
}) {
Text(formatCurrency(it))
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)
)
}
}
}

View file

@ -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<Int> = MutableStateFlow(0)
val totalBalance: StateFlow<Int> = _totalBalance.asStateFlow()
@HiltViewModel
class MainViewModel @Inject constructor(
private val repository: WalletRepository
) : ViewModel() {
val wallets: Flow<List<Wallet>> = repository.getAllWallets()
private val _wallets: MutableStateFlow<List<Wallet>> = MutableStateFlow(listOf())
val wallets: StateFlow<List<Wallet>> = _wallets.asStateFlow()
private val _openEvent = MutableSharedFlow<Long>()
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<Wallet>()
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<Int> = 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)
}
}
}

View file

@ -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()
}
}

View file

@ -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<Int> = MutableStateFlow(balance)
val balance: StateFlow<Int> = _balance.asStateFlow()
fun fund(money: Int) {
_balance.value += money
onUpdate(this, money)
}
}

View file

@ -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 = "",
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")
}
}
}
}
}

View file

@ -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<List<Wallet>> = walletDao.getAllWallets()
fun getWalletById(id: Long): Flow<Wallet> = 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)
}

View file

@ -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<Long>()
lateinit var wallet: Flow<Wallet>
lateinit var transactions: Flow<List<Transaction>>
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)
}
}
}

View file

@ -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
}

View file

@ -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<List<Transaction>>
@Insert
fun insertTransaction(transaction: Transaction)
@Insert
fun insertTransactions(transactions: List<Transaction>)
@Delete
fun deleteTransaction(transaction: Transaction)
@Insert
fun deleteTransactions(transactions: List<Transaction>)
}

View file

@ -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<List<Wallet>>
@Query("SELECT * FROM wallets WHERE id = (:id)")
fun getWalletById(id: Long): Flow<Wallet>
@Insert
suspend fun insertWallet(wallet: Wallet): Long
@Insert
suspend fun insertWallets(wallets: List<Wallet>): LongArray
@Update
fun updateWallet(wallet: Wallet)
@Update
fun updateWallets(wallets: List<Wallet>)
@Delete
fun deleteWallet(wallet: Wallet)
@Delete
fun deleteWallets(wallet: List<Wallet>)
}

View file

@ -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()
)

View file

@ -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
)

View file

@ -1,4 +1,5 @@
<resources>
<string name="app_name">Coin Counter</string>
<string name="title_activity_main">MainActivity</string>
<string name="title_activity_wallet">WalletActivity</string>
</resources>

View file

@ -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
}

View file

@ -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"}