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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

View file

@ -1,6 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <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"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" /> <option name="version" value="2.0.10" />
</component> </component>
</project> </project>

View file

@ -1,6 +1,11 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android) 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 { android {
@ -40,7 +45,7 @@ android {
compose = true compose = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.1" kotlinCompilerExtensionVersion = "1.5.15"
} }
packaging { packaging {
resources { resources {
@ -50,7 +55,11 @@ android {
} }
dependencies { dependencies {
implementation(libs.hilt.android)
kapt(libs.hilt.android.compiler)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -59,7 +68,8 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.datastore) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
@ -68,3 +78,8 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) 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"> xmlns:tools="http://schemas.android.com/tools">
<application <application
android:name=".MyApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
@ -12,7 +13,12 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.CoinCounter" 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View file

@ -1,11 +1,13 @@
package eu.m724.coincounter package eu.m724.coincounter
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedVisibility 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -31,33 +31,42 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.TextStyle 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.dp
import androidx.compose.ui.unit.sp 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 eu.m724.coincounter.ui.theme.CoinCounterTheme
import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var viewModel: MainViewModel private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel = MainViewModel(application) lifecycleScope.launch {
viewModel.init() viewModel.openEvent.collect {
openWallet(it)
}
}
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
@ -66,29 +75,43 @@ class MainActivity : ComponentActivity() {
Box( Box(
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
App(viewModel) App(
viewModel = viewModel,
onClick = {
openWallet(it.id)
}
)
} }
} }
} }
} }
} }
override fun onStop() { private fun openWallet(walletId: Long) {
super.onStop() val intent = Intent(application.applicationContext, WalletActivity::class.java)
viewModel.saveAll() intent.putExtra("walletId", walletId)
startActivity(intent)
} }
} }
@Composable @Composable
fun App(viewModel: MainViewModel) { fun App(
val total by viewModel.totalBalance.collectAsState() viewModel: MainViewModel,
val wallets by viewModel.wallets.collectAsState() onClick: (Wallet) -> Unit
) {
val total by viewModel.totalBalance.collectAsState(initial = 0)
val wallets by viewModel.wallets.collectAsState(initial = listOf())
Column { Column {
BalanceView(total) BalanceView(total)
WalletList( WalletList(
wallets = wallets, wallets = wallets,
onAdd = { viewModel.addWallet(it) } onClick = {
onClick(it)
},
onAdd = {
viewModel.addWallet(it)
}
) )
} }
} }
@ -117,170 +140,129 @@ fun BalanceView(balance: Int) {
} }
} }
@Composable @OptIn(ExperimentalLayoutApi::class)
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"
)
}
}
}
}
}
@Composable @Composable
fun WalletList( fun WalletList(
wallets: List<Wallet>, wallets: List<Wallet>,
onClick: (Wallet) -> Unit,
onAdd: (String) -> Unit onAdd: (String) -> Unit
) { ) {
LazyColumn( FlowRow(
modifier = Modifier.width(500.dp) modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
maxItemsInEachRow = 3
) { ) {
items(wallets) { wallet -> wallets.forEach { wallet ->
WalletCard(wallet) WalletCard(
} wallet = wallet,
item { onClick = {
AddWalletBox(onAdd) onClick(wallet)
}
)
} }
AddWalletButton(
onAdd = onAdd
)
} }
} }
@Composable @Composable
fun WalletCard(wallet: Wallet) { fun WalletCard(
var expanded by rememberSaveable { mutableStateOf(false) } wallet: Wallet,
val balance by wallet.balance.collectAsState() onClick: () -> Unit
) {
Button( Button(
onClick = { modifier = Modifier.padding(8.dp),
expanded = !expanded onClick = onClick,
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 2.dp),
colors = ButtonDefaults.buttonColors().copy( colors = ButtonDefaults.buttonColors().copy(
containerColor = CardDefaults.cardColors().containerColor, containerColor = CardDefaults.cardColors().containerColor,
contentColor = CardDefaults.cardColors().contentColor contentColor = CardDefaults.cardColors().contentColor
), ),
contentPadding = PaddingValues(8.dp, 2.dp) shape = RoundedCornerShape(30),
contentPadding = PaddingValues(16.dp, 4.dp)
) { ) {
Column { Column(
AnimatedVisibility(visible = !expanded) { horizontalAlignment = Alignment.CenterHorizontally
Row( ) {
modifier = Modifier Text(wallet.label)
.fillMaxWidth() Text(formatCurrency(wallet.balance))
.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)
}
)
}
}
} }
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun UnitButtons( fun AddWalletButton(
onClick: (Int) -> Unit onAdd: (String) -> Unit
) { ) {
val increments = listOf(1, 2, 5, 10, 20, 50, 100, 200, 500) var label by remember { mutableStateOf("") }
var add by rememberSaveable { mutableStateOf(true) } 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 { Card(
TextButton( modifier = Modifier
onClick = { add = false }, .padding(8.dp)
modifier = Modifier .height(48.dp)
.size(48.dp) ) {
.aspectRatio(1f), Row(
border = if (!add) BorderStroke(1.dp, Color.Red) else null, verticalAlignment = Alignment.CenterVertically
) { ) {
Text( AnimatedVisibility(
text = "-", visible = expanded
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,
) { ) {
Text( BasicTextField(
text = "+", value = label,
fontSize = 24.sp onValueChange = { label = it },
) modifier = Modifier
} .padding(start = 16.dp)
} .focusRequester(focusRequester),
FlowRow { textStyle = LocalTextStyle.current,
increments.forEach { keyboardOptions = KeyboardOptions(
TextButton(onClick = { imeAction = ImeAction.Done
onClick(if (add) it else -it) ),
}) { keyboardActions = KeyboardActions(
Text(formatCurrency(it)) 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)
)
} }
} }
} }

View file

@ -1,66 +1,34 @@
package eu.m724.coincounter package eu.m724.coincounter
import android.app.Application import androidx.lifecycle.ViewModel
import androidx.core.content.edit import androidx.lifecycle.viewModelScope
import androidx.lifecycle.AndroidViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import eu.m724.coincounter.data.entity.Wallet
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asStateFlow 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( @HiltViewModel
application: Application class MainViewModel @Inject constructor(
) : AndroidViewModel(application) { private val repository: WalletRepository
private val _totalBalance: MutableStateFlow<Int> = MutableStateFlow(0) ) : ViewModel() {
val totalBalance: StateFlow<Int> = _totalBalance.asStateFlow() val wallets: Flow<List<Wallet>> = repository.getAllWallets()
private val _wallets: MutableStateFlow<List<Wallet>> = MutableStateFlow(listOf()) private val _openEvent = MutableSharedFlow<Long>()
val wallets: StateFlow<List<Wallet>> = _wallets.asStateFlow() val openEvent = _openEvent.asSharedFlow()
private val dataPreferences = application.getSharedPreferences("data", Application.MODE_PRIVATE) val totalBalance: Flow<Int> = wallets.map { wallets ->
private val walletsPreferences = application.getSharedPreferences("wallets", Application.MODE_PRIVATE) wallets.sumOf { it.balance }
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)
}
}
} }
fun addWallet(name: String) { fun addWallet(name: String) {
_wallets.value += Wallet(++highestId, name, 0) { wa, mo -> onWalletUpdate(wa, mo) } val wallet = Wallet(label = name)
} viewModelScope.launch {
val id = repository.insertWallet(wallet)
private fun onWalletUpdate(wallet: Wallet, money: Int) { _openEvent.emit(id)
_totalBalance.value += money }
} }
} }

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> <resources>
<string name="app_name">Coin Counter</string> <string name="app_name">Coin Counter</string>
<string name="title_activity_main">MainActivity</string> <string name="title_activity_main">MainActivity</string>
<string name="title_activity_wallet">WalletActivity</string>
</resources> </resources>

View file

@ -2,4 +2,7 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) 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] [versions]
agp = "8.5.1" agp = "8.5.2"
datastore = "1.1.1" kotlin = "2.0.10"
kotlin = "1.9.0" coreKtx = "1.13.1"
coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.2.1"
espressoCore = "3.5.1" espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.8.4"
activityCompose = "1.8.0" activityCompose = "1.9.1"
composeBom = "2024.04.01" composeBom = "2024.06.00"
room = "2.6.1"
ksp = "2.0.10-1.0.24"
hilt = "2.44"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 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"}