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 {
|
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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(), "") }
|
||||||
) {
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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?")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))),
|
||||||
|
|
|
@ -76,6 +76,7 @@ class AddEditBudgetFragment : Fragment() {
|
||||||
is BudgetFormState.Exit -> {
|
is BudgetFormState.Exit -> {
|
||||||
findNavController().navigateUp()
|
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 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,
|
||||||
|
|
|
@ -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 */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
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.
|
// 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
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.
|
## 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
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
|
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
|
||||||
|
|
|
@ -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"
|
<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>
|
|
@ -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
|
* 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 {
|
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
|
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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.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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
|
@ -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())
|
||||||
}
|
}
|
|
@ -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
|
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(
|
||||||
|
@ -30,4 +28,11 @@ enum class Permission {
|
||||||
data class LoginRequest(
|
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
|
||||||
)
|
)
|
|
@ -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