WIP: Migrate to Kotlin Multiplatform

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

View file

@ -7,7 +7,7 @@ plugins {
}
android {
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"
}

View file

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

View file

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

View file

@ -67,6 +67,8 @@ class AuthViewModel @Inject constructor(
val correctServer = with(server.value) {
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 {

View file

@ -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
@ -89,6 +92,7 @@ fun LoginForm(
modifier = Modifier.fillMaxWidth(),
value = server,
onValueChange = setServer,
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, capitalization = KeyboardCapitalization.None),
placeholder = { Text("Server") }
)
TextField(

View file

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

View file

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

View file

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

View file

@ -20,6 +20,8 @@ import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.transactions.toLong
import 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,

View file

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

View file

@ -45,6 +45,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.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))
}
}

View file

@ -39,6 +39,17 @@
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_description"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_description"
style="@style/AppTheme.EditText"
android:inputType="textCapSentences" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_amount"
style="@style/AppTheme.EditText.Container"

View file

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

View file

@ -1 +0,0 @@
/build

View file

@ -1,46 +0,0 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdkVersion 31
defaultConfig {
minSdkVersion 26
targetSdkVersion 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation project(':common')
implementation project(':storage')
api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
def ktor_version = "1.6.0"
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-serialization:$ktor_version"
implementation "io.ktor:ktor-client-logging:$ktor_version"
implementation "ch.qos.logback:logback-classic:1.2.5"
// Dagger
implementation "com.google.dagger:hilt-android:$rootProject.ext.dagger"
kapt "com.google.dagger:hilt-compiler:$rootProject.ext.dagger"
testImplementation 'junit:junit:4.13.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}
repositories {
mavenCentral()
}

View file

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class title to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file title.
#-renamesourcefileattribute SourceFile

View file

@ -1,27 +0,0 @@
package com.wbrawner.budgetlib;
import android.content.Context;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("com.wbrawner.budgetlib.test", appContext.getPackageName());
}
}

View file

@ -1,57 +0,0 @@
package com.wbrawner.budget.lib.network
import android.content.SharedPreferences
import com.wbrawner.budget.common.PREF_KEY_TOKEN
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.lib.repository.NetworkBudgetRepository
import com.wbrawner.budget.lib.repository.NetworkCategoryRepository
import com.wbrawner.budget.lib.repository.NetworkTransactionRepository
import com.wbrawner.budget.lib.repository.NetworkUserRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
const val PREF_KEY_BASE_URL = "baseUrl"
@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
@Singleton
@Provides
fun provideApiService(sharedPreferences: SharedPreferences): TwigsApiService =
KtorTwigsApiService(
sharedPreferences.getString(PREF_KEY_BASE_URL, null),
sharedPreferences.getString(PREF_KEY_TOKEN, null)
)
@Singleton
@Provides
fun provideBudgetRepository(
apiService: TwigsApiService,
sharedPreferences: SharedPreferences
): BudgetRepository =
NetworkBudgetRepository(apiService, sharedPreferences)
@Singleton
@Provides
fun provideCategoryRepository(apiService: TwigsApiService): CategoryRepository =
NetworkCategoryRepository(apiService)
@Singleton
@Provides
fun provideTransactionRepository(apiService: TwigsApiService): TransactionRepository =
NetworkTransactionRepository(apiService)
@Singleton
@Provides
fun provideUserRepository(
apiService: TwigsApiService,
sharedPreferences: SharedPreferences
): UserRepository =
NetworkUserRepository(apiService, sharedPreferences)
}

View file

@ -1,109 +0,0 @@
package com.wbrawner.budget.lib.repository
import android.content.SharedPreferences
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.UserPermission
import com.wbrawner.budget.lib.network.TwigsApiService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
const val KEY_DEFAULT_BUDGET = "defaultBudget"
class NetworkBudgetRepository(
private val apiService: TwigsApiService,
private val sharedPreferences: SharedPreferences
) : BudgetRepository {
private val mutex = Mutex()
private val budgets: MutableSet<Budget> = mutableSetOf()
private val current = MutableStateFlow<Budget?>(null)
override val currentBudget: SharedFlow<Budget?> = current.asSharedFlow()
init {
currentBudget.onEach { budget ->
sharedPreferences.edit().apply {
budget?.id?.let {
putString(KEY_DEFAULT_BUDGET, it)
} ?: remove(KEY_DEFAULT_BUDGET)
apply()
}
}
}
override suspend fun prefetchData() {
val budgets = try {
findAll()
} catch (e: Exception) {
emptyList()
}
if (budgets.isEmpty()) return
val budgetId = sharedPreferences.getString(KEY_DEFAULT_BUDGET, budgets.first().id)!!
val budget = try {
findById(budgetId)
} catch (e: Exception) {
// For some reason we can't find the default budget id, so fallback to the first budget
budgets.first()
}
current.emit(budget)
}
override suspend fun create(newItem: Budget): Budget =
apiService.newBudget(NewBudgetRequest(newItem)).apply {
mutex.withLock {
budgets.add(this)
}
}
override suspend fun findAll(): List<Budget> = mutex.withLock {
if (budgets.size > 0) budgets.toList() else null
} ?: apiService.getBudgets().sortedBy { it.name }.apply {
mutex.withLock {
budgets.addAll(this)
}
}
override suspend fun findById(id: String, setCurrent: Boolean): Budget = (mutex.withLock {
budgets.firstOrNull { it.id == id }
} ?: apiService.getBudget(id).apply {
mutex.withLock {
budgets.add(this)
}
}).apply {
if (setCurrent) {
current.emit(this)
}
}
override suspend fun update(updatedItem: Budget): Budget =
apiService.updateBudget(updatedItem.id!!, updatedItem).apply {
mutex.withLock {
budgets.removeAll { it.id == this.id }
budgets.add(this)
}
}
override suspend fun delete(id: String) = apiService.deleteBudget(id).apply {
mutex.withLock {
budgets.removeAll { it.id == id }
}
}
override suspend fun getBalance(id: String): Long =
apiService.sumTransactions(budgetId = id).balance
}
data class NewBudgetRequest(
val name: String,
val description: String? = null,
val users: List<UserPermission>
) {
constructor(budget: Budget) : this(
budget.name,
budget.description,
budget.users
)
}

View file

@ -1,24 +0,0 @@
package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.lib.network.TwigsApiService
import javax.inject.Inject
class NetworkCategoryRepository @Inject constructor(private val apiService: TwigsApiService) : CategoryRepository {
override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem)
override suspend fun findAll(budgetIds: Array<String>?): List<Category> = apiService.getCategories(budgetIds).sortedBy { it.title }
override suspend fun findAll(): List<Category> = findAll(null)
override suspend fun findById(id: String): Category = apiService.getCategory(id)
override suspend fun update(updatedItem: Category): Category =
apiService.updateCategory(updatedItem.id!!, updatedItem)
override suspend fun delete(id: String) = apiService.deleteCategory(id)
override suspend fun getBalance(id: String): Long =
apiService.sumTransactions(categoryId = id).balance
}

View file

@ -1,35 +0,0 @@
package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.lib.network.TwigsApiService
import java.util.*
import javax.inject.Inject
class NetworkTransactionRepository @Inject constructor(private val apiService: TwigsApiService) :
TransactionRepository {
override suspend fun create(newItem: Transaction): Transaction =
apiService.newTransaction(newItem)
override suspend fun findAll(
budgetIds: List<String>?,
categoryIds: List<String>?,
start: Calendar?,
end: Calendar?
): List<Transaction> = apiService.getTransactions(
budgetIds,
categoryIds,
start?.toInstant()?.toString(),
end?.toInstant()?.toString()
)
override suspend fun findAll(): List<Transaction> = findAll(null)
override suspend fun findById(id: String): Transaction = apiService.getTransaction(id)
override suspend fun update(updatedItem: Transaction): Transaction =
apiService.updateTransaction(updatedItem.id!!, updatedItem)
override suspend fun delete(id: String) = apiService.deleteTransaction(id)
}

View file

@ -1,53 +0,0 @@
package com.wbrawner.budget.lib.repository
import android.content.SharedPreferences
import com.wbrawner.budget.common.PREF_KEY_USER_ID
import com.wbrawner.budget.common.Session
import com.wbrawner.budget.common.user.LoginRequest
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.lib.network.TwigsApiService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
class NetworkUserRepository @Inject constructor(
private val apiService: TwigsApiService,
private val sharedPreferences: SharedPreferences
) : UserRepository {
private val current = MutableStateFlow<User?>(null)
override val currentUser: SharedFlow<User?> = current.asSharedFlow()
override suspend fun login(server: String, username: String, password: String): Session {
apiService.baseUrl = server
return apiService.login(LoginRequest(username, password)).apply {
apiService.authToken = token
}
}
override suspend fun getProfile(): User {
val userId = sharedPreferences.getString(PREF_KEY_USER_ID, null)
?: throw RuntimeException("Not authenticated")
return apiService.getUser(userId).also {
current.emit(it)
}
}
override suspend fun create(newItem: User): User = apiService.newUser(newItem)
override suspend fun findAll(budgetId: String?): List<User> = apiService.getUsers(budgetId)
override suspend fun findAll(): List<User> = findAll(null)
override suspend fun findById(id: String): User = apiService.getUser(id)
override suspend fun findAllByNameLike(query: String): List<User> =
apiService.searchUsers(query)
override suspend fun update(updatedItem: User): User =
apiService.updateUser(updatedItem.id!!, updatedItem)
override suspend fun delete(id: String) = apiService.deleteUser(id)
}

View file

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

View file

@ -1,17 +0,0 @@
package com.wbrawner.budgetlib;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

View file

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

1
common/.gitignore vendored
View file

@ -1 +0,0 @@
/build

View file

@ -1,41 +0,0 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.5.20'
}
android {
compileSdkVersion 31
defaultConfig {
minSdkVersion 26
targetSdkVersion 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1'
api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2"
testImplementation 'junit:junit:4.13.1'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}
repositories {
mavenCentral()
}

View file

@ -1,41 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class title to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file title.
#-renamesourcefileattribute SourceFile
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Change here com.yourcompany.yourpackage
-keep,includedescriptorclasses class com.yourcompany.yourpackage.**$$serializer { *; } # <-- change package name to your app's
-keepclassmembers class com.yourcompany.yourpackage.** { # <-- change package name to your app's
*** Companion;
}
-keepclasseswithmembers class com.yourcompany.yourpackage.** { # <-- change package name to your app's
kotlinx.serialization.KSerializer serializer(...);
}

View file

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

View file

@ -1,16 +0,0 @@
package com.wbrawner.budget.common
import com.wbrawner.budget.common.transaction.DateSerializer
import kotlinx.serialization.Serializable
import java.util.*
const val PREF_KEY_USER_ID = "userId"
const val PREF_KEY_TOKEN = "sessionToken"
@Serializable
data class Session(
val userId: String,
val token: String,
@Serializable(with = DateSerializer::class)
val expiration: Date
)

View file

@ -1,17 +0,0 @@
package com.wbrawner.budget.common.budget
import com.wbrawner.budget.common.Identifiable
import com.wbrawner.budget.common.user.UserPermission
import kotlinx.serialization.Serializable
@Serializable
data class Budget(
override val id: String? = null,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : Identifiable {
override fun toString(): String {
return name
}
}

View file

@ -1,12 +0,0 @@
package com.wbrawner.budget.common.budget
import com.wbrawner.budget.common.Repository
import kotlinx.coroutines.flow.SharedFlow
interface BudgetRepository : Repository<Budget, String> {
val currentBudget: SharedFlow<Budget?>
override suspend fun findById(id: String): Budget = findById(id, false)
suspend fun findById(id: String, setCurrent: Boolean = false): Budget
suspend fun prefetchData()
suspend fun getBalance(id: String): Long
}

View file

@ -1,8 +0,0 @@
package com.wbrawner.budget.common.category
import com.wbrawner.budget.common.Repository
interface CategoryRepository : Repository<Category, String> {
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
suspend fun getBalance(id: String): Long
}

View file

@ -1,25 +0,0 @@
package com.wbrawner.budget.common.transaction
import com.wbrawner.budget.common.Repository
import java.util.*
interface TransactionRepository : Repository<Transaction, String> {
suspend fun findAll(
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
start: Calendar? = GregorianCalendar.getInstance().apply {
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
},
end: Calendar? = GregorianCalendar.getInstance().apply {
set(Calendar.DAY_OF_MONTH, getActualMaximum(Calendar.DAY_OF_MONTH))
set(Calendar.HOUR_OF_DAY, getActualMaximum(Calendar.HOUR_OF_DAY))
set(Calendar.MINUTE, getActualMaximum(Calendar.MINUTE))
set(Calendar.SECOND, getActualMaximum(Calendar.SECOND))
set(Calendar.MILLISECOND, getActualMaximum(Calendar.MILLISECOND))
}
): List<Transaction>
}

View file

@ -1,13 +0,0 @@
package com.wbrawner.budget.common.user
import com.wbrawner.budget.common.Repository
import com.wbrawner.budget.common.Session
import kotlinx.coroutines.flow.SharedFlow
interface UserRepository : Repository<User, String> {
val currentUser: SharedFlow<User?>
suspend fun login(server: String, username: String, password: String): Session
suspend fun getProfile(): User
suspend fun findAll(budgetId: String? = null): List<User>
suspend fun findAllByNameLike(query: String): List<User>
}

View file

@ -1,8 +0,0 @@
package com.wbrawner.budget.common.util
import android.app.Application
interface ErrorHandler {
fun init(application: Application)
fun reportException(t: Throwable, message: String? = null)
}

View file

@ -1,11 +0,0 @@
package com.wbrawner.budget.common.util
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
fun randomId(): String {
val id = StringBuilder()
repeat(32) {
id.append(CHARACTERS.random())
}
return id.toString()
}

View file

@ -1,17 +0,0 @@
package com.wbrawner.budget.common;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

View file

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

67
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,67 @@
[versions]
androidx-core = "1.9.0"
androidx-appcompat = "1.5.1"
androidx-splash = "1.0.0"
compose = "1.2.1"
compose-compiler = "1.3.2"
espresso = "3.3.0"
hilt-android = "2.44"
kotlin = "1.7.20"
kotlinx-serialization = "1.4.1"
kotlinx-coroutines = "1.6.4"
kotlinx-datetime = "0.4.0"
ktor = "2.1.2"
material = "1.3.0"
maxSdk = "33"
minSdk = "23"
navigation = "2.4.1"
okhttp = "4.2.2"
settings = "0.8.1"
versionCode = "1"
versionName = "1.0"
[libraries]
android-gradle = { module = "com.android.tools.build:gradle", version = "7.3.1" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
material = { module = "com.google.android.material:material", version.ref = "material" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
[bundles]
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]

View file

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

View file

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

3
settings.gradle.kts Normal file
View file

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

1
shared/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

70
shared/build.gradle.kts Normal file
View file

@ -0,0 +1,70 @@
plugins {
kotlin("multiplatform")
id("com.android.library")
kotlin("plugin.serialization")
}
kotlin {
android()
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Twigs"
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
api(libs.multiplatform.settings)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val androidMain by getting {
dependencies {
implementation(libs.ktor.client.android)
}
}
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
dependencies {
implementation(libs.ktor.client.ios)
}
}
}
}
android {
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
compileSdk = libs.versions.maxSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
buildTypes {
release {
consumerProguardFiles("proguard-rules.pro")
}
}
}

26
shared/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,26 @@
-keepattributes SourceFile,LineNumberTable
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.wbrawner.budgetlib">
package="com.wbrawner.pihelper.shared">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View file

@ -0,0 +1,22 @@
package com.wbrawner.pihelper.shared
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetReducer
import com.wbrawner.twigs.shared.budget.NetworkBudgetRepository
import com.wbrawner.twigs.shared.network.KtorAPIService
import com.wbrawner.twigs.shared.network.commonConfig
import io.ktor.client.*
import io.ktor.client.engine.android.*
val apiService = KtorAPIService(HttpClient(Android) {
commonConfig()
})
val budgetRepository = NetworkBudgetRepository(apiService)
fun Store.Companion.create(
reducers: List<Reducer> = listOf(
BudgetReducer(budgetRepository)
),
) = Store(reducers)

View file

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

View file

@ -1,17 +1,16 @@
package com.wbrawner.budget.common
package com.wbrawner.twigs.shared
/**
* Base interface for an entity repository that provides basic CRUD methods
*
* @param T The type of the object supported by this repository
* @param K The type of the primary identifier for the object supported by this repository
*/
interface Repository<T, K> {
interface Repository<T> {
suspend fun create(newItem: T): T
suspend fun findAll(): List<T>
suspend fun findById(id: K): T
suspend fun findById(id: String): T
suspend fun update(updatedItem: T): T
suspend fun delete(id: K)
suspend fun delete(id: String)
}
interface Identifiable {

View file

@ -0,0 +1,103 @@
package com.wbrawner.twigs.shared
import com.russhwolf.settings.Settings
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.network.APIService
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.User
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class Route(val path: String) {
WELCOME("welcome"),
LOGIN("login"),
REGISTER("register"),
OVERVIEW("overview"),
TRANSACTIONS("transactions"),
CATEGORIES("categories"),
RECURRING_TRANSACTIONS("recurringtransactions"),
PROFILE("profile"),
SETTINGS("settings"),
ABOUT("about"),
}
data class State(
val host: String? = null,
val user: User? = null,
val budgets: List<Budget>? = null,
val selectedBudget: String? = null,
val editingBudget: Boolean = false,
val categories: List<Category>? = null,
val selectedCategory: String? = null,
val editingCategory: Boolean = false,
val transactions: List<Transaction>? = null,
val selectedTransaction: String? = null,
val editingTransaction: Boolean = false,
val loading: Boolean = false,
val route: Route = Route.WELCOME,
val initialRoute: Route = Route.WELCOME
)
interface Action {
object About : Action
object Back : Action
}
interface AsyncAction : Action
interface Effect {
object Exit : Effect
object Empty: Effect
}
abstract class Reducer {
lateinit var dispatch: (Action) -> Unit
lateinit var emit: (Effect) -> Unit
abstract fun reduce(action: Action, state: State): State
}
abstract class AsyncReducer : Reducer() {
abstract suspend fun reduce(action: AsyncAction, state: State): State
}
const val KEY_HOST = "baseUrl"
class Store(
private val reducers: List<Reducer>,
private val settings: Settings = Settings(),
initialState: State = State()
) : CoroutineScope by CoroutineScope(Dispatchers.Main) {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<State> = _state
private val _effects = MutableSharedFlow<Effect>()
val effects: Flow<Effect> = _effects
init {
reducers.forEach {
it.dispatch = this::dispatch
}
}
fun dispatch(action: Action) {
launch {
var state = _state.value
if (action is AsyncAction) {
reducers.filterIsInstance<AsyncReducer>().forEach {
state = it.reduce(action, state)
}
} else {
reducers.forEach {
state = it.reduce(action, state)
}
}
_state.emit(state)
}
}
companion object
}

View file

@ -0,0 +1,31 @@
package com.wbrawner.twigs.shared
import kotlinx.datetime.*
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
fun randomId(): String {
val id = StringBuilder()
repeat(32) {
id.append(CHARACTERS.random())
}
return id.toString()
}
fun startOfMonth(now: Instant = Clock.System.now()): Instant =
with(now.toLocalDateTime(TimeZone.UTC)) {
LocalDateTime(year, month, 1, 0, 0, 0)
.toInstant(TimeZone.UTC)
}
fun endOfMonth(now: Instant = Clock.System.now()): Instant =
with(now.toLocalDateTime(TimeZone.UTC)) {
val (adjustedYear, adjustedMonth) = if (monthNumber == 12) {
year + 1 to 1
} else {
year to monthNumber + 1
}
LocalDateTime(adjustedYear, adjustedMonth, 1, 23, 59, 59)
.toInstant(TimeZone.UTC)
.minus(1, DateTimeUnit.DAY, TimeZone.UTC)
}

View file

@ -0,0 +1,24 @@
package com.wbrawner.twigs.shared.budget
import com.wbrawner.twigs.shared.user.UserPermission
import kotlinx.serialization.Serializable
@Serializable
data class Budget(
val id: String? = null,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
)
data class NewBudgetRequest(
val name: String,
val description: String? = null,
val users: List<UserPermission>
) {
constructor(budget: Budget) : this(
budget.name,
budget.description,
budget.users
)
}

View file

@ -0,0 +1,121 @@
package com.wbrawner.twigs.shared.budget
import com.wbrawner.twigs.shared.*
import com.wbrawner.twigs.shared.user.UserAction
import com.wbrawner.twigs.shared.user.UserPermission
sealed interface BudgetAction : Action {
data class CreateBudget(
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAction {
fun async() = BudgetAsyncAction.CreateBudgetAsync(name, description, users)
}
data class EditBudget(val id: String) : BudgetAction
data class SelectBudget(val id: String) : BudgetAction
data class UpdateBudget(
val id: String,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAction {
fun async() = BudgetAsyncAction.UpdateBudgetAsync(id, name, description, users)
}
data class DeleteBudget(val id: String) : BudgetAction {
fun async() = BudgetAsyncAction.DeleteBudgetAsync(id)
}
}
sealed interface BudgetAsyncAction : AsyncAction {
data class CreateBudgetAsync(
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAsyncAction
data class UpdateBudgetAsync(
val id: String,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAsyncAction
data class DeleteBudgetAsync(val id: String) : BudgetAsyncAction
}
class BudgetReducer(private val budgetRepository: BudgetRepository) : AsyncReducer() {
override fun reduce(action: Action, state: State): State = when (action) {
is Action.Back -> state.copy(
editingBudget = false,
selectedBudget = if (state.editingBudget) state.selectedBudget else null
)
is BudgetAction.CreateBudget -> state.copy(loading = true).also {
dispatch(action.async())
}
is BudgetAction.EditBudget -> state.copy(
editingBudget = true,
selectedBudget = action.id
)
is BudgetAction.SelectBudget -> state.copy(
selectedBudget = action.id
)
is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
dispatch(action.async())
}
is BudgetAction.DeleteBudget -> state.copy(loading = true).also {
dispatch(action.async())
}
is UserAction.Logout -> state.copy(
editingBudget = false,
selectedBudget = null,
budgets = null
)
else -> state
}
override suspend fun reduce(action: AsyncAction, state: State): State = when (action) {
is BudgetAsyncAction.CreateBudgetAsync -> {
val budget = budgetRepository.create(
Budget(name = action.name, description = action.description, users = action.users)
)
val budgets = state.budgets?.toMutableList() ?: mutableListOf()
budgets.add(budget)
budgets.sortBy { it.name }
state.copy(
loading = false,
budgets = budgets.toList(),
selectedBudget = budget.id
)
}
is BudgetAsyncAction.UpdateBudgetAsync -> {
budgetRepository.update(
Budget(
id = action.id,
name = action.name,
description = action.description,
users = action.users
)
)
state.copy(
loading = false,
editingBudget = false,
)
}
is BudgetAsyncAction.DeleteBudgetAsync -> {
budgetRepository.delete(action.id)
val budgets = state.budgets?.filterNot { it.id == action.id }
state.copy(
loading = false,
budgets = budgets,
editingBudget = false,
selectedBudget = null
)
}
else -> state
}
}

View file

@ -0,0 +1,24 @@
package com.wbrawner.twigs.shared.budget
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.network.APIService
interface BudgetRepository : Repository<Budget> {
suspend fun getBalance(id: String): Long
}
class NetworkBudgetRepository(private val apiService: APIService) : BudgetRepository {
override suspend fun create(newItem: Budget): Budget = apiService.newBudget(NewBudgetRequest(newItem))
override suspend fun findAll(): List<Budget> = apiService.getBudgets()
override suspend fun findById(id: String): Budget = apiService.getBudget(id)
override suspend fun update(updatedItem: Budget): Budget =
apiService.updateBudget(updatedItem.id!!, updatedItem)
override suspend fun delete(id: String) = apiService.deleteBudget(id)
override suspend fun getBalance(id: String): Long =
apiService.sumTransactions(budgetId = id).balance
}

View file

@ -1,19 +1,14 @@
package com.wbrawner.budget.common.category
package com.wbrawner.twigs.shared.category
import com.wbrawner.budget.common.Identifiable
import kotlinx.serialization.Serializable
@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
}
}
)

View file

@ -0,0 +1,9 @@
package com.wbrawner.twigs.shared.category
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.category.Category
interface CategoryRepository : Repository<Category> {
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
suspend fun getBalance(id: String): Long
}

View file

@ -1,15 +1,23 @@
package com.wbrawner.budget.lib.network
package com.wbrawner.twigs.shared.network
import com.wbrawner.budget.common.Session
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.transaction.BalanceResponse
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.user.LoginRequest
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.lib.repository.NewBudgetRequest
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.budget.NewBudgetRequest
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.transaction.BalanceResponse
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.LoginRequest
import com.wbrawner.twigs.shared.user.Session
import com.wbrawner.twigs.shared.user.User
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
interface TwigsApiService {
const val BASE_PATH = "/api"
interface APIService {
var baseUrl: String?
var authToken: String?
@ -96,4 +104,20 @@ interface TwigsApiService {
): User
suspend fun deleteUser(id: String)
companion object
}
fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 1000
connectTimeoutMillis = 1000
socketTimeoutMillis = 1000
}
}

View file

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

View file

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

View file

@ -0,0 +1,15 @@
package com.wbrawner.twigs.shared.transaction
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.endOfMonth
import com.wbrawner.twigs.shared.startOfMonth
import kotlinx.datetime.*
interface TransactionRepository : Repository<Transaction> {
suspend fun findAll(
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
start: Instant? = startOfMonth(),
end: Instant? = endOfMonth()
): List<Transaction>
}

View file

@ -1,17 +1,15 @@
package com.wbrawner.budget.common.user
package com.wbrawner.twigs.shared.user
import com.wbrawner.budget.common.Identifiable
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class User(
override val id: String? = null,
val id: String? = null,
val username: String,
val email: String? = null,
val avatar: String? = null
) : Identifiable {
override fun toString(): String = username
}
)
@Serializable
data class UserPermission(
@ -31,3 +29,10 @@ data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class Session(
val userId: String,
val token: String,
val expiration: Instant
)

View file

@ -0,0 +1,13 @@
package com.wbrawner.twigs.shared.user
import com.wbrawner.twigs.shared.Action
sealed interface UserAction: Action {
data class Login(val username: String, val password: String): UserAction
data class Register(val username: String, val password: String, val confirmPassword: String): UserAction
object Logout: UserAction
}
sealed interface UserEffect {
}

View file

@ -0,0 +1,10 @@
package com.wbrawner.twigs.shared.user
import com.wbrawner.twigs.shared.Repository
interface UserRepository : Repository<User> {
suspend fun login(username: String, password: String): User
suspend fun getProfile(): User
suspend fun findAll(budgetId: String? = null): List<User>
suspend fun findAllByNameLike(query: String): List<User>
}

View file

@ -0,0 +1,54 @@
package com.wbrawner.twigs.shared
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.budget.BudgetReducer
import com.wbrawner.twigs.shared.budget.BudgetRepository
import com.wbrawner.twigs.shared.user.Permission
import com.wbrawner.twigs.shared.user.UserPermission
import kotlinx.datetime.toInstant
import kotlin.test.BeforeTest
import kotlin.test.DefaultAsserter.assertEquals
import kotlin.test.DefaultAsserter.assertTrue
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNull
class BudgetReducerTests {
lateinit var reducer: BudgetReducer
lateinit var dispatchedActions: MutableList<Action>
lateinit var budgetRepository: FakeBudgetRepository
@BeforeTest
fun setup() {
dispatchedActions = mutableListOf()
budgetRepository = FakeBudgetRepository()
reducer = BudgetReducer(budgetRepository)
reducer.dispatch = { dispatchedActions.add(it) }
}
@Test
fun goBackWhileEditingTest() {
val state = State(editingBudget = true, selectedBudget = "test")
val newState = reducer.reduce(Action.Back, state)
assertFalse(newState.editingBudget)
assertEquals("selectedBudget should still be set", "test", newState.selectedBudget)
}
@Test
fun goBackWhileViewingTest() {
val state = State(selectedBudget = "test")
val newState = reducer.reduce(Action.Back, state)
assertNull(newState.selectedBudget)
}
@Test
fun createBudgetTest() {
val state = State()
val users = listOf(UserPermission("user", Permission.OWNER))
assertFalse(state.loading)
val newState = reducer.reduce(BudgetAction.CreateBudget("test", "description", users), state)
assertTrue(state.loading)
assertNull(newState.selectedBudget)
}
}

View file

@ -0,0 +1,33 @@
package com.wbrawner.twigs.shared
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.budget.BudgetRepository
class FakeBudgetRepository: BudgetRepository {
val budgets = mutableListOf<Budget>()
override suspend fun getBalance(id: String): Long {
return 0
}
override suspend fun create(newItem: Budget): Budget {
val saved = newItem.copy(id = randomId())
budgets.add(saved)
return saved
}
override suspend fun findAll(): List<Budget> = budgets
override suspend fun findById(id: String): Budget = budgets.first { it.id == id }
override suspend fun update(updatedItem: Budget): Budget {
budgets.removeAll { it.id == updatedItem.id }
budgets.add(updatedItem)
budgets.sortBy { it.name }
return updatedItem
}
override suspend fun delete(id: String) {
budgets.removeAll { it.id == id }
}
}

View file

@ -0,0 +1,55 @@
package com.wbrawner.twigs.shared
import kotlinx.datetime.toInstant
import kotlin.test.DefaultAsserter.assertEquals
import kotlin.test.Test
class UtilsTests {
@Test
fun `startOfMonth returns the correct dates`() {
listOf(
"2022-01-01T00:00:00Z" to "2022-01-01T00:00:00Z",
"2022-02-01T00:00:00Z" to "2022-02-10T12:30:45Z",
"2022-03-01T00:00:00Z" to "2022-03-15T12:30:45Z",
"2022-04-01T00:00:00Z" to "2022-04-20T12:30:45Z",
"2022-05-01T00:00:00Z" to "2022-05-20T12:30:45Z",
"2022-06-01T00:00:00Z" to "2022-06-20T12:30:45Z",
"2022-07-01T00:00:00Z" to "2022-07-20T12:30:45Z",
"2022-08-01T00:00:00Z" to "2022-08-20T12:30:45Z",
"2022-09-01T00:00:00Z" to "2022-09-20T12:30:45Z",
"2022-10-01T00:00:00Z" to "2022-10-20T12:30:45Z",
"2022-11-01T00:00:00Z" to "2022-11-20T12:30:45Z",
"2022-12-01T00:00:00Z" to "2022-12-31T23:59:59Z"
).forEach { (expected, now) ->
assertEquals(
"Failed to set $now to start of month",
expected,
startOfMonth(now.toInstant()).toString()
)
}
}
@Test
fun `endOfMonth returns the correct dates`() {
listOf(
"2022-01-31T23:59:59Z" to "2022-01-01T00:00:00Z",
"2022-02-28T23:59:59Z" to "2022-02-10T12:30:45Z",
"2022-03-31T23:59:59Z" to "2022-03-15T12:30:45Z",
"2022-04-30T23:59:59Z" to "2022-04-20T12:30:45Z",
"2022-05-31T23:59:59Z" to "2022-05-20T12:30:45Z",
"2022-06-30T23:59:59Z" to "2022-06-20T12:30:45Z",
"2022-07-31T23:59:59Z" to "2022-07-20T12:30:45Z",
"2022-08-31T23:59:59Z" to "2022-08-20T12:30:45Z",
"2022-09-30T23:59:59Z" to "2022-09-20T12:30:45Z",
"2022-10-31T23:59:59Z" to "2022-10-20T12:30:45Z",
"2022-11-30T23:59:59Z" to "2022-11-20T12:30:45Z",
"2022-12-31T23:59:59Z" to "2022-12-31T23:59:59Z"
).forEach { (expected, now) ->
assertEquals(
"Failed to set $now to end of month",
expected,
endOfMonth(now.toInstant()).toString()
)
}
}
}

View file

@ -0,0 +1,46 @@
package com.wbrawner.pihelper.shared
import com.wbrawner.twigs.shared.*
import io.ktor.client.*
import io.ktor.client.engine.darwin.*
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import platform.CoreCrypto.CC_SHA256
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
//fun APIService.Companion.create() = KtorAPIService(HttpClient(Darwin) {
// commonConfig()
//})
//fun Store.Companion.create() = Store(PiholeAPIService.create())
fun Store.watchState(block: (State) -> Unit) = state.watch(block)
fun Store.watchEffects(block: (Effect) -> Unit) = effects.watch(block)
fun interface Closeable {
fun close()
}
class CloseableFlow<T : Any> internal constructor(private val origin: Flow<T>) : Flow<T> by origin {
fun watch(block: (T) -> Unit): Closeable {
val job = Job()
onEach {
block(it)
}.launchIn(CoroutineScope(Dispatchers.Main + job))
return Closeable { job.cancel() }
}
}
internal fun <T : Any> Flow<T>.watch(block: (T) -> Unit): Closeable =
CloseableFlow(this).watch(block)

1
storage/.gitignore vendored
View file

@ -1 +0,0 @@
/build

View file

@ -1,43 +0,0 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdkVersion 31
defaultConfig {
minSdkVersion 23
targetSdkVersion 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// Security
def security_version = "1.0.0-beta01"
implementation "androidx.security:security-crypto:$security_version"
implementation 'androidx.appcompat:appcompat:1.3.1'
// Dagger
implementation "com.google.dagger:hilt-android:$rootProject.ext.dagger"
kapt "com.google.dagger:hilt-compiler:$rootProject.ext.dagger"
testImplementation 'junit:junit:4.13.1'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

View file

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -1,27 +0,0 @@
package com.wbrawner.auth;
import android.content.Context;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("com.wbrawner.auth.test", appContext.getPackageName());
}
}

View file

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

View file

@ -1,28 +0,0 @@
package com.wbrawner.budget.storage
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
const val ENCRYPTED_SHARED_PREFS_FILE_NAME = "shared-prefs.encrypted"
@Module
@InstallIn(SingletonComponent::class)
class StorageModule {
@Provides
@Singleton
fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
EncryptedSharedPreferences.create(
ENCRYPTED_SHARED_PREFS_FILE_NAME,
"budget",
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}

View file

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

View file

@ -1,17 +0,0 @@
package com.wbrawner.auth;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}