WIP: Migrate to Kotlin Multiplatform

This commit is contained in:
William Brawner 2022-12-20 10:41:21 -07:00
parent 316949a6b5
commit 120d3dd70b
76 changed files with 1009 additions and 969 deletions

View file

@ -7,7 +7,7 @@ plugins {
} }
android { android {
compileSdkVersion 31 compileSdkVersion 33
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
@ -18,7 +18,7 @@ android {
defaultConfig { defaultConfig {
applicationId "com.wbrawner.twigs" applicationId "com.wbrawner.twigs"
minSdkVersion 26 minSdkVersion 26
targetSdkVersion 31 targetSdkVersion 33
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
vectorDrawables { vectorDrawables {
@ -39,36 +39,35 @@ android {
compose true compose true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion rootProject.extensions.getExtraProperties().get("compose") kotlinCompilerExtensionVersion "1.3.2"
} }
} }
dependencies { dependencies {
implementation project(':common') implementation project(':shared')
implementation project(':budgetlib')
implementation project(':storage')
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.media:media:1.4.1' implementation 'androidx.media:media:1.6.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.android.material:material:1.4.0' implementation 'com.google.android.material:material:1.7.0'
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
// Dagger // Dagger
implementation "com.google.dagger:hilt-android:$dagger" implementation "com.google.dagger:hilt-android:$dagger"
kapt "com.google.dagger:hilt-compiler:$dagger" kapt "com.google.dagger:hilt-compiler:$dagger"
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0-alpha03' implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
testImplementation 'junit:junit:4.13.1' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-splashscreen:1.0.0-alpha01' implementation 'androidx.core:core-splashscreen:1.0.0'
def navigation = '2.4.0-alpha07' def navigation = '2.6.0-alpha04'
implementation "androidx.navigation:navigation-fragment-ktx:$navigation" implementation "androidx.navigation:navigation-fragment-ktx:$navigation"
implementation "androidx.navigation:navigation-ui-ktx:$navigation" implementation "androidx.navigation:navigation-ui-ktx:$navigation"
implementation "androidx.navigation:navigation-compose:$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:$compose"
implementation "androidx.compose.ui:ui-tooling:$compose" implementation "androidx.compose.ui:ui-tooling:$compose"
implementation "androidx.compose.foundation:foundation:$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-core:$compose"
implementation "androidx.compose.material:material-icons-extended:$compose" implementation "androidx.compose.material:material-icons-extended:$compose"
implementation "androidx.compose.animation:animation:$compose" implementation "androidx.compose.animation:animation:$compose"
implementation 'androidx.activity:activity-compose:1.3.1' implementation "androidx.compose.material3:material3:1.0.1"
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07' 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" androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose"
} }

View file

@ -2,7 +2,7 @@ package com.wbrawner.budget
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import com.wbrawner.budget.common.util.ErrorHandler import com.wbrawner.twigs.shared.ErrorHandler
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -12,7 +12,8 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class AppModule { class AppModule {
@Provides @Provides
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler { fun provideErrorHandler(): com.wbrawner.twigs.shared.ErrorHandler = object :
com.wbrawner.twigs.shared.ErrorHandler {
override fun init(application: Application) { override fun init(application: Application) {
// no-op // no-op
} }

View file

@ -8,17 +8,23 @@ import androidx.annotation.DrawableRes
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.* 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -52,7 +58,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
} }
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
setSupportActionBar(action_bar) 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.isDrawerIndicatorEnabled = true
toggle.isDrawerSlideAnimationEnabled = true toggle.isDrawerSlideAnimationEnabled = true
drawerLayout.addDrawerListener(toggle) drawerLayout.addDrawerListener(toggle)
@ -136,45 +143,59 @@ fun MainScreen() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TwigsDrawer(navController: NavController, budgets: List<Budget>) { fun TwigsDrawer(navController: NavController, budgets: List<Budget>, selectedBudgetId: String) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Row( 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) Image(painter = painterResource(id = image), null)
Text( Text(
text = "twigs", text = "twigs",
style = MaterialTheme.typography.h3 style = MaterialTheme.typography.h4
) )
} }
val currentBudget = navController.currentBackStackEntry?.arguments?.getString("id") NavigationDrawerItem(
} selected = false,
} onClick = { navController.navigate("overview") },
icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
@Composable label = { Text(text = "Overview") }
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)
) )
Text(text = text, color = tint) NavigationDrawerItem(
} selected = false,
} onClick = { navController.navigate("transactions") },
icon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
@Composable label = { Text(text = "Transactions") }
@Preview )
fun DrawerItem_Preview() { NavigationDrawerItem(
TwigsApp { selected = false,
DrawerItem(R.drawable.ic_folder_open, "Budget", 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 { TwigsApp {
Scaffold( Scaffold(
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
drawerContent = { TwigsDrawer(navController, emptyList()) } drawerContent = { TwigsDrawer(navController, emptyList(), "") }
) { ) {
} }

View file

@ -67,6 +67,8 @@ class AuthViewModel @Inject constructor(
val correctServer = with(server.value) { val correctServer = with(server.value) {
if (this.startsWith("http://") || this.startsWith("https://")) this if (this.startsWith("http://") || this.startsWith("https://")) this
else "https://$this" else "https://$this"
}.run{
if (this.endsWith("/api")) this else "$this/api"
} }
userRepository.login(correctServer.trim(), username.value.trim(), password.value.trim()) userRepository.login(correctServer.trim(), username.value.trim(), password.value.trim())
.also { .also {

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -18,6 +19,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource 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.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -30,8 +33,8 @@ import com.wbrawner.budget.ui.base.TwigsApp
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
fun LoginScreen( fun LoginScreen(
navController: NavController, navController: NavController,
viewModel: AuthViewModel viewModel: AuthViewModel
) { ) {
val loading by viewModel.loading.collectAsState() val loading by viewModel.loading.collectAsState()
val server by viewModel.server.collectAsState() val server by viewModel.server.collectAsState()
@ -39,87 +42,88 @@ fun LoginScreen(
val password by viewModel.password.collectAsState() val password by viewModel.password.collectAsState()
val enableLogin by viewModel.enableLogin.collectAsState() val enableLogin by viewModel.enableLogin.collectAsState()
AnimatedVisibility( AnimatedVisibility(
visible = !loading, visible = !loading,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
LoginForm( LoginForm(
server, server,
viewModel::setServer, viewModel::setServer,
username, username,
viewModel::setUsername, viewModel::setUsername,
password, password,
viewModel::setPassword, viewModel::setPassword,
enableLogin, enableLogin,
{ navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) }, { navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) },
{ navController.navigate(AuthRoutes.REGISTER.name) }, { navController.navigate(AuthRoutes.REGISTER.name) },
viewModel::login, viewModel::login,
) )
} }
} }
@Composable @Composable
fun LoginForm( fun LoginForm(
server: String, server: String,
setServer: (String) -> Unit, setServer: (String) -> Unit,
username: String, username: String,
setUsername: (String) -> Unit, setUsername: (String) -> Unit,
password: String, password: String,
setPassword: (String) -> Unit, setPassword: (String) -> Unit,
enableLogin: Boolean, enableLogin: Boolean,
forgotPassword: () -> Unit, forgotPassword: () -> Unit,
register: () -> Unit, register: () -> Unit,
login: () -> Unit, login: () -> Unit,
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(16.dp), .padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically), verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image( Image(
painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color), painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color),
contentDescription = null contentDescription = null
) )
Text("Log in to manage your budgets") Text("Log in to manage your budgets")
TextField( TextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = server, value = server,
onValueChange = setServer, onValueChange = setServer,
placeholder = { Text("Server") } keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, capitalization = KeyboardCapitalization.None),
placeholder = { Text("Server") }
) )
TextField( TextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = username, value = username,
onValueChange = setUsername, onValueChange = setUsername,
placeholder = { Text("Username") } placeholder = { Text("Username") }
) )
TextField( TextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = password, value = password,
onValueChange = setPassword, onValueChange = setPassword,
placeholder = { Text("Password") }, placeholder = { Text("Password") },
visualTransformation = PasswordVisualTransformation() visualTransformation = PasswordVisualTransformation()
) )
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = enableLogin, enabled = enableLogin,
onClick = login onClick = login
) { ) {
Text("Login") Text("Login")
} }
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = forgotPassword onClick = forgotPassword
) { ) {
Text("Forgot password?") Text("Forgot password?")
} }
OutlinedButton( OutlinedButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = register onClick = register
) { ) {
Text("Need an account?") Text("Need an account?")
} }

View file

@ -56,6 +56,7 @@ abstract class ListWithAddButtonFragment<Data : Identifiable, ViewModel : AsyncV
listContainer.visibility = View.GONE listContainer.visibility = View.GONE
noItemsTextView.visibility = View.VISIBLE noItemsTextView.visibility = View.VISIBLE
} }
else -> { /* no-op */ }
} }
} }
} }

View file

@ -1,25 +1,21 @@
package com.wbrawner.budget.ui.base package com.wbrawner.budget.ui.base
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.darkColors import androidx.compose.material3.MaterialTheme
import androidx.compose.material.lightColors import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color 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, primary = Green500,
primaryVariant = Green700, secondary = Green700,
) )
val darkColors = darkColors( val darkColors = darkColorScheme(
primary = Green300, primary = Green300,
primaryVariant = Green500, secondary = Green500,
background = Color.Black, background = Color.Black,
surface = Color.Black, surface = Color.Black,
) )
@ -27,7 +23,7 @@ val darkColors = darkColors(
@Composable @Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme( MaterialTheme(
colors = if (darkMode) darkColors else lightColors, colorScheme = if (darkMode) darkColors else lightColors,
// typography = MaterialTheme.typography.copy( // typography = MaterialTheme.typography.copy(
// h1 = MaterialTheme.typography.h1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))), // h1 = MaterialTheme.typography.h1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// h2 = MaterialTheme.typography.h2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))), // h2 = MaterialTheme.typography.h2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),

View file

@ -76,6 +76,7 @@ class AddEditBudgetFragment : Fragment() {
is BudgetFormState.Exit -> { is BudgetFormState.Exit -> {
findNavController().navigateUp() findNavController().navigateUp()
} }
else -> { /* no-op */ }
} }
}) })
} }

View file

@ -20,6 +20,8 @@ import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.transactions.toLong import com.wbrawner.budget.ui.transactions.toLong
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.activity_add_edit_category.* 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.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,6 +51,7 @@ class CategoryFormActivity : AppCompatActivity() {
setTitle(state.data.titleRes) setTitle(state.data.titleRes)
menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton
edit_category_name.setText(category.title) edit_category_name.setText(category.title)
edit_category_description.setText(category.description)
edit_category_amount.setText( edit_category_amount.setText(
String.format( String.format(
"%.02f", "%.02f",
@ -113,6 +116,7 @@ class CategoryFormActivity : AppCompatActivity() {
Category( Category(
id = id, id = id,
title = edit_category_name.text.toString(), title = edit_category_name.text.toString(),
description = edit_category_description.text.toString(),
amount = edit_category_amount.text.toLong(), amount = edit_category_amount.text.toLong(),
budgetId = (budgetSpinner.selectedItem as Budget).id!!, budgetId = (budgetSpinner.selectedItem as Budget).id!!,
expense = expense.isChecked, expense = expense.isChecked,

View file

@ -45,6 +45,7 @@ class OverviewFragment : Fragment() {
noData.visibility = View.VISIBLE noData.visibility = View.VISIBLE
Log.e("OverviewFragment", "Failed to load overview", state.exception) Log.e("OverviewFragment", "Failed to load overview", state.exception)
} }
else -> { /* no-op */ }
} }
}) })
} }

View file

@ -45,6 +45,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant
import java.util.* import java.util.*
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.math.max import kotlin.math.max
@ -124,6 +125,9 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
picker.addOnPositiveButtonClickListener { picker.addOnPositiveButtonClickListener {
transactionDate.text = transactionDate.text =
DateFormat.getDateFormat(this@TransactionFormActivity) DateFormat.getDateFormat(this@TransactionFormActivity)
.apply {
timeZone = TimeZone.getTimeZone("UTC")
}
.format(Date(it)) .format(Date(it))
} }
} }

View file

@ -39,6 +39,17 @@
android:inputType="textCapWords" /> android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout> </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 <com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_amount" android:id="@+id/container_edit_category_amount"
style="@style/AppTheme.EditText.Container" style="@style/AppTheme.EditText.Container"

View file

@ -20,6 +20,7 @@
<string name="title_edit_category">Edit Category</string> <string name="title_edit_category">Edit Category</string>
<string name="prompt_category_amount">Amount</string> <string name="prompt_category_amount">Amount</string>
<string name="prompt_category_name">Name</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="title_add_category">Add Category</string>
<string name="uncategorized">Uncategorized</string> <string name="uncategorized">Uncategorized</string>
<string name="prompt_transaction_category">Category</string> <string name="prompt_transaction_category">Category</string>

View file

@ -1 +0,0 @@
/build

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
<resources>
<string name="app_name">Budget Lib</string>
</resources>

View file

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

View file

@ -1,10 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.compose = '1.0.1' ext.compose = '1.3.1'
ext.dagger = '2.38.1' ext.dagger = '2.44'
ext.hyperion = '0.9.33' ext.hyperion = '0.9.33'
ext.kotlin_version = '1.5.21' ext.kotlin_version = '1.7.20'
ext.lifecycle_version = "2.2.0" ext.lifecycle_version = "2.2.0"
ext.moshi = '1.12.0' ext.moshi = '1.12.0'
ext.retrofit = '2.6.0' ext.retrofit = '2.6.0'
@ -14,9 +14,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.0.1' classpath libs.bundles.plugins
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.google.dagger:hilt-android-gradle-plugin:$dagger"
} }
} }

1
common/.gitignore vendored
View file

@ -1 +0,0 @@
/build

View file

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

View file

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

View file

@ -1 +0,0 @@
<manifest package="com.wbrawner.budget.common" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,16 @@
# Project-wide Gradle settings. ## For more details on how to configure your build environment visit
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html # http://www.gradle.org/docs/current/userguide/build_environment.html
#
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
android.enableJetifier=true # Default value: -Xmx1024m -XX:MaxPermSize=256m
android.useAndroidX=true # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx1536m #
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # 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 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true # 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
View 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"]

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

View file

@ -1 +0,0 @@
include ':android', ':budgetlib', ':common', ':storage'

3
settings.gradle.kts Normal file
View file

@ -0,0 +1,3 @@
enableFeaturePreview("VERSION_CATALOGS")
rootProject.name = "Twigs"
include(":android", ":shared")

1
shared/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

70
shared/build.gradle.kts Normal file
View 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
View 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

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <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" /> <uses-permission android:name="android.permission.INTERNET" />
</manifest> </manifest>

View file

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

View file

@ -0,0 +1,5 @@
package com.wbrawner.twigs.shared
interface ErrorHandler {
fun reportException(t: Throwable, message: String? = null)
}

View file

@ -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 * Base interface for an entity repository that provides basic CRUD methods
* *
* @param T The type of the object supported by this repository * @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 create(newItem: T): T
suspend fun findAll(): List<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 update(updatedItem: T): T
suspend fun delete(id: K) suspend fun delete(id: String)
} }
interface Identifiable { interface Identifiable {

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Category( data class Category(
val budgetId: String, val budgetId: String,
override val id: String? = null, val id: String? = null,
val title: String, val title: String,
val description: String? = null, val description: String? = null,
val amount: Long, val amount: Long,
val expense: Boolean = true, val expense: Boolean = true,
val archived: Boolean = false val archived: Boolean = false
): Identifiable { )
override fun toString(): String {
return title
}
}

View file

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

View file

@ -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.twigs.shared.budget.Budget
import com.wbrawner.budget.common.budget.Budget import com.wbrawner.twigs.shared.budget.NewBudgetRequest
import com.wbrawner.budget.common.category.Category import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.budget.common.transaction.BalanceResponse import com.wbrawner.twigs.shared.transaction.BalanceResponse
import com.wbrawner.budget.common.transaction.Transaction import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.budget.common.user.LoginRequest import com.wbrawner.twigs.shared.user.LoginRequest
import com.wbrawner.budget.common.user.User import com.wbrawner.twigs.shared.user.Session
import com.wbrawner.budget.lib.repository.NewBudgetRequest 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 baseUrl: String?
var authToken: String? var authToken: String?
@ -96,4 +104,20 @@ interface TwigsApiService {
): User ): User
suspend fun deleteUser(id: String) 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
}
} }

View file

@ -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.twigs.shared.budget.Budget
import com.wbrawner.budget.common.budget.Budget import com.wbrawner.twigs.shared.budget.NewBudgetRequest
import com.wbrawner.budget.common.category.Category import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.budget.common.transaction.BalanceResponse import com.wbrawner.twigs.shared.transaction.BalanceResponse
import com.wbrawner.budget.common.transaction.Transaction import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.budget.common.user.LoginRequest import com.wbrawner.twigs.shared.user.LoginRequest
import com.wbrawner.budget.common.user.User import com.wbrawner.twigs.shared.user.Session
import com.wbrawner.budget.lib.repository.NewBudgetRequest import com.wbrawner.twigs.shared.user.User
import com.wbrawner.budgetlib.BuildConfig
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.call.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.features.logging.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.http.* import io.ktor.http.*
class KtorTwigsApiService( class KtorAPIService(
override var baseUrl: String?, private val client: HttpClient
override var authToken: String? ) : APIService {
) : TwigsApiService { override var baseUrl: String? = null
private val client = HttpClient(CIO) { override var authToken: String? = null
install(JsonFeature) {
serializer = KotlinxSerializer()
}
if (BuildConfig.DEBUG) {
install(Logging) {
logger = Logger.ANDROID
level = LogLevel.ALL
}
}
}
override suspend fun getBudgets(count: Int?, page: Int?): List<Budget> = request("budgets") 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( override suspend fun newBudget(budget: NewBudgetRequest): Budget = request(
path = "budgets", path = "budgets",
httpBody = budget, body = budget,
httpMethod = HttpMethod.Post httpMethod = HttpMethod.Post
) )
override suspend fun updateBudget(id: String, budget: Budget): Budget = request( override suspend fun updateBudget(id: String, budget: Budget): Budget = request(
path = "budgets/$id", path = "budgets/$id",
httpBody = budget, body = budget,
httpMethod = HttpMethod.Put httpMethod = HttpMethod.Put
) )
@ -74,13 +59,13 @@ class KtorTwigsApiService(
override suspend fun newCategory(category: Category): Category = request( override suspend fun newCategory(category: Category): Category = request(
path = "categories", path = "categories",
httpBody = category, body = category,
httpMethod = HttpMethod.Post httpMethod = HttpMethod.Post
) )
override suspend fun updateCategory(id: String, category: Category): Category = request( override suspend fun updateCategory(id: String, category: Category): Category = request(
path = "categories/$id", path = "categories/$id",
httpBody = category, body = category,
httpMethod = HttpMethod.Put httpMethod = HttpMethod.Put
) )
@ -121,14 +106,14 @@ class KtorTwigsApiService(
override suspend fun newTransaction(transaction: Transaction): Transaction = request( override suspend fun newTransaction(transaction: Transaction): Transaction = request(
path = "transactions", path = "transactions",
httpBody = transaction, body = transaction,
httpMethod = HttpMethod.Post httpMethod = HttpMethod.Post
) )
override suspend fun updateTransaction(id: String, transaction: Transaction): Transaction = override suspend fun updateTransaction(id: String, transaction: Transaction): Transaction =
request( request(
path = "transactions/$id", path = "transactions/$id",
httpBody = transaction, body = transaction,
httpMethod = HttpMethod.Put httpMethod = HttpMethod.Put
) )
@ -148,7 +133,7 @@ class KtorTwigsApiService(
override suspend fun login(request: LoginRequest): Session = request( override suspend fun login(request: LoginRequest): Session = request(
path = "users/login", path = "users/login",
httpBody = request, body = request,
httpMethod = HttpMethod.Post httpMethod = HttpMethod.Post
) )
@ -165,13 +150,13 @@ class KtorTwigsApiService(
override suspend fun newUser(user: User): User = request( override suspend fun newUser(user: User): User = request(
path = "users", path = "users",
httpBody = user, body = user,
httpMethod = HttpMethod.Post, httpMethod = HttpMethod.Post,
) )
override suspend fun updateUser(id: String, user: User): User = request( override suspend fun updateUser(id: String, user: User): User = request(
path = "users/$id", path = "users/$id",
httpBody = user, body = user,
httpMethod = HttpMethod.Put, httpMethod = HttpMethod.Put,
) )
@ -183,9 +168,9 @@ class KtorTwigsApiService(
private suspend inline fun <reified T> request( private suspend inline fun <reified T> request(
path: String, path: String,
queryParams: List<Pair<String, Any?>>? = null, queryParams: List<Pair<String, Any?>>? = null,
httpBody: Any? = null, body: Any? = null,
httpMethod: HttpMethod = HttpMethod.Get 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 method = httpMethod
headers { headers {
authToken?.let { append(HttpHeaders.Authorization, "Bearer $it") } authToken?.let { append(HttpHeaders.Authorization, "Bearer $it") }
@ -199,9 +184,9 @@ class KtorTwigsApiService(
} }
} }
} }
httpBody?.let { body?.let {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
body = it setBody(it)
} }
} }.body()
} }

View file

@ -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.KSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
@ -8,28 +8,26 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import java.time.Instant
import java.util.*
@Serializable @Serializable
data class Transaction( data class Transaction(
override val id: String? = null, val id: String? = null,
val title: String, val title: String,
@Serializable(with = DateSerializer::class) @Serializable(with = DateSerializer::class)
val date: Date, val date: Instant,
val description: String? = null, val description: String? = null,
val amount: Long, val amount: Long,
val categoryId: String? = null, val categoryId: String? = null,
val budgetId: String, val budgetId: String,
val expense: Boolean, val expense: Boolean,
val createdBy: String val createdBy: String
): Identifiable )
@Serializable @Serializable
data class BalanceResponse(val balance: Long) data class BalanceResponse(val balance: Long)
object DateSerializer : KSerializer<Date> { object DateSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Date) = encoder.encodeString(value.toInstant().toString()) override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Date = Date.from(Instant.parse(decoder.decodeString())) override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
} }

View file

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

View file

@ -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 import kotlinx.serialization.Serializable
@Serializable @Serializable
data class User( data class User(
override val id: String? = null, val id: String? = null,
val username: String, val username: String,
val email: String? = null, val email: String? = null,
val avatar: String? = null val avatar: String? = null
) : Identifiable { )
override fun toString(): String = username
}
@Serializable @Serializable
data class UserPermission( data class UserPermission(
@ -31,3 +29,10 @@ data class LoginRequest(
val username: String, val username: String,
val password: String val password: String
) )
@Serializable
data class Session(
val userId: String,
val token: String,
val expiration: Instant
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View file

@ -1 +0,0 @@
/build

View file

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

View file

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

View file

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

View file

@ -1 +0,0 @@
<manifest package="com.wbrawner.auth" />

View file

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

View file

@ -1,3 +0,0 @@
<resources>
<string name="app_name">Auth</string>
</resources>

View file

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