From 120d3dd70b01eb848e01eecf01acf89d227bf304 Mon Sep 17 00:00:00 2001 From: William Brawner Date: Tue, 20 Dec 2022 10:41:21 -0700 Subject: [PATCH] WIP: Migrate to Kotlin Multiplatform --- android/build.gradle | 41 +++--- .../java/com/wbrawner/budget/AppModule.kt | 5 +- .../com/wbrawner/budget/ui/MainActivity.kt | 91 ++++++++----- .../wbrawner/budget/ui/auth/AuthViewModel.kt | 2 + .../wbrawner/budget/ui/auth/LoginScreen.kt | 110 ++++++++-------- .../ui/base/ListWithAddButtonFragment.kt | 1 + .../java/com/wbrawner/budget/ui/base/Theme.kt | 20 ++- .../ui/budgets/AddEditBudgetFragment.kt | 1 + .../ui/categories/CategoryFormActivity.kt | 4 + .../budget/ui/overview/OverviewFragment.kt | 1 + .../transactions/TransactionFormActivity.kt | 4 + .../res/layout/activity_add_edit_category.xml | 11 ++ android/src/main/res/values/strings.xml | 1 + budgetlib/.gitignore | 1 - budgetlib/build.gradle | 46 ------- budgetlib/proguard-rules.pro | 21 --- .../budgetlib/ExampleInstrumentedTest.java | 27 ---- .../budget/lib/network/NetworkModule.kt | 57 --------- .../lib/repository/NetworkBudgetRepository.kt | 109 ---------------- .../repository/NetworkCategoryRepository.kt | 24 ---- .../NetworkTransactionRepository.kt | 35 ----- .../lib/repository/NetworkUserRepository.kt | 53 -------- budgetlib/src/main/res/values/strings.xml | 3 - .../wbrawner/budgetlib/ExampleUnitTest.java | 17 --- build.gradle | 10 +- common/.gitignore | 1 - common/build.gradle | 41 ------ common/proguard-rules.pro | 41 ------ common/src/main/AndroidManifest.xml | 1 - .../com/wbrawner/budget/common/Session.kt | 16 --- .../wbrawner/budget/common/budget/Budget.kt | 17 --- .../budget/common/budget/BudgetRepository.kt | 12 -- .../common/category/CategoryRepository.kt | 8 -- .../transaction/TransactionRepository.kt | 25 ---- .../budget/common/user/UserRepository.kt | 13 -- .../budget/common/util/ErrorHandler.kt | 8 -- .../com/wbrawner/budget/common/util/Utils.kt | 11 -- .../budget/common/ExampleUnitTest.java | 17 --- gradle.properties | 17 +-- gradle/libs.versions.toml | 67 ++++++++++ gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle | 1 - settings.gradle.kts | 3 + shared/.gitignore | 1 + shared/build.gradle.kts | 70 ++++++++++ shared/proguard-rules.pro | 26 ++++ .../src/androidMain}/AndroidManifest.xml | 5 +- .../com/wbrawner/pihelper/shared/Android.kt | 22 ++++ .../com/wbrawner/twigs/shared/ErrorHandler.kt | 5 + .../com/wbrawner/twigs/shared}/Repository.kt | 9 +- .../kotlin/com/wbrawner/twigs/shared/Store.kt | 103 +++++++++++++++ .../kotlin/com/wbrawner/twigs/shared/Utils.kt | 31 +++++ .../wbrawner/twigs/shared/budget/Budget.kt | 24 ++++ .../twigs/shared/budget/BudgetAction.kt | 121 ++++++++++++++++++ .../twigs/shared/budget/BudgetRepository.kt | 24 ++++ .../twigs/shared}/category/Category.kt | 11 +- .../shared/category/CategoryRepository.kt | 9 ++ .../twigs/shared/network/APIService.kt | 46 +++++-- .../twigs/shared/network/KtorAPIService.kt | 73 +++++------ .../twigs/shared}/transaction/Transaction.kt | 18 ++- .../transaction/TransactionRepository.kt | 15 +++ .../com/wbrawner/twigs/shared}/user/User.kt | 17 ++- .../wbrawner/twigs/shared/user/UserAction.kt | 13 ++ .../twigs/shared/user/UserRepository.kt | 10 ++ .../twigs/shared/BudgetReducerTests.kt | 54 ++++++++ .../twigs/shared/FakeBudgetRepository.kt | 33 +++++ .../com/wbrawner/twigs/shared/UtilsTests.kt | 55 ++++++++ .../com/wbrawner/pihelper/shared/iOS.kt | 46 +++++++ storage/.gitignore | 1 - storage/build.gradle | 43 ------- storage/proguard-rules.pro | 21 --- .../auth/ExampleInstrumentedTest.java | 27 ---- storage/src/main/AndroidManifest.xml | 1 - .../wbrawner/budget/storage/StorageModule.kt | 28 ---- storage/src/main/res/values/strings.xml | 3 - .../com/wbrawner/auth/ExampleUnitTest.java | 17 --- 76 files changed, 1009 insertions(+), 969 deletions(-) delete mode 100644 budgetlib/.gitignore delete mode 100644 budgetlib/build.gradle delete mode 100644 budgetlib/proguard-rules.pro delete mode 100644 budgetlib/src/androidTest/java/com/wbrawner/budgetlib/ExampleInstrumentedTest.java delete mode 100644 budgetlib/src/main/java/com/wbrawner/budget/lib/network/NetworkModule.kt delete mode 100644 budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkBudgetRepository.kt delete mode 100644 budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkCategoryRepository.kt delete mode 100644 budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkTransactionRepository.kt delete mode 100644 budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkUserRepository.kt delete mode 100644 budgetlib/src/main/res/values/strings.xml delete mode 100644 budgetlib/src/test/java/com/wbrawner/budgetlib/ExampleUnitTest.java delete mode 100644 common/.gitignore delete mode 100644 common/build.gradle delete mode 100644 common/proguard-rules.pro delete mode 100644 common/src/main/AndroidManifest.xml delete mode 100644 common/src/main/java/com/wbrawner/budget/common/Session.kt delete mode 100644 common/src/main/java/com/wbrawner/budget/common/budget/Budget.kt delete mode 100644 common/src/main/java/com/wbrawner/budget/common/budget/BudgetRepository.kt delete mode 100644 common/src/main/java/com/wbrawner/budget/common/category/CategoryRepository.kt delete mode 100644 common/src/main/java/com/wbrawner/budget/common/transaction/TransactionRepository.kt delete mode 100644 common/src/main/java/com/wbrawner/budget/common/user/UserRepository.kt delete mode 100644 common/src/main/java/com/wbrawner/budget/common/util/ErrorHandler.kt delete mode 100644 common/src/main/java/com/wbrawner/budget/common/util/Utils.kt delete mode 100644 common/src/test/java/com/wbrawner/budget/common/ExampleUnitTest.java create mode 100644 gradle/libs.versions.toml delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts create mode 100644 shared/.gitignore create mode 100644 shared/build.gradle.kts create mode 100644 shared/proguard-rules.pro rename {budgetlib/src/main => shared/src/androidMain}/AndroidManifest.xml (59%) create mode 100644 shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/ErrorHandler.kt rename {common/src/main/java/com/wbrawner/budget/common => shared/src/commonMain/kotlin/com/wbrawner/twigs/shared}/Repository.kt (59%) create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Utils.kt create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/Budget.kt create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt rename {common/src/main/java/com/wbrawner/budget/common => shared/src/commonMain/kotlin/com/wbrawner/twigs/shared}/category/Category.kt (56%) create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt rename budgetlib/src/main/java/com/wbrawner/budget/lib/network/TwigsApiService.kt => shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt (64%) rename budgetlib/src/main/java/com/wbrawner/budget/lib/network/KtorTwigsApiService.kt => shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt (76%) rename {common/src/main/java/com/wbrawner/budget/common => shared/src/commonMain/kotlin/com/wbrawner/twigs/shared}/transaction/Transaction.kt (63%) create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt rename {common/src/main/java/com/wbrawner/budget/common => shared/src/commonMain/kotlin/com/wbrawner/twigs/shared}/user/User.kt (66%) create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserAction.kt create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserRepository.kt create mode 100644 shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/BudgetReducerTests.kt create mode 100644 shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/FakeBudgetRepository.kt create mode 100644 shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/UtilsTests.kt create mode 100644 shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt delete mode 100644 storage/.gitignore delete mode 100644 storage/build.gradle delete mode 100644 storage/proguard-rules.pro delete mode 100644 storage/src/androidTest/java/com/wbrawner/auth/ExampleInstrumentedTest.java delete mode 100644 storage/src/main/AndroidManifest.xml delete mode 100644 storage/src/main/java/com/wbrawner/budget/storage/StorageModule.kt delete mode 100644 storage/src/main/res/values/strings.xml delete mode 100644 storage/src/test/java/com/wbrawner/auth/ExampleUnitTest.java diff --git a/android/build.gradle b/android/build.gradle index e78235f..84b482a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ plugins { } android { - compileSdkVersion 31 + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -18,7 +18,7 @@ android { defaultConfig { applicationId "com.wbrawner.twigs" minSdkVersion 26 - targetSdkVersion 31 + targetSdkVersion 33 versionCode 1 versionName "1.0" vectorDrawables { @@ -39,36 +39,35 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion rootProject.extensions.getExtraProperties().get("compose") + kotlinCompilerExtensionVersion "1.3.2" } } dependencies { - implementation project(':common') - implementation project(':budgetlib') - implementation project(':storage') + implementation project(':shared') implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.appcompat:appcompat:1.3.1' - implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.media:media:1.4.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.0' - implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.media:media:1.6.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'com.google.android.material:material:1.7.0' implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" // Dagger implementation "com.google.dagger:hilt-android:$dagger" kapt "com.google.dagger:hilt-compiler:$dagger" - implementation 'androidx.hilt:hilt-navigation-compose:1.0.0-alpha03' - testImplementation 'junit:junit:4.13.1' - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' - implementation 'androidx.core:core-splashscreen:1.0.0-alpha01' - def navigation = '2.4.0-alpha07' + implementation 'androidx.hilt:hilt-navigation-compose:1.0.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:runner:1.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation 'androidx.core:core-splashscreen:1.0.0' + def navigation = '2.6.0-alpha04' implementation "androidx.navigation:navigation-fragment-ktx:$navigation" implementation "androidx.navigation:navigation-ui-ktx:$navigation" implementation "androidx.navigation:navigation-compose:$navigation" +// implementation "androidx.compose.compiler:compiler:1.4.0-alpha02" implementation "androidx.compose.ui:ui:$compose" implementation "androidx.compose.ui:ui-tooling:$compose" implementation "androidx.compose.foundation:foundation:$compose" @@ -76,7 +75,9 @@ dependencies { implementation "androidx.compose.material:material-icons-core:$compose" implementation "androidx.compose.material:material-icons-extended:$compose" implementation "androidx.compose.animation:animation:$compose" - implementation 'androidx.activity:activity-compose:1.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07' + implementation "androidx.compose.material3:material3:1.0.1" + implementation "androidx.compose.material3:material3-window-size-class:1.0.1" + implementation 'androidx.activity:activity-compose:1.6.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha03' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose" } diff --git a/android/src/main/java/com/wbrawner/budget/AppModule.kt b/android/src/main/java/com/wbrawner/budget/AppModule.kt index b550bbd..f432ae6 100644 --- a/android/src/main/java/com/wbrawner/budget/AppModule.kt +++ b/android/src/main/java/com/wbrawner/budget/AppModule.kt @@ -2,7 +2,7 @@ package com.wbrawner.budget import android.app.Application import android.util.Log -import com.wbrawner.budget.common.util.ErrorHandler +import com.wbrawner.twigs.shared.ErrorHandler import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -12,7 +12,8 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) class AppModule { @Provides - fun provideErrorHandler(): ErrorHandler = object : ErrorHandler { + fun provideErrorHandler(): com.wbrawner.twigs.shared.ErrorHandler = object : + com.wbrawner.twigs.shared.ErrorHandler { override fun init(application: Application) { // no-op } diff --git a/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt b/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt index 555a37c..b5c744e 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt @@ -8,17 +8,23 @@ import androidx.annotation.DrawableRes import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.NavigationDrawerItem import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,7 +58,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } setContentView(R.layout.activity_main) setSupportActionBar(action_bar) - toggle = ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close) + toggle = + ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close) toggle.isDrawerIndicatorEnabled = true toggle.isDrawerSlideAnimationEnabled = true drawerLayout.addDrawerListener(toggle) @@ -136,45 +143,59 @@ fun MainScreen() { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun TwigsDrawer(navController: NavController, budgets: List) { +fun TwigsDrawer(navController: NavController, budgets: List, selectedBudgetId: String) { Column(modifier = Modifier.fillMaxSize()) { Row( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - val image = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color + val image = + if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color Image(painter = painterResource(id = image), null) Text( text = "twigs", - style = MaterialTheme.typography.h3 + style = MaterialTheme.typography.h4 ) } - val currentBudget = navController.currentBackStackEntry?.arguments?.getString("id") - } -} - -@Composable -fun DrawerItem(@DrawableRes image: Int, text: String, selected: Boolean) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = spacedBy(8.dp, Alignment.Start), - ) { - val tint = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface - Image( - painter = painterResource(id = image), - contentDescription = null, - colorFilter = ColorFilter.tint(tint) + NavigationDrawerItem( + selected = false, + onClick = { navController.navigate("overview") }, + icon = { Icon(Icons.Default.Dashboard, contentDescription = null) }, + label = { Text(text = "Overview") } ) - Text(text = text, color = tint) - } -} - -@Composable -@Preview -fun DrawerItem_Preview() { - TwigsApp { - DrawerItem(R.drawable.ic_folder_open, "Budget", false) + NavigationDrawerItem( + selected = false, + onClick = { navController.navigate("transactions") }, + icon = { Icon(Icons.Default.AttachMoney, contentDescription = null) }, + label = { Text(text = "Transactions") } + ) + NavigationDrawerItem( + selected = false, + onClick = { navController.navigate("categories") }, + icon = { Icon(Icons.Default.Category, contentDescription = null) }, + label = { Text(text = "Categories") } + ) + NavigationDrawerItem( + selected = false, + onClick = { navController.navigate("recurring") }, + icon = { Icon(Icons.Default.Repeat, contentDescription = null) }, + label = { Text(text = "Recurring Transactions") } + ) + Divider() + LazyColumn(modifier = Modifier.fillMaxHeight()) { + items(budgets) { budget -> + val selected = budget.id == selectedBudgetId + val icon = if (selected) Icons.Filled.Folder else Icons.Outlined.Folder + NavigationDrawerItem( + icon = { Icon(icon, contentDescription = null) }, + label = { Text(budget.name) }, + selected = selected, + onClick = { navController.navigate("budgets/${budget.id}") } + ) + } + } } } @@ -186,7 +207,7 @@ fun TwigsDrawer_Preview() { TwigsApp { Scaffold( scaffoldState = scaffoldState, - drawerContent = { TwigsDrawer(navController, emptyList()) } + drawerContent = { TwigsDrawer(navController, emptyList(), "") } ) { } diff --git a/android/src/main/java/com/wbrawner/budget/ui/auth/AuthViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/auth/AuthViewModel.kt index 4d9edee..cb3faaf 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/auth/AuthViewModel.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/auth/AuthViewModel.kt @@ -67,6 +67,8 @@ class AuthViewModel @Inject constructor( val correctServer = with(server.value) { if (this.startsWith("http://") || this.startsWith("https://")) this else "https://$this" + }.run{ + if (this.endsWith("/api")) this else "$this/api" } userRepository.login(correctServer.trim(), username.value.trim(), password.value.trim()) .also { diff --git a/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt index b6431e3..b5bcefa 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -18,6 +19,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -30,8 +33,8 @@ import com.wbrawner.budget.ui.base.TwigsApp @ExperimentalAnimationApi @Composable fun LoginScreen( - navController: NavController, - viewModel: AuthViewModel + navController: NavController, + viewModel: AuthViewModel ) { val loading by viewModel.loading.collectAsState() val server by viewModel.server.collectAsState() @@ -39,87 +42,88 @@ fun LoginScreen( val password by viewModel.password.collectAsState() val enableLogin by viewModel.enableLogin.collectAsState() AnimatedVisibility( - visible = !loading, - enter = fadeIn(), - exit = fadeOut() + visible = !loading, + enter = fadeIn(), + exit = fadeOut() ) { LoginForm( - server, - viewModel::setServer, - username, - viewModel::setUsername, - password, - viewModel::setPassword, - enableLogin, - { navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) }, - { navController.navigate(AuthRoutes.REGISTER.name) }, - viewModel::login, + server, + viewModel::setServer, + username, + viewModel::setUsername, + password, + viewModel::setPassword, + enableLogin, + { navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) }, + { navController.navigate(AuthRoutes.REGISTER.name) }, + viewModel::login, ) } } @Composable fun LoginForm( - server: String, - setServer: (String) -> Unit, - username: String, - setUsername: (String) -> Unit, - password: String, - setPassword: (String) -> Unit, - enableLogin: Boolean, - forgotPassword: () -> Unit, - register: () -> Unit, - login: () -> Unit, + server: String, + setServer: (String) -> Unit, + username: String, + setUsername: (String) -> Unit, + password: String, + setPassword: (String) -> Unit, + enableLogin: Boolean, + forgotPassword: () -> Unit, + register: () -> Unit, + login: () -> Unit, ) { val scrollState = rememberScrollState() Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(16.dp), - verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color), - contentDescription = null + painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color), + contentDescription = null ) Text("Log in to manage your budgets") TextField( - modifier = Modifier.fillMaxWidth(), - value = server, - onValueChange = setServer, - placeholder = { Text("Server") } + modifier = Modifier.fillMaxWidth(), + value = server, + onValueChange = setServer, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, capitalization = KeyboardCapitalization.None), + placeholder = { Text("Server") } ) TextField( - modifier = Modifier.fillMaxWidth(), - value = username, - onValueChange = setUsername, - placeholder = { Text("Username") } + modifier = Modifier.fillMaxWidth(), + value = username, + onValueChange = setUsername, + placeholder = { Text("Username") } ) TextField( - modifier = Modifier.fillMaxWidth(), - value = password, - onValueChange = setPassword, - placeholder = { Text("Password") }, - visualTransformation = PasswordVisualTransformation() + modifier = Modifier.fillMaxWidth(), + value = password, + onValueChange = setPassword, + placeholder = { Text("Password") }, + visualTransformation = PasswordVisualTransformation() ) Button( - modifier = Modifier.fillMaxWidth(), - enabled = enableLogin, - onClick = login + modifier = Modifier.fillMaxWidth(), + enabled = enableLogin, + onClick = login ) { Text("Login") } TextButton( - modifier = Modifier.fillMaxWidth(), - onClick = forgotPassword + modifier = Modifier.fillMaxWidth(), + onClick = forgotPassword ) { Text("Forgot password?") } OutlinedButton( - modifier = Modifier.fillMaxWidth(), - onClick = register + modifier = Modifier.fillMaxWidth(), + onClick = register ) { Text("Need an account?") } diff --git a/android/src/main/java/com/wbrawner/budget/ui/base/ListWithAddButtonFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/base/ListWithAddButtonFragment.kt index c7cf387..5711dfc 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/base/ListWithAddButtonFragment.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/base/ListWithAddButtonFragment.kt @@ -56,6 +56,7 @@ abstract class ListWithAddButtonFragment { /* no-op */ } } } } diff --git a/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt b/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt index a13722e..1d03479 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt @@ -1,25 +1,21 @@ package com.wbrawner.budget.ui.base import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import com.wbrawner.budget.R -val lightColors = lightColors( +val lightColors = lightColorScheme( primary = Green500, - primaryVariant = Green700, + secondary = Green700, ) -val darkColors = darkColors( +val darkColors = darkColorScheme( primary = Green300, - primaryVariant = Green500, + secondary = Green500, background = Color.Black, surface = Color.Black, ) @@ -27,7 +23,7 @@ val darkColors = darkColors( @Composable fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { MaterialTheme( - colors = if (darkMode) darkColors else lightColors, + colorScheme = if (darkMode) darkColors else lightColors, // typography = MaterialTheme.typography.copy( // h1 = MaterialTheme.typography.h1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))), // h2 = MaterialTheme.typography.h2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))), diff --git a/android/src/main/java/com/wbrawner/budget/ui/budgets/AddEditBudgetFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/budgets/AddEditBudgetFragment.kt index 5db64a4..a1a3b8f 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/budgets/AddEditBudgetFragment.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/budgets/AddEditBudgetFragment.kt @@ -76,6 +76,7 @@ class AddEditBudgetFragment : Fragment() { is BudgetFormState.Exit -> { findNavController().navigateUp() } + else -> { /* no-op */ } } }) } diff --git a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormActivity.kt b/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormActivity.kt index 4c47e16..b341ad3 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormActivity.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormActivity.kt @@ -20,6 +20,8 @@ import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID import com.wbrawner.budget.ui.transactions.toLong import dagger.hilt.android.AndroidEntryPoint import kotlinx.android.synthetic.main.activity_add_edit_category.* +import kotlinx.android.synthetic.main.activity_add_edit_category.progressBar +import kotlinx.android.synthetic.main.fragment_add_edit_budget.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -49,6 +51,7 @@ class CategoryFormActivity : AppCompatActivity() { setTitle(state.data.titleRes) menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton edit_category_name.setText(category.title) + edit_category_description.setText(category.description) edit_category_amount.setText( String.format( "%.02f", @@ -113,6 +116,7 @@ class CategoryFormActivity : AppCompatActivity() { Category( id = id, title = edit_category_name.text.toString(), + description = edit_category_description.text.toString(), amount = edit_category_amount.text.toLong(), budgetId = (budgetSpinner.selectedItem as Budget).id!!, expense = expense.isChecked, diff --git a/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewFragment.kt index 9111ea7..216fc47 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewFragment.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewFragment.kt @@ -45,6 +45,7 @@ class OverviewFragment : Fragment() { noData.visibility = View.VISIBLE Log.e("OverviewFragment", "Failed to load overview", state.exception) } + else -> { /* no-op */ } } }) } diff --git a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormActivity.kt b/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormActivity.kt index 7d3ee2e..62e2ee7 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormActivity.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormActivity.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.math.BigDecimal +import java.time.Instant import java.util.* import kotlin.coroutines.CoroutineContext import kotlin.math.max @@ -124,6 +125,9 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope { picker.addOnPositiveButtonClickListener { transactionDate.text = DateFormat.getDateFormat(this@TransactionFormActivity) + .apply { + timeZone = TimeZone.getTimeZone("UTC") + } .format(Date(it)) } } diff --git a/android/src/main/res/layout/activity_add_edit_category.xml b/android/src/main/res/layout/activity_add_edit_category.xml index 142043c..013d398 100644 --- a/android/src/main/res/layout/activity_add_edit_category.xml +++ b/android/src/main/res/layout/activity_add_edit_category.xml @@ -39,6 +39,17 @@ android:inputType="textCapWords" /> + + + + + Edit Category Amount Name + Description Add Category Uncategorized Category diff --git a/budgetlib/.gitignore b/budgetlib/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/budgetlib/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/budgetlib/build.gradle b/budgetlib/build.gradle deleted file mode 100644 index a61179b..0000000 --- a/budgetlib/build.gradle +++ /dev/null @@ -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() -} diff --git a/budgetlib/proguard-rules.pro b/budgetlib/proguard-rules.pro deleted file mode 100644 index ed93946..0000000 --- a/budgetlib/proguard-rules.pro +++ /dev/null @@ -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 diff --git a/budgetlib/src/androidTest/java/com/wbrawner/budgetlib/ExampleInstrumentedTest.java b/budgetlib/src/androidTest/java/com/wbrawner/budgetlib/ExampleInstrumentedTest.java deleted file mode 100644 index ab93079..0000000 --- a/budgetlib/src/androidTest/java/com/wbrawner/budgetlib/ExampleInstrumentedTest.java +++ /dev/null @@ -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 Testing documentation - */ -@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()); - } -} diff --git a/budgetlib/src/main/java/com/wbrawner/budget/lib/network/NetworkModule.kt b/budgetlib/src/main/java/com/wbrawner/budget/lib/network/NetworkModule.kt deleted file mode 100644 index a7a65dc..0000000 --- a/budgetlib/src/main/java/com/wbrawner/budget/lib/network/NetworkModule.kt +++ /dev/null @@ -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) -} diff --git a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkBudgetRepository.kt b/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkBudgetRepository.kt deleted file mode 100644 index 45d539c..0000000 --- a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkBudgetRepository.kt +++ /dev/null @@ -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 = mutableSetOf() - private val current = MutableStateFlow(null) - override val currentBudget: SharedFlow = 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 = 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 -) { - constructor(budget: Budget) : this( - budget.name, - budget.description, - budget.users - ) -} \ No newline at end of file diff --git a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkCategoryRepository.kt b/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkCategoryRepository.kt deleted file mode 100644 index 69b4e96..0000000 --- a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkCategoryRepository.kt +++ /dev/null @@ -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?): List = apiService.getCategories(budgetIds).sortedBy { it.title } - - override suspend fun findAll(): List = 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 -} diff --git a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkTransactionRepository.kt b/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkTransactionRepository.kt deleted file mode 100644 index 4e36305..0000000 --- a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkTransactionRepository.kt +++ /dev/null @@ -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?, - categoryIds: List?, - start: Calendar?, - end: Calendar? - ): List = apiService.getTransactions( - budgetIds, - categoryIds, - start?.toInstant()?.toString(), - end?.toInstant()?.toString() - ) - - override suspend fun findAll(): List = 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) -} diff --git a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkUserRepository.kt b/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkUserRepository.kt deleted file mode 100644 index 4d06f97..0000000 --- a/budgetlib/src/main/java/com/wbrawner/budget/lib/repository/NetworkUserRepository.kt +++ /dev/null @@ -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(null) - override val currentUser: SharedFlow = 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 = apiService.getUsers(budgetId) - - override suspend fun findAll(): List = findAll(null) - - override suspend fun findById(id: String): User = apiService.getUser(id) - - override suspend fun findAllByNameLike(query: String): List = - apiService.searchUsers(query) - - override suspend fun update(updatedItem: User): User = - apiService.updateUser(updatedItem.id!!, updatedItem) - - override suspend fun delete(id: String) = apiService.deleteUser(id) -} diff --git a/budgetlib/src/main/res/values/strings.xml b/budgetlib/src/main/res/values/strings.xml deleted file mode 100644 index 5620dc5..0000000 --- a/budgetlib/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Budget Lib - diff --git a/budgetlib/src/test/java/com/wbrawner/budgetlib/ExampleUnitTest.java b/budgetlib/src/test/java/com/wbrawner/budgetlib/ExampleUnitTest.java deleted file mode 100644 index 11e4825..0000000 --- a/budgetlib/src/test/java/com/wbrawner/budgetlib/ExampleUnitTest.java +++ /dev/null @@ -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 Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 84dbbc5..fb5ef06 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.compose = '1.0.1' - ext.dagger = '2.38.1' + ext.compose = '1.3.1' + ext.dagger = '2.44' ext.hyperion = '0.9.33' - ext.kotlin_version = '1.5.21' + ext.kotlin_version = '1.7.20' ext.lifecycle_version = "2.2.0" ext.moshi = '1.12.0' ext.retrofit = '2.6.0' @@ -14,9 +14,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "com.google.dagger:hilt-android-gradle-plugin:$dagger" + classpath libs.bundles.plugins } } diff --git a/common/.gitignore b/common/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/common/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/common/build.gradle b/common/build.gradle deleted file mode 100644 index 0a26ccf..0000000 --- a/common/build.gradle +++ /dev/null @@ -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() -} diff --git a/common/proguard-rules.pro b/common/proguard-rules.pro deleted file mode 100644 index f51b861..0000000 --- a/common/proguard-rules.pro +++ /dev/null @@ -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(...); -} diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml deleted file mode 100644 index 348b50d..0000000 --- a/common/src/main/AndroidManifest.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/common/src/main/java/com/wbrawner/budget/common/Session.kt b/common/src/main/java/com/wbrawner/budget/common/Session.kt deleted file mode 100644 index 48ed85f..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/Session.kt +++ /dev/null @@ -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 -) \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/budget/Budget.kt b/common/src/main/java/com/wbrawner/budget/common/budget/Budget.kt deleted file mode 100644 index 33a5b8b..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/budget/Budget.kt +++ /dev/null @@ -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 = emptyList() -) : Identifiable { - override fun toString(): String { - return name - } -} diff --git a/common/src/main/java/com/wbrawner/budget/common/budget/BudgetRepository.kt b/common/src/main/java/com/wbrawner/budget/common/budget/BudgetRepository.kt deleted file mode 100644 index 69eaf0a..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/budget/BudgetRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.wbrawner.budget.common.budget - -import com.wbrawner.budget.common.Repository -import kotlinx.coroutines.flow.SharedFlow - -interface BudgetRepository : Repository { - val currentBudget: SharedFlow - 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 -} \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/category/CategoryRepository.kt b/common/src/main/java/com/wbrawner/budget/common/category/CategoryRepository.kt deleted file mode 100644 index 5ce1784..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/category/CategoryRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.wbrawner.budget.common.category - -import com.wbrawner.budget.common.Repository - -interface CategoryRepository : Repository { - suspend fun findAll(budgetIds: Array? = null): List - suspend fun getBalance(id: String): Long -} \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/transaction/TransactionRepository.kt b/common/src/main/java/com/wbrawner/budget/common/transaction/TransactionRepository.kt deleted file mode 100644 index 7954ec8..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/transaction/TransactionRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.wbrawner.budget.common.transaction - -import com.wbrawner.budget.common.Repository -import java.util.* - -interface TransactionRepository : Repository { - suspend fun findAll( - budgetIds: List? = null, - categoryIds: List? = 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 -} \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/user/UserRepository.kt b/common/src/main/java/com/wbrawner/budget/common/user/UserRepository.kt deleted file mode 100644 index d3fe6b5..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/user/UserRepository.kt +++ /dev/null @@ -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 { - val currentUser: SharedFlow - suspend fun login(server: String, username: String, password: String): Session - suspend fun getProfile(): User - suspend fun findAll(budgetId: String? = null): List - suspend fun findAllByNameLike(query: String): List -} \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/util/ErrorHandler.kt b/common/src/main/java/com/wbrawner/budget/common/util/ErrorHandler.kt deleted file mode 100644 index 8318a6c..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/util/ErrorHandler.kt +++ /dev/null @@ -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) -} diff --git a/common/src/main/java/com/wbrawner/budget/common/util/Utils.kt b/common/src/main/java/com/wbrawner/budget/common/util/Utils.kt deleted file mode 100644 index eb4a4c3..0000000 --- a/common/src/main/java/com/wbrawner/budget/common/util/Utils.kt +++ /dev/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() -} \ No newline at end of file diff --git a/common/src/test/java/com/wbrawner/budget/common/ExampleUnitTest.java b/common/src/test/java/com/wbrawner/budget/common/ExampleUnitTest.java deleted file mode 100644 index 8993f81..0000000 --- a/common/src/test/java/com/wbrawner/budget/common/ExampleUnitTest.java +++ /dev/null @@ -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 Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 8de5058..0ed1264 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,15 +1,16 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit +## For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html +# # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -android.enableJetifier=true -android.useAndroidX=true -org.gradle.jvmargs=-Xmx1536m +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true +#Thu Jul 21 13:20:25 MDT 2022 +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" +android.enableJetifier=true +android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4788bd9 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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"] + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 95f4cbc..b457f8f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 9392ebd..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':android', ':budgetlib', ':common', ':storage' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c3fc1a4 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ +enableFeaturePreview("VERSION_CATALOGS") +rootProject.name = "Twigs" +include(":android", ":shared") diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..961d00a --- /dev/null +++ b/shared/build.gradle.kts @@ -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") + } + } +} diff --git a/shared/proguard-rules.pro b/shared/proguard-rules.pro new file mode 100644 index 0000000..a071319 --- /dev/null +++ b/shared/proguard-rules.pro @@ -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 diff --git a/budgetlib/src/main/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml similarity index 59% rename from budgetlib/src/main/AndroidManifest.xml rename to shared/src/androidMain/AndroidManifest.xml index 8ef4598..479380f 100644 --- a/budgetlib/src/main/AndroidManifest.xml +++ b/shared/src/androidMain/AndroidManifest.xml @@ -1,5 +1,6 @@ + + package="com.wbrawner.pihelper.shared"> - + \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt new file mode 100644 index 0000000..56e79da --- /dev/null +++ b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt @@ -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 = listOf( + BudgetReducer(budgetRepository) + ), +) = Store(reducers) diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/ErrorHandler.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/ErrorHandler.kt new file mode 100644 index 0000000..9866ef5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/ErrorHandler.kt @@ -0,0 +1,5 @@ +package com.wbrawner.twigs.shared + +interface ErrorHandler { + fun reportException(t: Throwable, message: String? = null) +} diff --git a/common/src/main/java/com/wbrawner/budget/common/Repository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Repository.kt similarity index 59% rename from common/src/main/java/com/wbrawner/budget/common/Repository.kt rename to shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Repository.kt index ead7ead..84c17d1 100644 --- a/common/src/main/java/com/wbrawner/budget/common/Repository.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Repository.kt @@ -1,17 +1,16 @@ -package com.wbrawner.budget.common +package com.wbrawner.twigs.shared /** * Base interface for an entity repository that provides basic CRUD methods * * @param T The type of the object supported by this repository - * @param K The type of the primary identifier for the object supported by this repository */ -interface Repository { +interface Repository { suspend fun create(newItem: T): T suspend fun findAll(): List - suspend fun findById(id: K): T + suspend fun findById(id: String): T suspend fun update(updatedItem: T): T - suspend fun delete(id: K) + suspend fun delete(id: String) } interface Identifiable { diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt new file mode 100644 index 0000000..170df93 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt @@ -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? = null, + val selectedBudget: String? = null, + val editingBudget: Boolean = false, + val categories: List? = null, + val selectedCategory: String? = null, + val editingCategory: Boolean = false, + val transactions: List? = 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, + private val settings: Settings = Settings(), + initialState: State = State() +) : CoroutineScope by CoroutineScope(Dispatchers.Main) { + private val _state = MutableStateFlow(initialState) + val state: StateFlow = _state + private val _effects = MutableSharedFlow() + val effects: Flow = _effects + + init { + reducers.forEach { + it.dispatch = this::dispatch + } + } + + fun dispatch(action: Action) { + launch { + var state = _state.value + if (action is AsyncAction) { + reducers.filterIsInstance().forEach { + state = it.reduce(action, state) + } + } else { + reducers.forEach { + state = it.reduce(action, state) + } + } + _state.emit(state) + } + } + + companion object +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Utils.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Utils.kt new file mode 100644 index 0000000..08b78f7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Utils.kt @@ -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) + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/Budget.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/Budget.kt new file mode 100644 index 0000000..93dad9c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/Budget.kt @@ -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 = emptyList() +) + +data class NewBudgetRequest( + val name: String, + val description: String? = null, + val users: List +) { + constructor(budget: Budget) : this( + budget.name, + budget.description, + budget.users + ) +} diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt new file mode 100644 index 0000000..729ab5f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt @@ -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 = 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 = 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 = emptyList() + ) : BudgetAsyncAction + + data class UpdateBudgetAsync( + val id: String, + val name: String, + val description: String? = null, + val users: List = 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 + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt new file mode 100644 index 0000000..8c34931 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt @@ -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 { + 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 = 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 +} \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/category/Category.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/Category.kt similarity index 56% rename from common/src/main/java/com/wbrawner/budget/common/category/Category.kt rename to shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/Category.kt index 5773092..abc0133 100644 --- a/common/src/main/java/com/wbrawner/budget/common/category/Category.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/Category.kt @@ -1,19 +1,14 @@ -package com.wbrawner.budget.common.category +package com.wbrawner.twigs.shared.category -import com.wbrawner.budget.common.Identifiable import kotlinx.serialization.Serializable @Serializable data class Category( val budgetId: String, - override val id: String? = null, + val id: String? = null, val title: String, val description: String? = null, val amount: Long, val expense: Boolean = true, val archived: Boolean = false -): Identifiable { - override fun toString(): String { - return title - } -} +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt new file mode 100644 index 0000000..a35b9b4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt @@ -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 { + suspend fun findAll(budgetIds: Array? = null): List + suspend fun getBalance(id: String): Long +} \ No newline at end of file diff --git a/budgetlib/src/main/java/com/wbrawner/budget/lib/network/TwigsApiService.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt similarity index 64% rename from budgetlib/src/main/java/com/wbrawner/budget/lib/network/TwigsApiService.kt rename to shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt index 18ff7e0..adf2d73 100644 --- a/budgetlib/src/main/java/com/wbrawner/budget/lib/network/TwigsApiService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt @@ -1,15 +1,23 @@ -package com.wbrawner.budget.lib.network +package com.wbrawner.twigs.shared.network -import com.wbrawner.budget.common.Session -import com.wbrawner.budget.common.budget.Budget -import com.wbrawner.budget.common.category.Category -import com.wbrawner.budget.common.transaction.BalanceResponse -import com.wbrawner.budget.common.transaction.Transaction -import com.wbrawner.budget.common.user.LoginRequest -import com.wbrawner.budget.common.user.User -import com.wbrawner.budget.lib.repository.NewBudgetRequest +import com.wbrawner.twigs.shared.budget.Budget +import com.wbrawner.twigs.shared.budget.NewBudgetRequest +import com.wbrawner.twigs.shared.category.Category +import com.wbrawner.twigs.shared.transaction.BalanceResponse +import com.wbrawner.twigs.shared.transaction.Transaction +import com.wbrawner.twigs.shared.user.LoginRequest +import com.wbrawner.twigs.shared.user.Session +import com.wbrawner.twigs.shared.user.User +import io.ktor.client.* +import io.ktor.client.engine.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json -interface TwigsApiService { +const val BASE_PATH = "/api" + +interface APIService { var baseUrl: String? var authToken: String? @@ -96,4 +104,20 @@ interface TwigsApiService { ): User suspend fun deleteUser(id: String) -} \ No newline at end of file + + companion object +} + +fun HttpClientConfig.commonConfig() { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) + } + install(HttpTimeout) { + requestTimeoutMillis = 1000 + connectTimeoutMillis = 1000 + socketTimeoutMillis = 1000 + } +} diff --git a/budgetlib/src/main/java/com/wbrawner/budget/lib/network/KtorTwigsApiService.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt similarity index 76% rename from budgetlib/src/main/java/com/wbrawner/budget/lib/network/KtorTwigsApiService.kt rename to shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt index eb303a5..cf57428 100644 --- a/budgetlib/src/main/java/com/wbrawner/budget/lib/network/KtorTwigsApiService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt @@ -1,38 +1,23 @@ -package com.wbrawner.budget.lib.network +package com.wbrawner.twigs.shared.network -import com.wbrawner.budget.common.Session -import com.wbrawner.budget.common.budget.Budget -import com.wbrawner.budget.common.category.Category -import com.wbrawner.budget.common.transaction.BalanceResponse -import com.wbrawner.budget.common.transaction.Transaction -import com.wbrawner.budget.common.user.LoginRequest -import com.wbrawner.budget.common.user.User -import com.wbrawner.budget.lib.repository.NewBudgetRequest -import com.wbrawner.budgetlib.BuildConfig +import com.wbrawner.twigs.shared.budget.Budget +import com.wbrawner.twigs.shared.budget.NewBudgetRequest +import com.wbrawner.twigs.shared.category.Category +import com.wbrawner.twigs.shared.transaction.BalanceResponse +import com.wbrawner.twigs.shared.transaction.Transaction +import com.wbrawner.twigs.shared.user.LoginRequest +import com.wbrawner.twigs.shared.user.Session +import com.wbrawner.twigs.shared.user.User import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.features.json.* -import io.ktor.client.features.json.serializer.* -import io.ktor.client.features.logging.* +import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* -class KtorTwigsApiService( - override var baseUrl: String?, - override var authToken: String? -) : TwigsApiService { - private val client = HttpClient(CIO) { - install(JsonFeature) { - serializer = KotlinxSerializer() - - } - if (BuildConfig.DEBUG) { - install(Logging) { - logger = Logger.ANDROID - level = LogLevel.ALL - } - } - } +class KtorAPIService( + private val client: HttpClient +) : APIService { + override var baseUrl: String? = null + override var authToken: String? = null override suspend fun getBudgets(count: Int?, page: Int?): List = request("budgets") @@ -40,13 +25,13 @@ class KtorTwigsApiService( override suspend fun newBudget(budget: NewBudgetRequest): Budget = request( path = "budgets", - httpBody = budget, + body = budget, httpMethod = HttpMethod.Post ) override suspend fun updateBudget(id: String, budget: Budget): Budget = request( path = "budgets/$id", - httpBody = budget, + body = budget, httpMethod = HttpMethod.Put ) @@ -74,13 +59,13 @@ class KtorTwigsApiService( override suspend fun newCategory(category: Category): Category = request( path = "categories", - httpBody = category, + body = category, httpMethod = HttpMethod.Post ) override suspend fun updateCategory(id: String, category: Category): Category = request( path = "categories/$id", - httpBody = category, + body = category, httpMethod = HttpMethod.Put ) @@ -121,14 +106,14 @@ class KtorTwigsApiService( override suspend fun newTransaction(transaction: Transaction): Transaction = request( path = "transactions", - httpBody = transaction, + body = transaction, httpMethod = HttpMethod.Post ) override suspend fun updateTransaction(id: String, transaction: Transaction): Transaction = request( path = "transactions/$id", - httpBody = transaction, + body = transaction, httpMethod = HttpMethod.Put ) @@ -148,7 +133,7 @@ class KtorTwigsApiService( override suspend fun login(request: LoginRequest): Session = request( path = "users/login", - httpBody = request, + body = request, httpMethod = HttpMethod.Post ) @@ -165,13 +150,13 @@ class KtorTwigsApiService( override suspend fun newUser(user: User): User = request( path = "users", - httpBody = user, + body = user, httpMethod = HttpMethod.Post, ) override suspend fun updateUser(id: String, user: User): User = request( path = "users/$id", - httpBody = user, + body = user, httpMethod = HttpMethod.Put, ) @@ -183,9 +168,9 @@ class KtorTwigsApiService( private suspend inline fun request( path: String, queryParams: List>? = null, - httpBody: Any? = null, + body: Any? = null, httpMethod: HttpMethod = HttpMethod.Get - ): T = client.request(URLBuilder(baseUrl!!).path("api/$path").build()) { + ): T = client.request(URLBuilder(baseUrl!!).apply { path("api/$path") }.buildString()) { method = httpMethod headers { authToken?.let { append(HttpHeaders.Authorization, "Bearer $it") } @@ -199,9 +184,9 @@ class KtorTwigsApiService( } } } - httpBody?.let { + body?.let { contentType(ContentType.Application.Json) - body = it + setBody(it) } - } + }.body() } \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/transaction/Transaction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/Transaction.kt similarity index 63% rename from common/src/main/java/com/wbrawner/budget/common/transaction/Transaction.kt rename to shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/Transaction.kt index e9d0009..17e1787 100644 --- a/common/src/main/java/com/wbrawner/budget/common/transaction/Transaction.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/Transaction.kt @@ -1,6 +1,6 @@ -package com.wbrawner.budget.common.transaction +package com.wbrawner.twigs.shared.transaction -import com.wbrawner.budget.common.Identifiable +import kotlinx.datetime.Instant import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind @@ -8,28 +8,26 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import java.time.Instant -import java.util.* @Serializable data class Transaction( - override val id: String? = null, + val id: String? = null, val title: String, @Serializable(with = DateSerializer::class) - val date: Date, + val date: Instant, val description: String? = null, val amount: Long, val categoryId: String? = null, val budgetId: String, val expense: Boolean, val createdBy: String -): Identifiable +) @Serializable data class BalanceResponse(val balance: Long) -object DateSerializer : KSerializer { +object DateSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: Date) = encoder.encodeString(value.toInstant().toString()) - override fun deserialize(decoder: Decoder): Date = Date.from(Instant.parse(decoder.decodeString())) + override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString()) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt new file mode 100644 index 0000000..8c81615 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt @@ -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 { + suspend fun findAll( + budgetIds: List? = null, + categoryIds: List? = null, + start: Instant? = startOfMonth(), + end: Instant? = endOfMonth() + ): List +} \ No newline at end of file diff --git a/common/src/main/java/com/wbrawner/budget/common/user/User.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/User.kt similarity index 66% rename from common/src/main/java/com/wbrawner/budget/common/user/User.kt rename to shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/User.kt index 7116ff5..227963e 100644 --- a/common/src/main/java/com/wbrawner/budget/common/user/User.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/User.kt @@ -1,17 +1,15 @@ -package com.wbrawner.budget.common.user +package com.wbrawner.twigs.shared.user -import com.wbrawner.budget.common.Identifiable +import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @Serializable data class User( - override val id: String? = null, + val id: String? = null, val username: String, val email: String? = null, val avatar: String? = null -) : Identifiable { - override fun toString(): String = username -} +) @Serializable data class UserPermission( @@ -30,4 +28,11 @@ enum class Permission { data class LoginRequest( val username: String, val password: String +) + +@Serializable +data class Session( + val userId: String, + val token: String, + val expiration: Instant ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserAction.kt new file mode 100644 index 0000000..a974a10 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserAction.kt @@ -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 { + +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserRepository.kt new file mode 100644 index 0000000..19bcc27 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserRepository.kt @@ -0,0 +1,10 @@ +package com.wbrawner.twigs.shared.user + +import com.wbrawner.twigs.shared.Repository + +interface UserRepository : Repository { + suspend fun login(username: String, password: String): User + suspend fun getProfile(): User + suspend fun findAll(budgetId: String? = null): List + suspend fun findAllByNameLike(query: String): List +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/BudgetReducerTests.kt b/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/BudgetReducerTests.kt new file mode 100644 index 0000000..4db4794 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/BudgetReducerTests.kt @@ -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 + 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) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/FakeBudgetRepository.kt b/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/FakeBudgetRepository.kt new file mode 100644 index 0000000..e7e1ebc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/FakeBudgetRepository.kt @@ -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() + + 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 = 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 } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/UtilsTests.kt b/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/UtilsTests.kt new file mode 100644 index 0000000..78e6c62 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/wbrawner/twigs/shared/UtilsTests.kt @@ -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() + ) + } + } +} \ No newline at end of file diff --git a/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt b/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt new file mode 100644 index 0000000..0195779 --- /dev/null +++ b/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt @@ -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 internal constructor(private val origin: Flow) : Flow 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 Flow.watch(block: (T) -> Unit): Closeable = + CloseableFlow(this).watch(block) \ No newline at end of file diff --git a/storage/.gitignore b/storage/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/storage/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/storage/build.gradle b/storage/build.gradle deleted file mode 100644 index 33effcf..0000000 --- a/storage/build.gradle +++ /dev/null @@ -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" -} diff --git a/storage/proguard-rules.pro b/storage/proguard-rules.pro deleted file mode 100644 index f1b4245..0000000 --- a/storage/proguard-rules.pro +++ /dev/null @@ -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 diff --git a/storage/src/androidTest/java/com/wbrawner/auth/ExampleInstrumentedTest.java b/storage/src/androidTest/java/com/wbrawner/auth/ExampleInstrumentedTest.java deleted file mode 100644 index ccdd46a..0000000 --- a/storage/src/androidTest/java/com/wbrawner/auth/ExampleInstrumentedTest.java +++ /dev/null @@ -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 Testing documentation - */ -@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()); - } -} diff --git a/storage/src/main/AndroidManifest.xml b/storage/src/main/AndroidManifest.xml deleted file mode 100644 index 4a4ddaa..0000000 --- a/storage/src/main/AndroidManifest.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/storage/src/main/java/com/wbrawner/budget/storage/StorageModule.kt b/storage/src/main/java/com/wbrawner/budget/storage/StorageModule.kt deleted file mode 100644 index 7b0186f..0000000 --- a/storage/src/main/java/com/wbrawner/budget/storage/StorageModule.kt +++ /dev/null @@ -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 - ) -} \ No newline at end of file diff --git a/storage/src/main/res/values/strings.xml b/storage/src/main/res/values/strings.xml deleted file mode 100644 index 637ea40..0000000 --- a/storage/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Auth - diff --git a/storage/src/test/java/com/wbrawner/auth/ExampleUnitTest.java b/storage/src/test/java/com/wbrawner/auth/ExampleUnitTest.java deleted file mode 100644 index 535e7d1..0000000 --- a/storage/src/test/java/com/wbrawner/auth/ExampleUnitTest.java +++ /dev/null @@ -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 Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file