WIP: Migrate to Kotlin Multiplatform
This commit is contained in:
parent
316949a6b5
commit
120d3dd70b
76 changed files with 1009 additions and 969 deletions
|
@ -7,7 +7,7 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
compileSdkVersion 33
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
|
@ -18,7 +18,7 @@ android {
|
|||
defaultConfig {
|
||||
applicationId "com.wbrawner.twigs"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 31
|
||||
targetSdkVersion 33
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
vectorDrawables {
|
||||
|
@ -39,36 +39,35 @@ android {
|
|||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion rootProject.extensions.getExtraProperties().get("compose")
|
||||
kotlinCompilerExtensionVersion "1.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
implementation project(':budgetlib')
|
||||
implementation project(':storage')
|
||||
implementation project(':shared')
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.media:media:1.4.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.media:media:1.6.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'com.google.android.material:material:1.7.0'
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
|
||||
// Dagger
|
||||
implementation "com.google.dagger:hilt-android:$dagger"
|
||||
kapt "com.google.dagger:hilt-compiler:$dagger"
|
||||
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0-alpha03'
|
||||
testImplementation 'junit:junit:4.13.1'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-alpha01'
|
||||
def navigation = '2.4.0-alpha07'
|
||||
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0'
|
||||
def navigation = '2.6.0-alpha04'
|
||||
implementation "androidx.navigation:navigation-fragment-ktx:$navigation"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$navigation"
|
||||
implementation "androidx.navigation:navigation-compose:$navigation"
|
||||
// implementation "androidx.compose.compiler:compiler:1.4.0-alpha02"
|
||||
implementation "androidx.compose.ui:ui:$compose"
|
||||
implementation "androidx.compose.ui:ui-tooling:$compose"
|
||||
implementation "androidx.compose.foundation:foundation:$compose"
|
||||
|
@ -76,7 +75,9 @@ dependencies {
|
|||
implementation "androidx.compose.material:material-icons-core:$compose"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose"
|
||||
implementation "androidx.compose.animation:animation:$compose"
|
||||
implementation 'androidx.activity:activity-compose:1.3.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07'
|
||||
implementation "androidx.compose.material3:material3:1.0.1"
|
||||
implementation "androidx.compose.material3:material3-window-size-class:1.0.1"
|
||||
implementation 'androidx.activity:activity-compose:1.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha03'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose"
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ package com.wbrawner.budget
|
|||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.wbrawner.budget.common.util.ErrorHandler
|
||||
import com.wbrawner.twigs.shared.ErrorHandler
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
@ -12,7 +12,8 @@ import dagger.hilt.components.SingletonComponent
|
|||
@InstallIn(SingletonComponent::class)
|
||||
class AppModule {
|
||||
@Provides
|
||||
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
|
||||
fun provideErrorHandler(): com.wbrawner.twigs.shared.ErrorHandler = object :
|
||||
com.wbrawner.twigs.shared.ErrorHandler {
|
||||
override fun init(application: Application) {
|
||||
// no-op
|
||||
}
|
||||
|
|
|
@ -8,17 +8,23 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -52,7 +58,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
|||
}
|
||||
setContentView(R.layout.activity_main)
|
||||
setSupportActionBar(action_bar)
|
||||
toggle = ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close)
|
||||
toggle =
|
||||
ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close)
|
||||
toggle.isDrawerIndicatorEnabled = true
|
||||
toggle.isDrawerSlideAnimationEnabled = true
|
||||
drawerLayout.addDrawerListener(toggle)
|
||||
|
@ -136,45 +143,59 @@ fun MainScreen() {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TwigsDrawer(navController: NavController, budgets: List<Budget>) {
|
||||
fun TwigsDrawer(navController: NavController, budgets: List<Budget>, selectedBudgetId: String) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val image = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color
|
||||
val image =
|
||||
if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color
|
||||
Image(painter = painterResource(id = image), null)
|
||||
Text(
|
||||
text = "twigs",
|
||||
style = MaterialTheme.typography.h3
|
||||
style = MaterialTheme.typography.h4
|
||||
)
|
||||
}
|
||||
val currentBudget = navController.currentBackStackEntry?.arguments?.getString("id")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerItem(@DrawableRes image: Int, text: String, selected: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = spacedBy(8.dp, Alignment.Start),
|
||||
) {
|
||||
val tint = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface
|
||||
Image(
|
||||
painter = painterResource(id = image),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(tint)
|
||||
NavigationDrawerItem(
|
||||
selected = false,
|
||||
onClick = { navController.navigate("overview") },
|
||||
icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
|
||||
label = { Text(text = "Overview") }
|
||||
)
|
||||
Text(text = text, color = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun DrawerItem_Preview() {
|
||||
TwigsApp {
|
||||
DrawerItem(R.drawable.ic_folder_open, "Budget", false)
|
||||
NavigationDrawerItem(
|
||||
selected = false,
|
||||
onClick = { navController.navigate("transactions") },
|
||||
icon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
|
||||
label = { Text(text = "Transactions") }
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
selected = false,
|
||||
onClick = { navController.navigate("categories") },
|
||||
icon = { Icon(Icons.Default.Category, contentDescription = null) },
|
||||
label = { Text(text = "Categories") }
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
selected = false,
|
||||
onClick = { navController.navigate("recurring") },
|
||||
icon = { Icon(Icons.Default.Repeat, contentDescription = null) },
|
||||
label = { Text(text = "Recurring Transactions") }
|
||||
)
|
||||
Divider()
|
||||
LazyColumn(modifier = Modifier.fillMaxHeight()) {
|
||||
items(budgets) { budget ->
|
||||
val selected = budget.id == selectedBudgetId
|
||||
val icon = if (selected) Icons.Filled.Folder else Icons.Outlined.Folder
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(icon, contentDescription = null) },
|
||||
label = { Text(budget.name) },
|
||||
selected = selected,
|
||||
onClick = { navController.navigate("budgets/${budget.id}") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -186,7 +207,7 @@ fun TwigsDrawer_Preview() {
|
|||
TwigsApp {
|
||||
Scaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
drawerContent = { TwigsDrawer(navController, emptyList()) }
|
||||
drawerContent = { TwigsDrawer(navController, emptyList(), "") }
|
||||
) {
|
||||
|
||||
}
|
||||
|
|
|
@ -67,6 +67,8 @@ class AuthViewModel @Inject constructor(
|
|||
val correctServer = with(server.value) {
|
||||
if (this.startsWith("http://") || this.startsWith("https://")) this
|
||||
else "https://$this"
|
||||
}.run{
|
||||
if (this.endsWith("/api")) this else "$this/api"
|
||||
}
|
||||
userRepository.login(correctServer.trim(), username.value.trim(), password.value.trim())
|
||||
.also {
|
||||
|
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
|
@ -18,6 +19,8 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
@ -30,8 +33,8 @@ import com.wbrawner.budget.ui.base.TwigsApp
|
|||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavController,
|
||||
viewModel: AuthViewModel
|
||||
navController: NavController,
|
||||
viewModel: AuthViewModel
|
||||
) {
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
val server by viewModel.server.collectAsState()
|
||||
|
@ -39,87 +42,88 @@ fun LoginScreen(
|
|||
val password by viewModel.password.collectAsState()
|
||||
val enableLogin by viewModel.enableLogin.collectAsState()
|
||||
AnimatedVisibility(
|
||||
visible = !loading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
visible = !loading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
LoginForm(
|
||||
server,
|
||||
viewModel::setServer,
|
||||
username,
|
||||
viewModel::setUsername,
|
||||
password,
|
||||
viewModel::setPassword,
|
||||
enableLogin,
|
||||
{ navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) },
|
||||
{ navController.navigate(AuthRoutes.REGISTER.name) },
|
||||
viewModel::login,
|
||||
server,
|
||||
viewModel::setServer,
|
||||
username,
|
||||
viewModel::setUsername,
|
||||
password,
|
||||
viewModel::setPassword,
|
||||
enableLogin,
|
||||
{ navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) },
|
||||
{ navController.navigate(AuthRoutes.REGISTER.name) },
|
||||
viewModel::login,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginForm(
|
||||
server: String,
|
||||
setServer: (String) -> Unit,
|
||||
username: String,
|
||||
setUsername: (String) -> Unit,
|
||||
password: String,
|
||||
setPassword: (String) -> Unit,
|
||||
enableLogin: Boolean,
|
||||
forgotPassword: () -> Unit,
|
||||
register: () -> Unit,
|
||||
login: () -> Unit,
|
||||
server: String,
|
||||
setServer: (String) -> Unit,
|
||||
username: String,
|
||||
setUsername: (String) -> Unit,
|
||||
password: String,
|
||||
setPassword: (String) -> Unit,
|
||||
enableLogin: Boolean,
|
||||
forgotPassword: () -> Unit,
|
||||
register: () -> Unit,
|
||||
login: () -> Unit,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color),
|
||||
contentDescription = null
|
||||
painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color),
|
||||
contentDescription = null
|
||||
)
|
||||
Text("Log in to manage your budgets")
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = server,
|
||||
onValueChange = setServer,
|
||||
placeholder = { Text("Server") }
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = server,
|
||||
onValueChange = setServer,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, capitalization = KeyboardCapitalization.None),
|
||||
placeholder = { Text("Server") }
|
||||
)
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = username,
|
||||
onValueChange = setUsername,
|
||||
placeholder = { Text("Username") }
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = username,
|
||||
onValueChange = setUsername,
|
||||
placeholder = { Text("Username") }
|
||||
)
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = password,
|
||||
onValueChange = setPassword,
|
||||
placeholder = { Text("Password") },
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = password,
|
||||
onValueChange = setPassword,
|
||||
placeholder = { Text("Password") },
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enableLogin,
|
||||
onClick = login
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enableLogin,
|
||||
onClick = login
|
||||
) {
|
||||
Text("Login")
|
||||
}
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = forgotPassword
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = forgotPassword
|
||||
) {
|
||||
Text("Forgot password?")
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = register
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = register
|
||||
) {
|
||||
Text("Need an account?")
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ abstract class ListWithAddButtonFragment<Data : Identifiable, ViewModel : AsyncV
|
|||
listContainer.visibility = View.GONE
|
||||
noItemsTextView.visibility = View.VISIBLE
|
||||
}
|
||||
else -> { /* no-op */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,21 @@
|
|||
package com.wbrawner.budget.ui.base
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.wbrawner.budget.R
|
||||
|
||||
val lightColors = lightColors(
|
||||
val lightColors = lightColorScheme(
|
||||
primary = Green500,
|
||||
primaryVariant = Green700,
|
||||
secondary = Green700,
|
||||
)
|
||||
|
||||
val darkColors = darkColors(
|
||||
val darkColors = darkColorScheme(
|
||||
primary = Green300,
|
||||
primaryVariant = Green500,
|
||||
secondary = Green500,
|
||||
background = Color.Black,
|
||||
surface = Color.Black,
|
||||
)
|
||||
|
@ -27,7 +23,7 @@ val darkColors = darkColors(
|
|||
@Composable
|
||||
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colors = if (darkMode) darkColors else lightColors,
|
||||
colorScheme = if (darkMode) darkColors else lightColors,
|
||||
// typography = MaterialTheme.typography.copy(
|
||||
// h1 = MaterialTheme.typography.h1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
||||
// h2 = MaterialTheme.typography.h2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
||||
|
|
|
@ -76,6 +76,7 @@ class AddEditBudgetFragment : Fragment() {
|
|||
is BudgetFormState.Exit -> {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
else -> { /* no-op */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -20,6 +20,8 @@ import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
|
|||
import com.wbrawner.budget.ui.transactions.toLong
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.android.synthetic.main.activity_add_edit_category.*
|
||||
import kotlinx.android.synthetic.main.activity_add_edit_category.progressBar
|
||||
import kotlinx.android.synthetic.main.fragment_add_edit_budget.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -49,6 +51,7 @@ class CategoryFormActivity : AppCompatActivity() {
|
|||
setTitle(state.data.titleRes)
|
||||
menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton
|
||||
edit_category_name.setText(category.title)
|
||||
edit_category_description.setText(category.description)
|
||||
edit_category_amount.setText(
|
||||
String.format(
|
||||
"%.02f",
|
||||
|
@ -113,6 +116,7 @@ class CategoryFormActivity : AppCompatActivity() {
|
|||
Category(
|
||||
id = id,
|
||||
title = edit_category_name.text.toString(),
|
||||
description = edit_category_description.text.toString(),
|
||||
amount = edit_category_amount.text.toLong(),
|
||||
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
|
||||
expense = expense.isChecked,
|
||||
|
|
|
@ -45,6 +45,7 @@ class OverviewFragment : Fragment() {
|
|||
noData.visibility = View.VISIBLE
|
||||
Log.e("OverviewFragment", "Failed to load overview", state.exception)
|
||||
}
|
||||
else -> { /* no-op */ }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.math.max
|
||||
|
@ -124,6 +125,9 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
|
|||
picker.addOnPositiveButtonClickListener {
|
||||
transactionDate.text =
|
||||
DateFormat.getDateFormat(this@TransactionFormActivity)
|
||||
.apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
.format(Date(it))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,17 @@
|
|||
android:inputType="textCapWords" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/container_edit_category_description"
|
||||
style="@style/AppTheme.EditText.Container"
|
||||
android:hint="@string/prompt_category_description">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/edit_category_description"
|
||||
style="@style/AppTheme.EditText"
|
||||
android:inputType="textCapSentences" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/container_edit_category_amount"
|
||||
style="@style/AppTheme.EditText.Container"
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
<string name="title_edit_category">Edit Category</string>
|
||||
<string name="prompt_category_amount">Amount</string>
|
||||
<string name="prompt_category_name">Name</string>
|
||||
<string name="prompt_category_description">Description</string>
|
||||
<string name="title_add_category">Add Category</string>
|
||||
<string name="uncategorized">Uncategorized</string>
|
||||
<string name="prompt_transaction_category">Category</string>
|
||||
|
|
1
budgetlib/.gitignore
vendored
1
budgetlib/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
/build
|
|
@ -1,46 +0,0 @@
|
|||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'dagger.hilt.android.plugin'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 31
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
implementation project(':storage')
|
||||
api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
|
||||
def ktor_version = "1.6.0"
|
||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-serialization:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-logging:$ktor_version"
|
||||
implementation "ch.qos.logback:logback-classic:1.2.5"
|
||||
// Dagger
|
||||
implementation "com.google.dagger:hilt-android:$rootProject.ext.dagger"
|
||||
kapt "com.google.dagger:hilt-compiler:$rootProject.ext.dagger"
|
||||
testImplementation 'junit:junit:4.13.1'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
}
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
21
budgetlib/proguard-rules.pro
vendored
21
budgetlib/proguard-rules.pro
vendored
|
@ -1,21 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class title to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file title.
|
||||
#-renamesourcefileattribute SourceFile
|
|
@ -1,27 +0,0 @@
|
|||
package com.wbrawner.budgetlib;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.InstrumentationRegistry;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||
|
||||
assertEquals("com.wbrawner.budgetlib.test", appContext.getPackageName());
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
package com.wbrawner.budget.lib.network
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.wbrawner.budget.common.PREF_KEY_TOKEN
|
||||
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||
import com.wbrawner.budget.common.category.CategoryRepository
|
||||
import com.wbrawner.budget.common.transaction.TransactionRepository
|
||||
import com.wbrawner.budget.common.user.UserRepository
|
||||
import com.wbrawner.budget.lib.repository.NetworkBudgetRepository
|
||||
import com.wbrawner.budget.lib.repository.NetworkCategoryRepository
|
||||
import com.wbrawner.budget.lib.repository.NetworkTransactionRepository
|
||||
import com.wbrawner.budget.lib.repository.NetworkUserRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
const val PREF_KEY_BASE_URL = "baseUrl"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class NetworkModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideApiService(sharedPreferences: SharedPreferences): TwigsApiService =
|
||||
KtorTwigsApiService(
|
||||
sharedPreferences.getString(PREF_KEY_BASE_URL, null),
|
||||
sharedPreferences.getString(PREF_KEY_TOKEN, null)
|
||||
)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideBudgetRepository(
|
||||
apiService: TwigsApiService,
|
||||
sharedPreferences: SharedPreferences
|
||||
): BudgetRepository =
|
||||
NetworkBudgetRepository(apiService, sharedPreferences)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideCategoryRepository(apiService: TwigsApiService): CategoryRepository =
|
||||
NetworkCategoryRepository(apiService)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideTransactionRepository(apiService: TwigsApiService): TransactionRepository =
|
||||
NetworkTransactionRepository(apiService)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideUserRepository(
|
||||
apiService: TwigsApiService,
|
||||
sharedPreferences: SharedPreferences
|
||||
): UserRepository =
|
||||
NetworkUserRepository(apiService, sharedPreferences)
|
||||
}
|
|
@ -1,109 +0,0 @@
|
|||
package com.wbrawner.budget.lib.repository
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.wbrawner.budget.common.budget.Budget
|
||||
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||
import com.wbrawner.budget.common.user.UserPermission
|
||||
import com.wbrawner.budget.lib.network.TwigsApiService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
const val KEY_DEFAULT_BUDGET = "defaultBudget"
|
||||
|
||||
class NetworkBudgetRepository(
|
||||
private val apiService: TwigsApiService,
|
||||
private val sharedPreferences: SharedPreferences
|
||||
) : BudgetRepository {
|
||||
private val mutex = Mutex()
|
||||
private val budgets: MutableSet<Budget> = mutableSetOf()
|
||||
private val current = MutableStateFlow<Budget?>(null)
|
||||
override val currentBudget: SharedFlow<Budget?> = current.asSharedFlow()
|
||||
|
||||
init {
|
||||
currentBudget.onEach { budget ->
|
||||
sharedPreferences.edit().apply {
|
||||
budget?.id?.let {
|
||||
putString(KEY_DEFAULT_BUDGET, it)
|
||||
} ?: remove(KEY_DEFAULT_BUDGET)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prefetchData() {
|
||||
val budgets = try {
|
||||
findAll()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
if (budgets.isEmpty()) return
|
||||
val budgetId = sharedPreferences.getString(KEY_DEFAULT_BUDGET, budgets.first().id)!!
|
||||
val budget = try {
|
||||
findById(budgetId)
|
||||
} catch (e: Exception) {
|
||||
// For some reason we can't find the default budget id, so fallback to the first budget
|
||||
budgets.first()
|
||||
}
|
||||
current.emit(budget)
|
||||
}
|
||||
|
||||
override suspend fun create(newItem: Budget): Budget =
|
||||
apiService.newBudget(NewBudgetRequest(newItem)).apply {
|
||||
mutex.withLock {
|
||||
budgets.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findAll(): List<Budget> = mutex.withLock {
|
||||
if (budgets.size > 0) budgets.toList() else null
|
||||
} ?: apiService.getBudgets().sortedBy { it.name }.apply {
|
||||
mutex.withLock {
|
||||
budgets.addAll(this)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findById(id: String, setCurrent: Boolean): Budget = (mutex.withLock {
|
||||
budgets.firstOrNull { it.id == id }
|
||||
} ?: apiService.getBudget(id).apply {
|
||||
mutex.withLock {
|
||||
budgets.add(this)
|
||||
}
|
||||
}).apply {
|
||||
if (setCurrent) {
|
||||
current.emit(this)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun update(updatedItem: Budget): Budget =
|
||||
apiService.updateBudget(updatedItem.id!!, updatedItem).apply {
|
||||
mutex.withLock {
|
||||
budgets.removeAll { it.id == this.id }
|
||||
budgets.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String) = apiService.deleteBudget(id).apply {
|
||||
mutex.withLock {
|
||||
budgets.removeAll { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBalance(id: String): Long =
|
||||
apiService.sumTransactions(budgetId = id).balance
|
||||
}
|
||||
|
||||
data class NewBudgetRequest(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission>
|
||||
) {
|
||||
constructor(budget: Budget) : this(
|
||||
budget.name,
|
||||
budget.description,
|
||||
budget.users
|
||||
)
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package com.wbrawner.budget.lib.repository
|
||||
|
||||
import com.wbrawner.budget.common.category.Category
|
||||
import com.wbrawner.budget.common.category.CategoryRepository
|
||||
import com.wbrawner.budget.lib.network.TwigsApiService
|
||||
import javax.inject.Inject
|
||||
|
||||
class NetworkCategoryRepository @Inject constructor(private val apiService: TwigsApiService) : CategoryRepository {
|
||||
override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem)
|
||||
|
||||
override suspend fun findAll(budgetIds: Array<String>?): List<Category> = apiService.getCategories(budgetIds).sortedBy { it.title }
|
||||
|
||||
override suspend fun findAll(): List<Category> = findAll(null)
|
||||
|
||||
override suspend fun findById(id: String): Category = apiService.getCategory(id)
|
||||
|
||||
override suspend fun update(updatedItem: Category): Category =
|
||||
apiService.updateCategory(updatedItem.id!!, updatedItem)
|
||||
|
||||
override suspend fun delete(id: String) = apiService.deleteCategory(id)
|
||||
|
||||
override suspend fun getBalance(id: String): Long =
|
||||
apiService.sumTransactions(categoryId = id).balance
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
package com.wbrawner.budget.lib.repository
|
||||
|
||||
import com.wbrawner.budget.common.transaction.Transaction
|
||||
import com.wbrawner.budget.common.transaction.TransactionRepository
|
||||
import com.wbrawner.budget.lib.network.TwigsApiService
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class NetworkTransactionRepository @Inject constructor(private val apiService: TwigsApiService) :
|
||||
TransactionRepository {
|
||||
|
||||
override suspend fun create(newItem: Transaction): Transaction =
|
||||
apiService.newTransaction(newItem)
|
||||
|
||||
override suspend fun findAll(
|
||||
budgetIds: List<String>?,
|
||||
categoryIds: List<String>?,
|
||||
start: Calendar?,
|
||||
end: Calendar?
|
||||
): List<Transaction> = apiService.getTransactions(
|
||||
budgetIds,
|
||||
categoryIds,
|
||||
start?.toInstant()?.toString(),
|
||||
end?.toInstant()?.toString()
|
||||
)
|
||||
|
||||
override suspend fun findAll(): List<Transaction> = findAll(null)
|
||||
|
||||
override suspend fun findById(id: String): Transaction = apiService.getTransaction(id)
|
||||
|
||||
override suspend fun update(updatedItem: Transaction): Transaction =
|
||||
apiService.updateTransaction(updatedItem.id!!, updatedItem)
|
||||
|
||||
override suspend fun delete(id: String) = apiService.deleteTransaction(id)
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
package com.wbrawner.budget.lib.repository
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.wbrawner.budget.common.PREF_KEY_USER_ID
|
||||
import com.wbrawner.budget.common.Session
|
||||
import com.wbrawner.budget.common.user.LoginRequest
|
||||
import com.wbrawner.budget.common.user.User
|
||||
import com.wbrawner.budget.common.user.UserRepository
|
||||
import com.wbrawner.budget.lib.network.TwigsApiService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
class NetworkUserRepository @Inject constructor(
|
||||
private val apiService: TwigsApiService,
|
||||
private val sharedPreferences: SharedPreferences
|
||||
) : UserRepository {
|
||||
|
||||
private val current = MutableStateFlow<User?>(null)
|
||||
override val currentUser: SharedFlow<User?> = current.asSharedFlow()
|
||||
|
||||
override suspend fun login(server: String, username: String, password: String): Session {
|
||||
apiService.baseUrl = server
|
||||
return apiService.login(LoginRequest(username, password)).apply {
|
||||
apiService.authToken = token
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getProfile(): User {
|
||||
val userId = sharedPreferences.getString(PREF_KEY_USER_ID, null)
|
||||
?: throw RuntimeException("Not authenticated")
|
||||
return apiService.getUser(userId).also {
|
||||
current.emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun create(newItem: User): User = apiService.newUser(newItem)
|
||||
|
||||
override suspend fun findAll(budgetId: String?): List<User> = apiService.getUsers(budgetId)
|
||||
|
||||
override suspend fun findAll(): List<User> = findAll(null)
|
||||
|
||||
override suspend fun findById(id: String): User = apiService.getUser(id)
|
||||
|
||||
override suspend fun findAllByNameLike(query: String): List<User> =
|
||||
apiService.searchUsers(query)
|
||||
|
||||
override suspend fun update(updatedItem: User): User =
|
||||
apiService.updateUser(updatedItem.id!!, updatedItem)
|
||||
|
||||
override suspend fun delete(id: String) = apiService.deleteUser(id)
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
<resources>
|
||||
<string name="app_name">Budget Lib</string>
|
||||
</resources>
|
|
@ -1,17 +0,0 @@
|
|||
package com.wbrawner.budgetlib;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
10
build.gradle
10
build.gradle
|
@ -1,10 +1,10 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.compose = '1.0.1'
|
||||
ext.dagger = '2.38.1'
|
||||
ext.compose = '1.3.1'
|
||||
ext.dagger = '2.44'
|
||||
ext.hyperion = '0.9.33'
|
||||
ext.kotlin_version = '1.5.21'
|
||||
ext.kotlin_version = '1.7.20'
|
||||
ext.lifecycle_version = "2.2.0"
|
||||
ext.moshi = '1.12.0'
|
||||
ext.retrofit = '2.6.0'
|
||||
|
@ -14,9 +14,7 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.0.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "com.google.dagger:hilt-android-gradle-plugin:$dagger"
|
||||
classpath libs.bundles.plugins
|
||||
}
|
||||
}
|
||||
|
||||
|
|
1
common/.gitignore
vendored
1
common/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
/build
|
|
@ -1,41 +0,0 @@
|
|||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.5.20'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 31
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1'
|
||||
api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2"
|
||||
testImplementation 'junit:junit:4.13.1'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
}
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
41
common/proguard-rules.pro
vendored
41
common/proguard-rules.pro
vendored
|
@ -1,41 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class title to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file title.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
|
||||
|
||||
# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Change here com.yourcompany.yourpackage
|
||||
-keep,includedescriptorclasses class com.yourcompany.yourpackage.**$$serializer { *; } # <-- change package name to your app's
|
||||
-keepclassmembers class com.yourcompany.yourpackage.** { # <-- change package name to your app's
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.yourcompany.yourpackage.** { # <-- change package name to your app's
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
<manifest package="com.wbrawner.budget.common" />
|
|
@ -1,16 +0,0 @@
|
|||
package com.wbrawner.budget.common
|
||||
|
||||
import com.wbrawner.budget.common.transaction.DateSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.*
|
||||
|
||||
const val PREF_KEY_USER_ID = "userId"
|
||||
const val PREF_KEY_TOKEN = "sessionToken"
|
||||
|
||||
@Serializable
|
||||
data class Session(
|
||||
val userId: String,
|
||||
val token: String,
|
||||
@Serializable(with = DateSerializer::class)
|
||||
val expiration: Date
|
||||
)
|
|
@ -1,17 +0,0 @@
|
|||
package com.wbrawner.budget.common.budget
|
||||
|
||||
import com.wbrawner.budget.common.Identifiable
|
||||
import com.wbrawner.budget.common.user.UserPermission
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Budget(
|
||||
override val id: String? = null,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission> = emptyList()
|
||||
) : Identifiable {
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package com.wbrawner.budget.common.budget
|
||||
|
||||
import com.wbrawner.budget.common.Repository
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
interface BudgetRepository : Repository<Budget, String> {
|
||||
val currentBudget: SharedFlow<Budget?>
|
||||
override suspend fun findById(id: String): Budget = findById(id, false)
|
||||
suspend fun findById(id: String, setCurrent: Boolean = false): Budget
|
||||
suspend fun prefetchData()
|
||||
suspend fun getBalance(id: String): Long
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package com.wbrawner.budget.common.category
|
||||
|
||||
import com.wbrawner.budget.common.Repository
|
||||
|
||||
interface CategoryRepository : Repository<Category, String> {
|
||||
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
|
||||
suspend fun getBalance(id: String): Long
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
package com.wbrawner.budget.common.transaction
|
||||
|
||||
import com.wbrawner.budget.common.Repository
|
||||
import java.util.*
|
||||
|
||||
interface TransactionRepository : Repository<Transaction, String> {
|
||||
suspend fun findAll(
|
||||
budgetIds: List<String>? = null,
|
||||
categoryIds: List<String>? = null,
|
||||
start: Calendar? = GregorianCalendar.getInstance().apply {
|
||||
set(Calendar.DAY_OF_MONTH, 1)
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
},
|
||||
end: Calendar? = GregorianCalendar.getInstance().apply {
|
||||
set(Calendar.DAY_OF_MONTH, getActualMaximum(Calendar.DAY_OF_MONTH))
|
||||
set(Calendar.HOUR_OF_DAY, getActualMaximum(Calendar.HOUR_OF_DAY))
|
||||
set(Calendar.MINUTE, getActualMaximum(Calendar.MINUTE))
|
||||
set(Calendar.SECOND, getActualMaximum(Calendar.SECOND))
|
||||
set(Calendar.MILLISECOND, getActualMaximum(Calendar.MILLISECOND))
|
||||
}
|
||||
): List<Transaction>
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
package com.wbrawner.budget.common.user
|
||||
|
||||
import com.wbrawner.budget.common.Repository
|
||||
import com.wbrawner.budget.common.Session
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
interface UserRepository : Repository<User, String> {
|
||||
val currentUser: SharedFlow<User?>
|
||||
suspend fun login(server: String, username: String, password: String): Session
|
||||
suspend fun getProfile(): User
|
||||
suspend fun findAll(budgetId: String? = null): List<User>
|
||||
suspend fun findAllByNameLike(query: String): List<User>
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package com.wbrawner.budget.common.util
|
||||
|
||||
import android.app.Application
|
||||
|
||||
interface ErrorHandler {
|
||||
fun init(application: Application)
|
||||
fun reportException(t: Throwable, message: String? = null)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package com.wbrawner.budget.common.util
|
||||
|
||||
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
fun randomId(): String {
|
||||
val id = StringBuilder()
|
||||
repeat(32) {
|
||||
id.append(CHARACTERS.random())
|
||||
}
|
||||
return id.toString()
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
package com.wbrawner.budget.common;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
|
@ -1,15 +1,16 @@
|
|||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
## For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
#
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
android.enableJetifier=true
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
# Default value: -Xmx1024m -XX:MaxPermSize=256m
|
||||
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
#
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
#Thu Jul 21 13:20:25 MDT 2022
|
||||
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
|
||||
android.enableJetifier=true
|
||||
android.useAndroidX=true
|
||||
|
|
67
gradle/libs.versions.toml
Normal file
67
gradle/libs.versions.toml
Normal file
|
@ -0,0 +1,67 @@
|
|||
[versions]
|
||||
androidx-core = "1.9.0"
|
||||
androidx-appcompat = "1.5.1"
|
||||
androidx-splash = "1.0.0"
|
||||
compose = "1.2.1"
|
||||
compose-compiler = "1.3.2"
|
||||
espresso = "3.3.0"
|
||||
hilt-android = "2.44"
|
||||
kotlin = "1.7.20"
|
||||
kotlinx-serialization = "1.4.1"
|
||||
kotlinx-coroutines = "1.6.4"
|
||||
kotlinx-datetime = "0.4.0"
|
||||
ktor = "2.1.2"
|
||||
material = "1.3.0"
|
||||
maxSdk = "33"
|
||||
minSdk = "23"
|
||||
navigation = "2.4.1"
|
||||
okhttp = "4.2.2"
|
||||
settings = "0.8.1"
|
||||
versionCode = "1"
|
||||
versionName = "1.0"
|
||||
|
||||
[libraries]
|
||||
android-gradle = { module = "com.android.tools.build:gradle", version = "7.3.1" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
|
||||
compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
|
||||
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
|
||||
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
|
||||
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
|
||||
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
|
||||
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
|
||||
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
||||
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
|
||||
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
|
||||
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
|
||||
junit = { module = "junit:junit", version = "4.12" }
|
||||
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" }
|
||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
||||
kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
|
||||
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
|
||||
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
|
||||
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
|
||||
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
|
||||
test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
|
||||
|
||||
[bundles]
|
||||
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
|
||||
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
|
||||
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
|
||||
|
||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
include ':android', ':budgetlib', ':common', ':storage'
|
3
settings.gradle.kts
Normal file
3
settings.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
enableFeaturePreview("VERSION_CATALOGS")
|
||||
rootProject.name = "Twigs"
|
||||
include(":android", ":shared")
|
1
shared/.gitignore
vendored
Normal file
1
shared/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
70
shared/build.gradle.kts
Normal file
70
shared/build.gradle.kts
Normal file
|
@ -0,0 +1,70 @@
|
|||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("com.android.library")
|
||||
kotlin("plugin.serialization")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android()
|
||||
listOf(iosArm64(), iosSimulatorArm64()).forEach {
|
||||
it.binaries.framework {
|
||||
baseName = "Twigs"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.serialization)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
api(libs.multiplatform.settings)
|
||||
}
|
||||
}
|
||||
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
|
||||
val androidMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.ktor.client.android)
|
||||
}
|
||||
}
|
||||
|
||||
val iosArm64Main by getting
|
||||
val iosSimulatorArm64Main by getting
|
||||
val iosMain by creating {
|
||||
dependsOn(commonMain)
|
||||
iosArm64Main.dependsOn(this)
|
||||
iosSimulatorArm64Main.dependsOn(this)
|
||||
dependencies {
|
||||
implementation(libs.ktor.client.ios)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
compileSdk = libs.versions.maxSdk.get().toInt()
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.maxSdk.get().toInt()
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
26
shared/proguard-rules.pro
vendored
Normal file
26
shared/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
-if @kotlinx.serialization.Serializable class **
|
||||
-keepclassmembers class <1> {
|
||||
static <1>$Companion Companion;
|
||||
}
|
||||
|
||||
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
static **$* *;
|
||||
}
|
||||
-keepclassmembers class <2>$<3> {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep `INSTANCE.serializer()` of serializable objects.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
-keepclassmembers class <1> {
|
||||
public static <1> INSTANCE;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.wbrawner.budgetlib">
|
||||
package="com.wbrawner.pihelper.shared">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
|
@ -0,0 +1,22 @@
|
|||
package com.wbrawner.pihelper.shared
|
||||
|
||||
import com.wbrawner.twigs.shared.Reducer
|
||||
import com.wbrawner.twigs.shared.Store
|
||||
import com.wbrawner.twigs.shared.budget.BudgetReducer
|
||||
import com.wbrawner.twigs.shared.budget.NetworkBudgetRepository
|
||||
import com.wbrawner.twigs.shared.network.KtorAPIService
|
||||
import com.wbrawner.twigs.shared.network.commonConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.android.*
|
||||
|
||||
val apiService = KtorAPIService(HttpClient(Android) {
|
||||
commonConfig()
|
||||
})
|
||||
|
||||
val budgetRepository = NetworkBudgetRepository(apiService)
|
||||
|
||||
fun Store.Companion.create(
|
||||
reducers: List<Reducer> = listOf(
|
||||
BudgetReducer(budgetRepository)
|
||||
),
|
||||
) = Store(reducers)
|
|
@ -0,0 +1,5 @@
|
|||
package com.wbrawner.twigs.shared
|
||||
|
||||
interface ErrorHandler {
|
||||
fun reportException(t: Throwable, message: String? = null)
|
||||
}
|
|
@ -1,17 +1,16 @@
|
|||
package com.wbrawner.budget.common
|
||||
package com.wbrawner.twigs.shared
|
||||
|
||||
/**
|
||||
* Base interface for an entity repository that provides basic CRUD methods
|
||||
*
|
||||
* @param T The type of the object supported by this repository
|
||||
* @param K The type of the primary identifier for the object supported by this repository
|
||||
*/
|
||||
interface Repository<T, K> {
|
||||
interface Repository<T> {
|
||||
suspend fun create(newItem: T): T
|
||||
suspend fun findAll(): List<T>
|
||||
suspend fun findById(id: K): T
|
||||
suspend fun findById(id: String): T
|
||||
suspend fun update(updatedItem: T): T
|
||||
suspend fun delete(id: K)
|
||||
suspend fun delete(id: String)
|
||||
}
|
||||
|
||||
interface Identifiable {
|
103
shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt
Normal file
103
shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt
Normal file
|
@ -0,0 +1,103 @@
|
|||
package com.wbrawner.twigs.shared
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.wbrawner.twigs.shared.budget.Budget
|
||||
import com.wbrawner.twigs.shared.category.Category
|
||||
import com.wbrawner.twigs.shared.network.APIService
|
||||
import com.wbrawner.twigs.shared.transaction.Transaction
|
||||
import com.wbrawner.twigs.shared.user.User
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class Route(val path: String) {
|
||||
WELCOME("welcome"),
|
||||
LOGIN("login"),
|
||||
REGISTER("register"),
|
||||
OVERVIEW("overview"),
|
||||
TRANSACTIONS("transactions"),
|
||||
CATEGORIES("categories"),
|
||||
RECURRING_TRANSACTIONS("recurringtransactions"),
|
||||
PROFILE("profile"),
|
||||
SETTINGS("settings"),
|
||||
ABOUT("about"),
|
||||
}
|
||||
|
||||
data class State(
|
||||
val host: String? = null,
|
||||
val user: User? = null,
|
||||
val budgets: List<Budget>? = null,
|
||||
val selectedBudget: String? = null,
|
||||
val editingBudget: Boolean = false,
|
||||
val categories: List<Category>? = null,
|
||||
val selectedCategory: String? = null,
|
||||
val editingCategory: Boolean = false,
|
||||
val transactions: List<Transaction>? = null,
|
||||
val selectedTransaction: String? = null,
|
||||
val editingTransaction: Boolean = false,
|
||||
val loading: Boolean = false,
|
||||
val route: Route = Route.WELCOME,
|
||||
val initialRoute: Route = Route.WELCOME
|
||||
)
|
||||
|
||||
interface Action {
|
||||
object About : Action
|
||||
object Back : Action
|
||||
}
|
||||
|
||||
interface AsyncAction : Action
|
||||
|
||||
interface Effect {
|
||||
object Exit : Effect
|
||||
object Empty: Effect
|
||||
}
|
||||
|
||||
abstract class Reducer {
|
||||
lateinit var dispatch: (Action) -> Unit
|
||||
lateinit var emit: (Effect) -> Unit
|
||||
|
||||
abstract fun reduce(action: Action, state: State): State
|
||||
}
|
||||
|
||||
abstract class AsyncReducer : Reducer() {
|
||||
abstract suspend fun reduce(action: AsyncAction, state: State): State
|
||||
}
|
||||
|
||||
const val KEY_HOST = "baseUrl"
|
||||
|
||||
class Store(
|
||||
private val reducers: List<Reducer>,
|
||||
private val settings: Settings = Settings(),
|
||||
initialState: State = State()
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.Main) {
|
||||
private val _state = MutableStateFlow(initialState)
|
||||
val state: StateFlow<State> = _state
|
||||
private val _effects = MutableSharedFlow<Effect>()
|
||||
val effects: Flow<Effect> = _effects
|
||||
|
||||
init {
|
||||
reducers.forEach {
|
||||
it.dispatch = this::dispatch
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: Action) {
|
||||
launch {
|
||||
var state = _state.value
|
||||
if (action is AsyncAction) {
|
||||
reducers.filterIsInstance<AsyncReducer>().forEach {
|
||||
state = it.reduce(action, state)
|
||||
}
|
||||
} else {
|
||||
reducers.forEach {
|
||||
state = it.reduce(action, state)
|
||||
}
|
||||
}
|
||||
_state.emit(state)
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package com.wbrawner.twigs.shared
|
||||
|
||||
import kotlinx.datetime.*
|
||||
|
||||
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
fun randomId(): String {
|
||||
val id = StringBuilder()
|
||||
repeat(32) {
|
||||
id.append(CHARACTERS.random())
|
||||
}
|
||||
return id.toString()
|
||||
}
|
||||
|
||||
fun startOfMonth(now: Instant = Clock.System.now()): Instant =
|
||||
with(now.toLocalDateTime(TimeZone.UTC)) {
|
||||
LocalDateTime(year, month, 1, 0, 0, 0)
|
||||
.toInstant(TimeZone.UTC)
|
||||
}
|
||||
|
||||
fun endOfMonth(now: Instant = Clock.System.now()): Instant =
|
||||
with(now.toLocalDateTime(TimeZone.UTC)) {
|
||||
val (adjustedYear, adjustedMonth) = if (monthNumber == 12) {
|
||||
year + 1 to 1
|
||||
} else {
|
||||
year to monthNumber + 1
|
||||
}
|
||||
LocalDateTime(adjustedYear, adjustedMonth, 1, 23, 59, 59)
|
||||
.toInstant(TimeZone.UTC)
|
||||
.minus(1, DateTimeUnit.DAY, TimeZone.UTC)
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package com.wbrawner.twigs.shared.budget
|
||||
|
||||
import com.wbrawner.twigs.shared.user.UserPermission
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Budget(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission> = emptyList()
|
||||
)
|
||||
|
||||
data class NewBudgetRequest(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission>
|
||||
) {
|
||||
constructor(budget: Budget) : this(
|
||||
budget.name,
|
||||
budget.description,
|
||||
budget.users
|
||||
)
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
package com.wbrawner.twigs.shared.budget
|
||||
|
||||
import com.wbrawner.twigs.shared.*
|
||||
import com.wbrawner.twigs.shared.user.UserAction
|
||||
import com.wbrawner.twigs.shared.user.UserPermission
|
||||
|
||||
sealed interface BudgetAction : Action {
|
||||
data class CreateBudget(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission> = emptyList()
|
||||
) : BudgetAction {
|
||||
fun async() = BudgetAsyncAction.CreateBudgetAsync(name, description, users)
|
||||
}
|
||||
|
||||
data class EditBudget(val id: String) : BudgetAction
|
||||
|
||||
data class SelectBudget(val id: String) : BudgetAction
|
||||
|
||||
data class UpdateBudget(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission> = emptyList()
|
||||
) : BudgetAction {
|
||||
fun async() = BudgetAsyncAction.UpdateBudgetAsync(id, name, description, users)
|
||||
}
|
||||
|
||||
data class DeleteBudget(val id: String) : BudgetAction {
|
||||
fun async() = BudgetAsyncAction.DeleteBudgetAsync(id)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface BudgetAsyncAction : AsyncAction {
|
||||
data class CreateBudgetAsync(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission> = emptyList()
|
||||
) : BudgetAsyncAction
|
||||
|
||||
data class UpdateBudgetAsync(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermission> = emptyList()
|
||||
) : BudgetAsyncAction
|
||||
|
||||
data class DeleteBudgetAsync(val id: String) : BudgetAsyncAction
|
||||
}
|
||||
|
||||
class BudgetReducer(private val budgetRepository: BudgetRepository) : AsyncReducer() {
|
||||
override fun reduce(action: Action, state: State): State = when (action) {
|
||||
is Action.Back -> state.copy(
|
||||
editingBudget = false,
|
||||
selectedBudget = if (state.editingBudget) state.selectedBudget else null
|
||||
)
|
||||
is BudgetAction.CreateBudget -> state.copy(loading = true).also {
|
||||
dispatch(action.async())
|
||||
}
|
||||
is BudgetAction.EditBudget -> state.copy(
|
||||
editingBudget = true,
|
||||
selectedBudget = action.id
|
||||
)
|
||||
is BudgetAction.SelectBudget -> state.copy(
|
||||
selectedBudget = action.id
|
||||
)
|
||||
is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
|
||||
dispatch(action.async())
|
||||
}
|
||||
is BudgetAction.DeleteBudget -> state.copy(loading = true).also {
|
||||
dispatch(action.async())
|
||||
}
|
||||
is UserAction.Logout -> state.copy(
|
||||
editingBudget = false,
|
||||
selectedBudget = null,
|
||||
budgets = null
|
||||
)
|
||||
else -> state
|
||||
}
|
||||
|
||||
override suspend fun reduce(action: AsyncAction, state: State): State = when (action) {
|
||||
is BudgetAsyncAction.CreateBudgetAsync -> {
|
||||
val budget = budgetRepository.create(
|
||||
Budget(name = action.name, description = action.description, users = action.users)
|
||||
)
|
||||
val budgets = state.budgets?.toMutableList() ?: mutableListOf()
|
||||
budgets.add(budget)
|
||||
budgets.sortBy { it.name }
|
||||
state.copy(
|
||||
loading = false,
|
||||
budgets = budgets.toList(),
|
||||
selectedBudget = budget.id
|
||||
)
|
||||
}
|
||||
is BudgetAsyncAction.UpdateBudgetAsync -> {
|
||||
budgetRepository.update(
|
||||
Budget(
|
||||
id = action.id,
|
||||
name = action.name,
|
||||
description = action.description,
|
||||
users = action.users
|
||||
)
|
||||
)
|
||||
state.copy(
|
||||
loading = false,
|
||||
editingBudget = false,
|
||||
)
|
||||
}
|
||||
is BudgetAsyncAction.DeleteBudgetAsync -> {
|
||||
budgetRepository.delete(action.id)
|
||||
val budgets = state.budgets?.filterNot { it.id == action.id }
|
||||
state.copy(
|
||||
loading = false,
|
||||
budgets = budgets,
|
||||
editingBudget = false,
|
||||
selectedBudget = null
|
||||
)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package com.wbrawner.twigs.shared.budget
|
||||
|
||||
import com.wbrawner.twigs.shared.Repository
|
||||
import com.wbrawner.twigs.shared.network.APIService
|
||||
|
||||
interface BudgetRepository : Repository<Budget> {
|
||||
suspend fun getBalance(id: String): Long
|
||||
}
|
||||
|
||||
class NetworkBudgetRepository(private val apiService: APIService) : BudgetRepository {
|
||||
override suspend fun create(newItem: Budget): Budget = apiService.newBudget(NewBudgetRequest(newItem))
|
||||
|
||||
override suspend fun findAll(): List<Budget> = apiService.getBudgets()
|
||||
|
||||
override suspend fun findById(id: String): Budget = apiService.getBudget(id)
|
||||
|
||||
override suspend fun update(updatedItem: Budget): Budget =
|
||||
apiService.updateBudget(updatedItem.id!!, updatedItem)
|
||||
|
||||
override suspend fun delete(id: String) = apiService.deleteBudget(id)
|
||||
|
||||
override suspend fun getBalance(id: String): Long =
|
||||
apiService.sumTransactions(budgetId = id).balance
|
||||
}
|
|
@ -1,19 +1,14 @@
|
|||
package com.wbrawner.budget.common.category
|
||||
package com.wbrawner.twigs.shared.category
|
||||
|
||||
import com.wbrawner.budget.common.Identifiable
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Category(
|
||||
val budgetId: String,
|
||||
override val id: String? = null,
|
||||
val id: String? = null,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val amount: Long,
|
||||
val expense: Boolean = true,
|
||||
val archived: Boolean = false
|
||||
): Identifiable {
|
||||
override fun toString(): String {
|
||||
return title
|
||||
}
|
||||
}
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
package com.wbrawner.twigs.shared.category
|
||||
|
||||
import com.wbrawner.twigs.shared.Repository
|
||||
import com.wbrawner.twigs.shared.category.Category
|
||||
|
||||
interface CategoryRepository : Repository<Category> {
|
||||
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
|
||||
suspend fun getBalance(id: String): Long
|
||||
}
|
|
@ -1,15 +1,23 @@
|
|||
package com.wbrawner.budget.lib.network
|
||||
package com.wbrawner.twigs.shared.network
|
||||
|
||||
import com.wbrawner.budget.common.Session
|
||||
import com.wbrawner.budget.common.budget.Budget
|
||||
import com.wbrawner.budget.common.category.Category
|
||||
import com.wbrawner.budget.common.transaction.BalanceResponse
|
||||
import com.wbrawner.budget.common.transaction.Transaction
|
||||
import com.wbrawner.budget.common.user.LoginRequest
|
||||
import com.wbrawner.budget.common.user.User
|
||||
import com.wbrawner.budget.lib.repository.NewBudgetRequest
|
||||
import com.wbrawner.twigs.shared.budget.Budget
|
||||
import com.wbrawner.twigs.shared.budget.NewBudgetRequest
|
||||
import com.wbrawner.twigs.shared.category.Category
|
||||
import com.wbrawner.twigs.shared.transaction.BalanceResponse
|
||||
import com.wbrawner.twigs.shared.transaction.Transaction
|
||||
import com.wbrawner.twigs.shared.user.LoginRequest
|
||||
import com.wbrawner.twigs.shared.user.Session
|
||||
import com.wbrawner.twigs.shared.user.User
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
interface TwigsApiService {
|
||||
const val BASE_PATH = "/api"
|
||||
|
||||
interface APIService {
|
||||
var baseUrl: String?
|
||||
var authToken: String?
|
||||
|
||||
|
@ -96,4 +104,20 @@ interface TwigsApiService {
|
|||
): User
|
||||
|
||||
suspend fun deleteUser(id: String)
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 1000
|
||||
connectTimeoutMillis = 1000
|
||||
socketTimeoutMillis = 1000
|
||||
}
|
||||
}
|
|
@ -1,38 +1,23 @@
|
|||
package com.wbrawner.budget.lib.network
|
||||
package com.wbrawner.twigs.shared.network
|
||||
|
||||
import com.wbrawner.budget.common.Session
|
||||
import com.wbrawner.budget.common.budget.Budget
|
||||
import com.wbrawner.budget.common.category.Category
|
||||
import com.wbrawner.budget.common.transaction.BalanceResponse
|
||||
import com.wbrawner.budget.common.transaction.Transaction
|
||||
import com.wbrawner.budget.common.user.LoginRequest
|
||||
import com.wbrawner.budget.common.user.User
|
||||
import com.wbrawner.budget.lib.repository.NewBudgetRequest
|
||||
import com.wbrawner.budgetlib.BuildConfig
|
||||
import com.wbrawner.twigs.shared.budget.Budget
|
||||
import com.wbrawner.twigs.shared.budget.NewBudgetRequest
|
||||
import com.wbrawner.twigs.shared.category.Category
|
||||
import com.wbrawner.twigs.shared.transaction.BalanceResponse
|
||||
import com.wbrawner.twigs.shared.transaction.Transaction
|
||||
import com.wbrawner.twigs.shared.user.LoginRequest
|
||||
import com.wbrawner.twigs.shared.user.Session
|
||||
import com.wbrawner.twigs.shared.user.User
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.client.features.logging.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class KtorTwigsApiService(
|
||||
override var baseUrl: String?,
|
||||
override var authToken: String?
|
||||
) : TwigsApiService {
|
||||
private val client = HttpClient(CIO) {
|
||||
install(JsonFeature) {
|
||||
serializer = KotlinxSerializer()
|
||||
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
install(Logging) {
|
||||
logger = Logger.ANDROID
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
}
|
||||
}
|
||||
class KtorAPIService(
|
||||
private val client: HttpClient
|
||||
) : APIService {
|
||||
override var baseUrl: String? = null
|
||||
override var authToken: String? = null
|
||||
|
||||
override suspend fun getBudgets(count: Int?, page: Int?): List<Budget> = request("budgets")
|
||||
|
||||
|
@ -40,13 +25,13 @@ class KtorTwigsApiService(
|
|||
|
||||
override suspend fun newBudget(budget: NewBudgetRequest): Budget = request(
|
||||
path = "budgets",
|
||||
httpBody = budget,
|
||||
body = budget,
|
||||
httpMethod = HttpMethod.Post
|
||||
)
|
||||
|
||||
override suspend fun updateBudget(id: String, budget: Budget): Budget = request(
|
||||
path = "budgets/$id",
|
||||
httpBody = budget,
|
||||
body = budget,
|
||||
httpMethod = HttpMethod.Put
|
||||
)
|
||||
|
||||
|
@ -74,13 +59,13 @@ class KtorTwigsApiService(
|
|||
|
||||
override suspend fun newCategory(category: Category): Category = request(
|
||||
path = "categories",
|
||||
httpBody = category,
|
||||
body = category,
|
||||
httpMethod = HttpMethod.Post
|
||||
)
|
||||
|
||||
override suspend fun updateCategory(id: String, category: Category): Category = request(
|
||||
path = "categories/$id",
|
||||
httpBody = category,
|
||||
body = category,
|
||||
httpMethod = HttpMethod.Put
|
||||
)
|
||||
|
||||
|
@ -121,14 +106,14 @@ class KtorTwigsApiService(
|
|||
|
||||
override suspend fun newTransaction(transaction: Transaction): Transaction = request(
|
||||
path = "transactions",
|
||||
httpBody = transaction,
|
||||
body = transaction,
|
||||
httpMethod = HttpMethod.Post
|
||||
)
|
||||
|
||||
override suspend fun updateTransaction(id: String, transaction: Transaction): Transaction =
|
||||
request(
|
||||
path = "transactions/$id",
|
||||
httpBody = transaction,
|
||||
body = transaction,
|
||||
httpMethod = HttpMethod.Put
|
||||
)
|
||||
|
||||
|
@ -148,7 +133,7 @@ class KtorTwigsApiService(
|
|||
|
||||
override suspend fun login(request: LoginRequest): Session = request(
|
||||
path = "users/login",
|
||||
httpBody = request,
|
||||
body = request,
|
||||
httpMethod = HttpMethod.Post
|
||||
)
|
||||
|
||||
|
@ -165,13 +150,13 @@ class KtorTwigsApiService(
|
|||
|
||||
override suspend fun newUser(user: User): User = request(
|
||||
path = "users",
|
||||
httpBody = user,
|
||||
body = user,
|
||||
httpMethod = HttpMethod.Post,
|
||||
)
|
||||
|
||||
override suspend fun updateUser(id: String, user: User): User = request(
|
||||
path = "users/$id",
|
||||
httpBody = user,
|
||||
body = user,
|
||||
httpMethod = HttpMethod.Put,
|
||||
)
|
||||
|
||||
|
@ -183,9 +168,9 @@ class KtorTwigsApiService(
|
|||
private suspend inline fun <reified T> request(
|
||||
path: String,
|
||||
queryParams: List<Pair<String, Any?>>? = null,
|
||||
httpBody: Any? = null,
|
||||
body: Any? = null,
|
||||
httpMethod: HttpMethod = HttpMethod.Get
|
||||
): T = client.request(URLBuilder(baseUrl!!).path("api/$path").build()) {
|
||||
): T = client.request(URLBuilder(baseUrl!!).apply { path("api/$path") }.buildString()) {
|
||||
method = httpMethod
|
||||
headers {
|
||||
authToken?.let { append(HttpHeaders.Authorization, "Bearer $it") }
|
||||
|
@ -199,9 +184,9 @@ class KtorTwigsApiService(
|
|||
}
|
||||
}
|
||||
}
|
||||
httpBody?.let {
|
||||
body?.let {
|
||||
contentType(ContentType.Application.Json)
|
||||
body = it
|
||||
setBody(it)
|
||||
}
|
||||
}
|
||||
}.body()
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package com.wbrawner.budget.common.transaction
|
||||
package com.wbrawner.twigs.shared.transaction
|
||||
|
||||
import com.wbrawner.budget.common.Identifiable
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
|
@ -8,28 +8,26 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class Transaction(
|
||||
override val id: String? = null,
|
||||
val id: String? = null,
|
||||
val title: String,
|
||||
@Serializable(with = DateSerializer::class)
|
||||
val date: Date,
|
||||
val date: Instant,
|
||||
val description: String? = null,
|
||||
val amount: Long,
|
||||
val categoryId: String? = null,
|
||||
val budgetId: String,
|
||||
val expense: Boolean,
|
||||
val createdBy: String
|
||||
): Identifiable
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BalanceResponse(val balance: Long)
|
||||
|
||||
object DateSerializer : KSerializer<Date> {
|
||||
object DateSerializer : KSerializer<Instant> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)
|
||||
override fun serialize(encoder: Encoder, value: Date) = encoder.encodeString(value.toInstant().toString())
|
||||
override fun deserialize(decoder: Decoder): Date = Date.from(Instant.parse(decoder.decodeString()))
|
||||
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
|
||||
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package com.wbrawner.twigs.shared.transaction
|
||||
|
||||
import com.wbrawner.twigs.shared.Repository
|
||||
import com.wbrawner.twigs.shared.endOfMonth
|
||||
import com.wbrawner.twigs.shared.startOfMonth
|
||||
import kotlinx.datetime.*
|
||||
|
||||
interface TransactionRepository : Repository<Transaction> {
|
||||
suspend fun findAll(
|
||||
budgetIds: List<String>? = null,
|
||||
categoryIds: List<String>? = null,
|
||||
start: Instant? = startOfMonth(),
|
||||
end: Instant? = endOfMonth()
|
||||
): List<Transaction>
|
||||
}
|
|
@ -1,17 +1,15 @@
|
|||
package com.wbrawner.budget.common.user
|
||||
package com.wbrawner.twigs.shared.user
|
||||
|
||||
import com.wbrawner.budget.common.Identifiable
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
override val id: String? = null,
|
||||
val id: String? = null,
|
||||
val username: String,
|
||||
val email: String? = null,
|
||||
val avatar: String? = null
|
||||
) : Identifiable {
|
||||
override fun toString(): String = username
|
||||
}
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserPermission(
|
||||
|
@ -31,3 +29,10 @@ data class LoginRequest(
|
|||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Session(
|
||||
val userId: String,
|
||||
val token: String,
|
||||
val expiration: Instant
|
||||
)
|
|
@ -0,0 +1,13 @@
|
|||
package com.wbrawner.twigs.shared.user
|
||||
|
||||
import com.wbrawner.twigs.shared.Action
|
||||
|
||||
sealed interface UserAction: Action {
|
||||
data class Login(val username: String, val password: String): UserAction
|
||||
data class Register(val username: String, val password: String, val confirmPassword: String): UserAction
|
||||
object Logout: UserAction
|
||||
}
|
||||
|
||||
sealed interface UserEffect {
|
||||
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.wbrawner.twigs.shared.user
|
||||
|
||||
import com.wbrawner.twigs.shared.Repository
|
||||
|
||||
interface UserRepository : Repository<User> {
|
||||
suspend fun login(username: String, password: String): User
|
||||
suspend fun getProfile(): User
|
||||
suspend fun findAll(budgetId: String? = null): List<User>
|
||||
suspend fun findAllByNameLike(query: String): List<User>
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package com.wbrawner.twigs.shared
|
||||
|
||||
import com.wbrawner.twigs.shared.budget.BudgetAction
|
||||
import com.wbrawner.twigs.shared.budget.BudgetReducer
|
||||
import com.wbrawner.twigs.shared.budget.BudgetRepository
|
||||
import com.wbrawner.twigs.shared.user.Permission
|
||||
import com.wbrawner.twigs.shared.user.UserPermission
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.DefaultAsserter.assertEquals
|
||||
import kotlin.test.DefaultAsserter.assertTrue
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class BudgetReducerTests {
|
||||
lateinit var reducer: BudgetReducer
|
||||
lateinit var dispatchedActions: MutableList<Action>
|
||||
lateinit var budgetRepository: FakeBudgetRepository
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
dispatchedActions = mutableListOf()
|
||||
budgetRepository = FakeBudgetRepository()
|
||||
reducer = BudgetReducer(budgetRepository)
|
||||
reducer.dispatch = { dispatchedActions.add(it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goBackWhileEditingTest() {
|
||||
val state = State(editingBudget = true, selectedBudget = "test")
|
||||
val newState = reducer.reduce(Action.Back, state)
|
||||
assertFalse(newState.editingBudget)
|
||||
assertEquals("selectedBudget should still be set", "test", newState.selectedBudget)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goBackWhileViewingTest() {
|
||||
val state = State(selectedBudget = "test")
|
||||
val newState = reducer.reduce(Action.Back, state)
|
||||
assertNull(newState.selectedBudget)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createBudgetTest() {
|
||||
val state = State()
|
||||
val users = listOf(UserPermission("user", Permission.OWNER))
|
||||
assertFalse(state.loading)
|
||||
val newState = reducer.reduce(BudgetAction.CreateBudget("test", "description", users), state)
|
||||
assertTrue(state.loading)
|
||||
|
||||
assertNull(newState.selectedBudget)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package com.wbrawner.twigs.shared
|
||||
|
||||
import com.wbrawner.twigs.shared.budget.Budget
|
||||
import com.wbrawner.twigs.shared.budget.BudgetRepository
|
||||
|
||||
class FakeBudgetRepository: BudgetRepository {
|
||||
val budgets = mutableListOf<Budget>()
|
||||
|
||||
override suspend fun getBalance(id: String): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
override suspend fun create(newItem: Budget): Budget {
|
||||
val saved = newItem.copy(id = randomId())
|
||||
budgets.add(saved)
|
||||
return saved
|
||||
}
|
||||
|
||||
override suspend fun findAll(): List<Budget> = budgets
|
||||
|
||||
override suspend fun findById(id: String): Budget = budgets.first { it.id == id }
|
||||
|
||||
override suspend fun update(updatedItem: Budget): Budget {
|
||||
budgets.removeAll { it.id == updatedItem.id }
|
||||
budgets.add(updatedItem)
|
||||
budgets.sortBy { it.name }
|
||||
return updatedItem
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String) {
|
||||
budgets.removeAll { it.id == id }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package com.wbrawner.twigs.shared
|
||||
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.test.DefaultAsserter.assertEquals
|
||||
import kotlin.test.Test
|
||||
|
||||
class UtilsTests {
|
||||
@Test
|
||||
fun `startOfMonth returns the correct dates`() {
|
||||
listOf(
|
||||
"2022-01-01T00:00:00Z" to "2022-01-01T00:00:00Z",
|
||||
"2022-02-01T00:00:00Z" to "2022-02-10T12:30:45Z",
|
||||
"2022-03-01T00:00:00Z" to "2022-03-15T12:30:45Z",
|
||||
"2022-04-01T00:00:00Z" to "2022-04-20T12:30:45Z",
|
||||
"2022-05-01T00:00:00Z" to "2022-05-20T12:30:45Z",
|
||||
"2022-06-01T00:00:00Z" to "2022-06-20T12:30:45Z",
|
||||
"2022-07-01T00:00:00Z" to "2022-07-20T12:30:45Z",
|
||||
"2022-08-01T00:00:00Z" to "2022-08-20T12:30:45Z",
|
||||
"2022-09-01T00:00:00Z" to "2022-09-20T12:30:45Z",
|
||||
"2022-10-01T00:00:00Z" to "2022-10-20T12:30:45Z",
|
||||
"2022-11-01T00:00:00Z" to "2022-11-20T12:30:45Z",
|
||||
"2022-12-01T00:00:00Z" to "2022-12-31T23:59:59Z"
|
||||
).forEach { (expected, now) ->
|
||||
assertEquals(
|
||||
"Failed to set $now to start of month",
|
||||
expected,
|
||||
startOfMonth(now.toInstant()).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `endOfMonth returns the correct dates`() {
|
||||
listOf(
|
||||
"2022-01-31T23:59:59Z" to "2022-01-01T00:00:00Z",
|
||||
"2022-02-28T23:59:59Z" to "2022-02-10T12:30:45Z",
|
||||
"2022-03-31T23:59:59Z" to "2022-03-15T12:30:45Z",
|
||||
"2022-04-30T23:59:59Z" to "2022-04-20T12:30:45Z",
|
||||
"2022-05-31T23:59:59Z" to "2022-05-20T12:30:45Z",
|
||||
"2022-06-30T23:59:59Z" to "2022-06-20T12:30:45Z",
|
||||
"2022-07-31T23:59:59Z" to "2022-07-20T12:30:45Z",
|
||||
"2022-08-31T23:59:59Z" to "2022-08-20T12:30:45Z",
|
||||
"2022-09-30T23:59:59Z" to "2022-09-20T12:30:45Z",
|
||||
"2022-10-31T23:59:59Z" to "2022-10-20T12:30:45Z",
|
||||
"2022-11-30T23:59:59Z" to "2022-11-20T12:30:45Z",
|
||||
"2022-12-31T23:59:59Z" to "2022-12-31T23:59:59Z"
|
||||
).forEach { (expected, now) ->
|
||||
assertEquals(
|
||||
"Failed to set $now to end of month",
|
||||
expected,
|
||||
endOfMonth(now.toInstant()).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package com.wbrawner.pihelper.shared
|
||||
|
||||
import com.wbrawner.twigs.shared.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.darwin.*
|
||||
import io.ktor.utils.io.charsets.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import platform.CoreCrypto.CC_SHA256
|
||||
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
|
||||
|
||||
//fun APIService.Companion.create() = KtorAPIService(HttpClient(Darwin) {
|
||||
// commonConfig()
|
||||
//})
|
||||
|
||||
//fun Store.Companion.create() = Store(PiholeAPIService.create())
|
||||
|
||||
fun Store.watchState(block: (State) -> Unit) = state.watch(block)
|
||||
fun Store.watchEffects(block: (Effect) -> Unit) = effects.watch(block)
|
||||
|
||||
fun interface Closeable {
|
||||
fun close()
|
||||
}
|
||||
|
||||
class CloseableFlow<T : Any> internal constructor(private val origin: Flow<T>) : Flow<T> by origin {
|
||||
fun watch(block: (T) -> Unit): Closeable {
|
||||
val job = Job()
|
||||
|
||||
onEach {
|
||||
block(it)
|
||||
}.launchIn(CoroutineScope(Dispatchers.Main + job))
|
||||
|
||||
return Closeable { job.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <T : Any> Flow<T>.watch(block: (T) -> Unit): Closeable =
|
||||
CloseableFlow(this).watch(block)
|
1
storage/.gitignore
vendored
1
storage/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
/build
|
|
@ -1,43 +0,0 @@
|
|||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'dagger.hilt.android.plugin'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 31
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
// Security
|
||||
def security_version = "1.0.0-beta01"
|
||||
implementation "androidx.security:security-crypto:$security_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
// Dagger
|
||||
implementation "com.google.dagger:hilt-android:$rootProject.ext.dagger"
|
||||
kapt "com.google.dagger:hilt-compiler:$rootProject.ext.dagger"
|
||||
testImplementation 'junit:junit:4.13.1'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
}
|
21
storage/proguard-rules.pro
vendored
21
storage/proguard-rules.pro
vendored
|
@ -1,21 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
|
@ -1,27 +0,0 @@
|
|||
package com.wbrawner.auth;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.InstrumentationRegistry;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||
|
||||
assertEquals("com.wbrawner.auth.test", appContext.getPackageName());
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
<manifest package="com.wbrawner.auth" />
|
|
@ -1,28 +0,0 @@
|
|||
package com.wbrawner.budget.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
const val ENCRYPTED_SHARED_PREFS_FILE_NAME = "shared-prefs.encrypted"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class StorageModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
|
||||
EncryptedSharedPreferences.create(
|
||||
ENCRYPTED_SHARED_PREFS_FILE_NAME,
|
||||
"budget",
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
<resources>
|
||||
<string name="app_name">Auth</string>
|
||||
</resources>
|
|
@ -1,17 +0,0 @@
|
|||
package com.wbrawner.auth;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue