diff --git a/android/build.gradle b/android/build.gradle
deleted file mode 100644
index 84b482a..0000000
--- a/android/build.gradle
+++ /dev/null
@@ -1,83 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'kotlin-android'
- id 'kotlin-android-extensions'
- id 'kotlin-kapt'
- id 'dagger.hilt.android.plugin'
-}
-
-android {
- compileSdkVersion 33
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = "1.8"
- }
- defaultConfig {
- applicationId "com.wbrawner.twigs"
- minSdkVersion 26
- targetSdkVersion 33
- versionCode 1
- versionName "1.0"
- vectorDrawables {
- useSupportLibrary = true
- }
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
- buildTypes {
- release {
- minifyEnabled true
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- }
- }
- sourceSets {
- androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
- }
- buildFeatures {
- compose true
- }
- composeOptions {
- kotlinCompilerExtensionVersion "1.3.2"
- }
-}
-
-dependencies {
- 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.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'
- 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"
- implementation "androidx.compose.material:material:$compose"
- implementation "androidx.compose.material:material-icons-core:$compose"
- implementation "androidx.compose.material:material-icons-extended:$compose"
- implementation "androidx.compose.animation:animation:$compose"
- 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/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dad1854
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,91 @@
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ id("kotlin-kapt")
+ id("dagger.hilt.android.plugin")
+}
+
+val keystoreProperties = Properties()
+try {
+ val keystorePropertiesFile = rootProject.file("keystore.properties")
+ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+} catch (ignored: FileNotFoundException) {
+ logger.warn("Unable to load keystore properties. Using debug signing configuration instead")
+ keystoreProperties["keyAlias"] = "androiddebugkey"
+ keystoreProperties["keyPassword"] = "android"
+ keystoreProperties["storeFile"] =
+ File(System.getProperty("user.home"), ".android/debug.keystore").absolutePath
+ keystoreProperties["storePassword"] = "android"
+}
+
+android {
+ compileSdk = libs.versions.maxSdk.get().toInt()
+ defaultConfig {
+ applicationId = "com.wbrawner.twigs"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.maxSdk.get().toInt()
+ versionCode = libs.versions.versionCode.get().toInt()
+ versionName = libs.versions.versionName.get()
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ signingConfig = signingConfigs["debug"]
+ }
+ signingConfigs {
+ create("release") {
+ keyAlias = keystoreProperties["keyAlias"].toString()
+ keyPassword = keystoreProperties["keyPassword"].toString()
+ storeFile = file(keystoreProperties["storeFile"].toString())
+ storePassword = keystoreProperties["storePassword"].toString()
+ }
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ signingConfig = signingConfigs["release"]
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
+ }
+}
+
+dependencies {
+ implementation(project(":shared"))
+ implementation(libs.bundles.coroutines)
+ implementation(libs.bundles.compose)
+ implementation(libs.hilt.android.core)
+ implementation(libs.hilt.navigation.compose)
+ kapt(libs.hilt.android.kapt)
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.splash)
+ implementation(libs.material)
+ implementation("androidx.legacy:legacy-support-v4:1.0.0")
+ implementation(libs.preference)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestUtil(libs.androidx.test.orchestrator)
+ androidTestImplementation(libs.test.ext)
+ androidTestImplementation(libs.espresso)
+ androidTestImplementation(libs.hilt.android.testing)
+ kaptAndroidTest(libs.hilt.android.kapt)
+ androidTestImplementation(libs.compose.test.junit)
+ debugImplementation(libs.compose.test.manifest)
+}
diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro
index ed93946..ff1996f 100644
--- a/android/proguard-rules.pro
+++ b/android/proguard-rules.pro
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
+# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 1cefe20..7e3576b 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -12,10 +12,9 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
- tools:ignore="GoogleAppIndexingWarning"
- tools:targetApi="n">
+ tools:ignore="GoogleAppIndexingWarning">
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/AppModule.kt b/android/src/main/java/com/wbrawner/budget/AppModule.kt
index f432ae6..18f15d8 100644
--- a/android/src/main/java/com/wbrawner/budget/AppModule.kt
+++ b/android/src/main/java/com/wbrawner/budget/AppModule.kt
@@ -1,8 +1,9 @@
package com.wbrawner.budget
-import android.app.Application
import android.util.Log
+import com.wbrawner.pihelper.shared.create
import com.wbrawner.twigs.shared.ErrorHandler
+import com.wbrawner.twigs.shared.Store
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -12,14 +13,12 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
- fun provideErrorHandler(): com.wbrawner.twigs.shared.ErrorHandler = object :
- com.wbrawner.twigs.shared.ErrorHandler {
- override fun init(application: Application) {
- // no-op
- }
-
+ fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
override fun reportException(t: Throwable, message: String?) {
Log.e("ErrorHandler", "Report exception: $message", t)
}
}
+
+ @Provides
+ fun providesStore() = Store.create()
}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/AsyncState.kt b/android/src/main/java/com/wbrawner/budget/AsyncState.kt
deleted file mode 100644
index 32bc192..0000000
--- a/android/src/main/java/com/wbrawner/budget/AsyncState.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.wbrawner.budget
-
-import android.util.Log
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.launch
-
-sealed class AsyncState {
- object Loading : AsyncState()
- class Success(val data: T) : AsyncState()
- class Error(val exception: Exception) : AsyncState() {
- constructor(message: String) : this(RuntimeException(message))
- }
-
- object Exit : AsyncState()
-}
-
-interface AsyncViewModel {
- val state: MutableStateFlow>
-}
-
-
-fun VM.load(block: suspend () -> T): Job where VM : ViewModel, VM : AsyncViewModel = viewModelScope.launch {
- if (state.replayCache.firstOrNull() !is AsyncState.Success) {
- state.emit(AsyncState.Loading)
- }
- try {
- state.emit(AsyncState.Success(block()))
- } catch (e: Exception) {
- state.emit(AsyncState.Error(e))
- Log.e("AsyncViewModel", "Failed to load data", e)
- }
-}
-
diff --git a/android/src/main/java/com/wbrawner/budget/TwigsApplication.kt b/android/src/main/java/com/wbrawner/budget/TwigsApplication.kt
index cc8f5cc..4f008a9 100644
--- a/android/src/main/java/com/wbrawner/budget/TwigsApplication.kt
+++ b/android/src/main/java/com/wbrawner/budget/TwigsApplication.kt
@@ -1,33 +1,7 @@
package com.wbrawner.budget
import android.app.Application
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.user.UserRepository
import dagger.hilt.android.HiltAndroidApp
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-import kotlin.coroutines.CoroutineContext
@HiltAndroidApp
-class TwigsApplication : Application(), CoroutineScope {
- override val coroutineContext: CoroutineContext = Dispatchers.Main
-
- @Inject
- lateinit var budgetRepository: BudgetRepository
-
- @Inject
- lateinit var userRepository: UserRepository
-
- override fun onCreate() {
- super.onCreate()
- launch {
- try {
- userRepository.getProfile()
- budgetRepository.prefetchData()
- } catch (ignored: Exception) {
- }
- }
- }
-}
+class TwigsApplication : Application()
diff --git a/android/src/main/java/com/wbrawner/budget/ui/CategoriesScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/CategoriesScreen.kt
new file mode 100644
index 0000000..370e0ac
--- /dev/null
+++ b/android/src/main/java/com/wbrawner/budget/ui/CategoriesScreen.kt
@@ -0,0 +1,105 @@
+package com.wbrawner.budget.ui
+
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import android.util.Log
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.wbrawner.budget.ui.base.TwigsApp
+import com.wbrawner.twigs.shared.Store
+import com.wbrawner.twigs.shared.category.Category
+import com.wbrawner.twigs.shared.transaction.TransactionAction
+import java.util.*
+import kotlin.math.abs
+import kotlin.math.max
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CategoriesScreen(store: Store) {
+ val scrollState = rememberLazyListState()
+ TwigsScaffold(store = store, title = "Categories") {
+ val state by store.state.collectAsState()
+ state.categories?.let { categories ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(it),
+ state = scrollState
+ ) {
+ itemsIndexed(categories, key = { _, t -> t.id!! }) { index, category ->
+ CategoryListItem(category, state.categoryBalances?.get(category.id!!)) {
+ store.dispatch(TransactionAction.SelectTransaction(category.id))
+ }
+ }
+ }
+ } ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+}
+
+@Composable
+fun CategoryListItem(category: Category, balance: Long?, onClick: (Category) -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick(category) }
+ .padding(8.dp)
+ .heightIn(min = 56.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = spacedBy(4.dp)
+ ) {
+ Text(category.title, style = MaterialTheme.typography.bodyLarge)
+ Spacer(modifier = Modifier.height(8.dp))
+ balance?.let {
+ val denominator = remember { max(abs(it), abs(category.amount)).toFloat() }
+ val progress =
+ remember { if (denominator == 0f) 0f else abs(it).toFloat() / denominator }
+ Log.d(
+ "Twigs",
+ "Category ${category.title} amount: $denominator balance: $it progress: $progress"
+ )
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ progress = progress,
+ color = if (category.expense) Color.Red else Color.Green
+ )
+ } ?: LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+ }
+}
+
+@Composable
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+fun CategoryListItem_Preview() {
+ TwigsApp {
+ CategoryListItem(
+ category = Category(
+ title = "Groceries",
+ amount = 150000,
+ budgetId = "budgetId",
+ expense = true,
+ ),
+ balance = null
+ ) {}
+ }
+}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/Constants.kt b/android/src/main/java/com/wbrawner/budget/ui/Constants.kt
deleted file mode 100644
index 4731dc1..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/Constants.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.wbrawner.budget.ui
-
-const val EXTRA_BUDGET_ID = "budgetId"
-const val EXTRA_CATEGORY_ID = "categoryId"
-const val EXTRA_CATEGORY_NAME = "categoryName"
-const val EXTRA_TRANSACTION_ID = "transactionId"
\ No newline at end of file
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 b5c744e..7294318 100644
--- a/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt
+++ b/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt
@@ -1,152 +1,178 @@
package com.wbrawner.budget.ui
import android.os.Bundle
-import android.view.MenuItem
import androidx.activity.compose.setContent
-import androidx.activity.viewModels
-import androidx.annotation.DrawableRes
-import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.animation.ExperimentalAnimationApi
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.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.material3.*
+import androidx.compose.runtime.*
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
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.NavController
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
-import androidx.navigation.findNavController
-import androidx.navigation.ui.setupWithNavController
-import com.google.android.material.navigation.NavigationView
+import androidx.navigation.navArgument
import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
+import com.wbrawner.budget.ui.auth.LoginScreen
import com.wbrawner.budget.ui.base.TwigsApp
+import com.wbrawner.budget.ui.transaction.TransactionDetailsScreen
+import com.wbrawner.budget.ui.transaction.TransactionsScreen
+import com.wbrawner.twigs.shared.Route
+import com.wbrawner.twigs.shared.Store
+import com.wbrawner.twigs.shared.budget.BudgetAction
+import com.wbrawner.twigs.shared.category.CategoryAction
+import com.wbrawner.twigs.shared.transaction.TransactionAction
import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.android.synthetic.main.activity_main.*
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
+import javax.inject.Inject
-private const val MENU_GROUP_BUDGETS = 50
-private const val MENU_ITEM_ADD_BUDGET = 100
-private const val MENU_ITEM_SETTINGS = 101
-
+@OptIn(ExperimentalAnimationApi::class)
@AndroidEntryPoint
-class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
- private lateinit var toggle: ActionBarDrawerToggle
- private val viewModel: MainViewModel by viewModels()
+class MainActivity : AppCompatActivity() {
+ @Inject
+ lateinit var store: Store
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ installSplashScreen()
setContent {
-
- }
- setContentView(R.layout.activity_main)
- setSupportActionBar(action_bar)
- toggle =
- ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close)
- toggle.isDrawerIndicatorEnabled = true
- toggle.isDrawerSlideAnimationEnabled = true
- drawerLayout.addDrawerListener(toggle)
- navigationView.setNavigationItemSelectedListener(this)
- supportActionBar?.setHomeButtonEnabled(true)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- val navController = findNavController(R.id.content_container)
- menu_main.setupWithNavController(navController)
- navController.addOnDestinationChangedListener { _, destination, _ ->
- title = destination.label
- val homeAsUpIndicator = when (destination.label) {
- getString(R.string.title_overview) -> R.drawable.ic_menu
- getString(R.string.title_transactions) -> R.drawable.ic_menu
- getString(R.string.title_profile) -> R.drawable.ic_menu
- getString(R.string.title_categories) -> R.drawable.ic_menu
- else -> 0
+ val state by store.state.collectAsState()
+ val navController = rememberNavController()
+ LaunchedEffect(state.route) {
+ navController.navigate(state.route.path)
}
- supportActionBar?.setHomeAsUpIndicator(homeAsUpIndicator)
- }
- lifecycleScope.launch {
- viewModel.loadBudgets().collect { list ->
- val menu = navigationView.menu
- menu.clear()
- val budgetsMenu = navigationView.menu.addSubMenu(0, 0, 0, "Budgets")
- list.budgets.forEachIndexed { index, budget ->
- budgetsMenu.add(MENU_GROUP_BUDGETS, index, index, budget.name)
- .setIcon(R.drawable.ic_folder_selectable)
+ TwigsApp {
+ val authViewModel: AuthViewModel = hiltViewModel()
+ NavHost(navController, state.initialRoute.path) {
+ composable(Route.Login.path) {
+ LoginScreen(store = store, viewModel = authViewModel)
+ }
+ composable(Route.Overview.path) {
+ OverviewScreen(store = store)
+ }
+ composable(Route.Transactions(selected = null).path) {
+ TransactionsScreen(store = store)
+ }
+ composable(
+ Route.Transactions(selected = "{id}").path,
+ arguments = listOf(navArgument("id") {
+ type = NavType.StringType
+ nullable = false
+ })
+ ) {
+ TransactionDetailsScreen(store = store)
+ }
+ composable(Route.Categories(selected = null).path) {
+ CategoriesScreen(store = store)
+ }
+// composable(Route.RECURRING_TRANSACTIONS.path) {
+// RecurringTransactionsScreen(store = store)
+// }
}
- budgetsMenu.setGroupCheckable(MENU_GROUP_BUDGETS, true, true)
- list.selectedIndex?.let {
- budgetsMenu.getItem(it).isChecked = true
- }
- menu.add(0, MENU_ITEM_ADD_BUDGET, list.budgets.size, R.string.title_add_budget)
- .setIcon(R.drawable.ic_add_white_24dp)
- menu.add(1, MENU_ITEM_SETTINGS, list.budgets.size + 1, "Settings")
- .setIcon(R.drawable.ic_settings)
}
}
}
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId != android.R.id.home) return super.onOptionsItemSelected(item)
- with(findNavController(R.id.content_container)) {
- when (currentDestination?.label) {
- getString(R.string.title_overview) -> drawerLayout.open()
- getString(R.string.title_transactions) -> drawerLayout.open()
- getString(R.string.title_profile) -> drawerLayout.open()
- getString(R.string.title_categories) -> drawerLayout.open()
- else -> navigateUp()
- }
- }
- return true
- }
-
- companion object {
- const val EXTRA_OPEN_FRAGMENT = "com.wbrawner.budget.MainActivity.EXTRA_OPEN_FRAGMENT"
- }
-
- override fun onNavigationItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- MENU_ITEM_ADD_BUDGET -> findNavController(R.id.content_container).navigate(R.id.addEditBudget)
- MENU_ITEM_SETTINGS -> findNavController(R.id.content_container).navigate(R.id.addEditBudget)
- else -> viewModel.loadBudget(item.itemId)
- }
- drawerLayout.close()
- return true
- }
-}
-
-@Composable
-fun MainScreen() {
- val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
- val scaffoldState = rememberScaffoldState(drawerState)
- Scaffold(
- scaffoldState = scaffoldState,
- drawerContent = {
-
- }
- ) {
-
- }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun TwigsDrawer(navController: NavController, budgets: List, selectedBudgetId: String) {
- Column(modifier = Modifier.fillMaxSize()) {
+fun TwigsScaffold(
+ store: Store,
+ title: String,
+ onClickFab: (() -> Unit)? = null,
+ actions: @Composable RowScope.() -> Unit = {},
+ drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
+ navigationIcon: @Composable () -> Unit = {
+ val coroutineScope = rememberCoroutineScope()
+ IconButton(onClick = {
+ coroutineScope.launch {
+ drawerState.open()
+ }
+ }) {
+ Icon(Icons.Default.Menu, "Main menu")
+ }
+ },
+ content: @Composable (padding: PaddingValues) -> Unit
+) {
+ val state by store.state.collectAsState()
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ ModalDrawerSheet {
+ TwigsDrawer(store = store, drawerState::close)
+ }
+ }
+ ) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ navigationIcon = navigationIcon,
+ actions = actions,
+ title = {
+ Text(title)
+ }
+ )
+ },
+ bottomBar = {
+ NavigationBar {
+ NavigationBarItem(
+ selected = state.route == Route.Overview,
+ onClick = { store.dispatch(BudgetAction.OverviewClicked) },
+ icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
+ label = { Text(text = "Overview") }
+ )
+ NavigationBarItem(
+ selected = state.route is Route.Transactions,
+ onClick = { store.dispatch(TransactionAction.TransactionsClicked) },
+ icon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
+ label = { Text(text = "Transactions") }
+ )
+ NavigationBarItem(
+ selected = state.route is Route.Categories,
+ onClick = { store.dispatch(CategoryAction.CategoriesClicked) },
+ icon = { Icon(Icons.Default.Category, contentDescription = null) },
+ label = { Text(text = "Categories") }
+ )
+ NavigationBarItem(
+ selected = false,
+ onClick = { store.dispatch(BudgetAction.OverviewClicked) },
+ icon = { Icon(Icons.Default.Repeat, contentDescription = null) },
+ label = { Text(text = "Recurring") }
+ )
+ }
+ },
+ floatingActionButton = {
+ onClickFab?.let { onClick ->
+ FloatingActionButton(onClick = onClick) {
+ Icon(imageVector = Icons.Default.Add, "Add")
+ }
+ }
+ }
+ ) {
+ content(it)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TwigsDrawer(store: Store, close: suspend () -> Unit) {
+ val state by store.state.collectAsState()
+ val coroutineScope = rememberCoroutineScope()
+ Column(modifier = Modifier.fillMaxSize(), verticalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@@ -156,60 +182,27 @@ fun TwigsDrawer(navController: NavController, budgets: List, selectedBud
Image(painter = painterResource(id = image), null)
Text(
text = "twigs",
- style = MaterialTheme.typography.h4
+ style = MaterialTheme.typography.titleLarge
)
}
- 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}") }
- )
+ state.budgets?.let { budgets ->
+ LazyColumn(modifier = Modifier.fillMaxHeight()) {
+ items(budgets) { budget ->
+ val selected = budget.id == state.selectedBudget
+ val icon = if (selected) Icons.Filled.Folder else Icons.Default.Folder
+ NavigationDrawerItem(
+ icon = { Icon(icon, contentDescription = null) },
+ label = { Text(budget.name) },
+ selected = selected,
+ onClick = {
+ store.dispatch(BudgetAction.SelectBudget(budget.id))
+ coroutineScope.launch {
+ close()
+ }
+ }
+ )
+ }
}
}
}
-}
-
-@Composable
-@Preview
-fun TwigsDrawer_Preview() {
- val scaffoldState = rememberScaffoldState(rememberDrawerState(initialValue = DrawerValue.Open))
- val navController = rememberNavController()
- TwigsApp {
- Scaffold(
- scaffoldState = scaffoldState,
- drawerContent = { TwigsDrawer(navController, emptyList(), "") }
- ) {
-
- }
- }
-}
+}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/MainViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/MainViewModel.kt
deleted file mode 100644
index 303eada..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/MainViewModel.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.wbrawner.budget.ui
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.user.UserRepository
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class MainViewModel @Inject constructor(
- val budgetRepository: BudgetRepository,
- val userRepository: UserRepository
-) : ViewModel() {
-
- private val budgets = MutableSharedFlow(replay = 1)
-
- fun loadBudgets(): SharedFlow {
- viewModelScope.launch {
- val list = budgetRepository.findAll().sortedBy { it.name }
- budgets.emit(
- BudgetList(
- list,
- budgetRepository.currentBudget.replayCache.firstOrNull()?.let {
- list.indexOf(it)
- }
- ))
- }
- return budgets
- }
-
- fun loadBudget(index: Int) {
- val list = budgets.replayCache.firstOrNull() ?: return
- viewModelScope.launch {
- budgetRepository.findById(list.budgets[index].id!!, true)
- budgets.emit(list.copy(selectedIndex = index))
- }
- }
-}
-
-data class BudgetList(
- val budgets: List = emptyList(),
- val selectedIndex: Int? = null
-)
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/OverviewScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/OverviewScreen.kt
new file mode 100644
index 0000000..6d3aca4
--- /dev/null
+++ b/android/src/main/java/com/wbrawner/budget/ui/OverviewScreen.kt
@@ -0,0 +1,39 @@
+package com.wbrawner.budget.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.wbrawner.budget.ui.transaction.toCurrencyString
+import com.wbrawner.twigs.shared.Store
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OverviewScreen(store: Store) {
+ val state by store.state.collectAsState()
+ TwigsScaffold(store = store, title = "Overview") { padding ->
+ val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ budget?.let { budget ->
+ Text(budget.name)
+ Text(budget.description ?: "")
+ }
+ Text("Cash Flow")
+ Text(state.budgetBalance?.toCurrencyString() ?: "-")
+ }
+ }
+}
+
diff --git a/android/src/main/java/com/wbrawner/budget/ui/RecurringTransactionsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/RecurringTransactionsScreen.kt
new file mode 100644
index 0000000..ae4c2b8
--- /dev/null
+++ b/android/src/main/java/com/wbrawner/budget/ui/RecurringTransactionsScreen.kt
@@ -0,0 +1,14 @@
+package com.wbrawner.budget.ui
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import com.wbrawner.twigs.shared.Store
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RecurringTransactionsScreen(store: Store) {
+ TwigsScaffold(store = store, title = "Recurring Transactions") {
+ Text("Not yet implemented")
+ }
+}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/auth/AuthActivity.kt b/android/src/main/java/com/wbrawner/budget/ui/auth/AuthActivity.kt
deleted file mode 100644
index ff9b126..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/auth/AuthActivity.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.wbrawner.budget.ui.auth
-
-import android.content.Intent
-import android.os.Bundle
-import android.util.Log
-import android.view.View
-import android.view.ViewTreeObserver
-import androidx.activity.compose.setContent
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.animation.ExperimentalAnimationApi
-import androidx.compose.material.Text
-import androidx.compose.runtime.LaunchedEffect
-import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.rememberNavController
-import com.wbrawner.budget.ui.AuthViewModel
-import com.wbrawner.budget.ui.MainActivity
-import com.wbrawner.budget.ui.base.TwigsApp
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.flow.collect
-
-@AndroidEntryPoint
-class AuthActivity : AppCompatActivity() {
- val viewModel: AuthViewModel by viewModels()
-
- @ExperimentalAnimationApi
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- installSplashScreen()
- val content: View = findViewById(android.R.id.content)
- content.viewTreeObserver.addOnPreDrawListener(
- object : ViewTreeObserver.OnPreDrawListener {
- override fun onPreDraw(): Boolean {
- return if (viewModel.authenticated.value != null) {
- Log.d("SplashScreen", "onPredraw Complete, removing listener")
- content.viewTreeObserver.removeOnPreDrawListener(this)
- true
- } else {
- Log.d("SplashScreen", "onPredraw active, ignoring draw")
- false
- }
- }
- }
- )
- setContent {
- TwigsApp {
- LaunchedEffect(key1 = viewModel) {
- viewModel.checkForExistingCredentials()
- }
- LaunchedEffect(key1 = viewModel) {
- viewModel.authenticated.collect {
- if (it == true) {
- // TODO: Replace with Compose Navigation
- startActivity(Intent(this@AuthActivity, MainActivity::class.java))
- finish()
- }
- }
- }
- val navController = rememberNavController()
- NavHost(navController, AuthRoutes.LOGIN.name) {
- composable(AuthRoutes.LOGIN.name) {
- LoginScreen(navController = navController, viewModel = viewModel)
- }
- composable(AuthRoutes.REGISTER.name) {
- Text("Not yet implemented")
- }
- composable(AuthRoutes.FORGOT_PASSWORD.name) {
- Text("Not yet implemented")
- }
- }
- }
- }
- }
-}
-
-enum class AuthRoutes {
- LOGIN,
- REGISTER,
- FORGOT_PASSWORD,
-}
\ No newline at end of file
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 cb3faaf..015e743 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
@@ -1,100 +1,19 @@
package com.wbrawner.budget.ui
-import android.content.SharedPreferences
-import androidx.core.content.edit
+import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.wbrawner.budget.common.PREF_KEY_TOKEN
-import com.wbrawner.budget.common.PREF_KEY_USER_ID
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.user.UserRepository
-import com.wbrawner.budget.lib.network.PREF_KEY_BASE_URL
+import com.wbrawner.twigs.shared.Store
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
- val budgetRepository: BudgetRepository,
- val userRepository: UserRepository,
- val sharedPreferences: SharedPreferences,
+ val store: Store
) : ViewModel() {
- val authenticated = MutableStateFlow(null)
- val loading = MutableStateFlow(false)
- val error = MutableSharedFlow(extraBufferCapacity = 1)
- val server = MutableStateFlow("")
- val username = MutableStateFlow("")
- val email = MutableStateFlow("")
- val password = MutableStateFlow("")
- val confirmPassword = MutableStateFlow("")
- val enableLogin = MutableStateFlow(false)
-
- init {
- viewModelScope.launch {
- combine(server, username, password) { (server, username, password) ->
- arrayOf(server.isNotBlank(), username.isNotBlank(), password.isNotBlank())
- }.collect {
- viewModelScope.launch {
- enableLogin.emit(it.all { it })
- }
- }
- }
- }
-
- fun checkForExistingCredentials() = viewModelScope.launch {
- if (!sharedPreferences.contains(PREF_KEY_TOKEN)) {
- authenticated.emit(false)
- return@launch
- }
- loading.emit(true)
- try {
- userRepository.getProfile()
- authenticated.emit(true)
- } catch (ignored: Exception) {
- authenticated.emit(false)
- } finally {
- loading.emit(false)
- }
- }
-
- fun login() = viewModelScope.launch {
- loading.emit(true)
- try {
- 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 {
- sharedPreferences.edit {
- putString(PREF_KEY_BASE_URL, correctServer)
- putString(PREF_KEY_USER_ID, it.userId)
- putString(PREF_KEY_TOKEN, it.token)
- }
- budgetRepository.prefetchData()
- authenticated.emit(true)
- }
- } catch (e: Exception) {
- loading.emit(false)
- error.emit(e.localizedMessage ?: "Login failed")
- }
- }
-
- fun setServer(server: String) = viewModelScope.launch {
- this@AuthViewModel.server.emit(server)
- }
-
- fun setUsername(username: String) = viewModelScope.launch {
- this@AuthViewModel.username.emit(username)
- }
-
- fun setPassword(password: String) = viewModelScope.launch {
- this@AuthViewModel.password.emit(password)
- }
+ val server = mutableStateOf("")
+ val username = mutableStateOf("")
+ val email = mutableStateOf("")
+ val password = mutableStateOf("")
+ val confirmPassword = mutableStateOf("")
+ val enableLogin = mutableStateOf(false)
}
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 b5bcefa..816d8aa 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
@@ -7,123 +7,250 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.*
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.isShiftPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.ImeAction
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
import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.AuthViewModel
import com.wbrawner.budget.ui.base.TwigsApp
+import com.wbrawner.twigs.shared.Effect
+import com.wbrawner.twigs.shared.Store
+import com.wbrawner.twigs.shared.user.ConfigAction
@ExperimentalAnimationApi
@Composable
fun LoginScreen(
- navController: NavController,
- viewModel: AuthViewModel
+ store: Store,
+ viewModel: AuthViewModel
) {
- val loading by viewModel.loading.collectAsState()
- val server by viewModel.server.collectAsState()
- val username by viewModel.username.collectAsState()
- val password by viewModel.password.collectAsState()
- val enableLogin by viewModel.enableLogin.collectAsState()
+ val state by store.state.collectAsState()
+ val effect by store.effects.collectAsState(initial = Effect.Empty)
+ val (error, setError) = remember { mutableStateOf("") }
+ (effect as? Effect.Error)?.let {
+ setError(it.message)
+ } ?: setError("")
+ val (server, setServer) = viewModel.server
+ val (username, setUsername) = viewModel.username
+ val (password, setPassword) = viewModel.password
AnimatedVisibility(
- visible = !loading,
- enter = fadeIn(),
- exit = fadeOut()
+ visible = !state.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,
+ error,
+ server,
+ setServer,
+ username,
+ setUsername,
+ password,
+ setPassword,
+ true,
+ { store.dispatch(ConfigAction.ForgotPasswordClicked) },
+ { store.dispatch(ConfigAction.RegisterClicked) },
+ {
+ store.dispatch(ConfigAction.SetServer(server))
+ store.dispatch(
+ ConfigAction.Login(
+ viewModel.username.value,
+ viewModel.password.value
+ )
+ )
+ },
)
}
+ AnimatedVisibility(
+ visible = state.loading,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
}
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@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,
+ error: String,
+ 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()
+ val (serverInput, usernameInput, passwordInput, loginButton) = FocusRequester.createRefs()
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")
+ if (error.isNotBlank()) {
+ Text(text = error, color = Color.Red)
+ }
TextField(
- modifier = Modifier.fillMaxWidth(),
- value = server,
- onValueChange = setServer,
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, capitalization = KeyboardCapitalization.None),
- placeholder = { Text("Server") }
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(serverInput)
+ .onPreviewKeyEvent {
+ if (it.key == Key.Tab && !it.isShiftPressed) {
+ usernameInput.requestFocus()
+ true
+ } else {
+ false
+ }
+ },
+ value = server,
+ onValueChange = setServer,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Uri,
+ capitalization = KeyboardCapitalization.None,
+ imeAction = ImeAction.Next
+ ),
+ placeholder = { Text("Server") },
+ keyboardActions = KeyboardActions(onNext = {
+ usernameInput.requestFocus()
+ }),
+ maxLines = 1
)
TextField(
- modifier = Modifier.fillMaxWidth(),
- value = username,
- onValueChange = setUsername,
- placeholder = { Text("Username") }
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(usernameInput)
+ .onPreviewKeyEvent {
+ if (it.key == Key.Tab) {
+ if (it.isShiftPressed) {
+ serverInput.requestFocus()
+ } else {
+ passwordInput.requestFocus()
+ }
+ true
+ } else {
+ false
+ }
+ },
+ value = username,
+ onValueChange = setUsername,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Text,
+ capitalization = KeyboardCapitalization.None,
+ imeAction = ImeAction.Next
+ ),
+ placeholder = { Text("Username") },
+ keyboardActions = KeyboardActions(onNext = {
+ passwordInput.requestFocus()
+ }),
+ maxLines = 1
)
TextField(
- modifier = Modifier.fillMaxWidth(),
- value = password,
- onValueChange = setPassword,
- placeholder = { Text("Password") },
- visualTransformation = PasswordVisualTransformation()
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(passwordInput)
+ .onPreviewKeyEvent {
+ when (it.key) {
+ Key.Tab -> {
+ if (it.isShiftPressed) {
+ usernameInput.requestFocus()
+ } else {
+ loginButton.requestFocus()
+ }
+ true
+ }
+
+ Key.Enter -> {
+ login()
+ true
+ }
+
+ else -> {
+ false
+ }
+ }
+ },
+ value = password,
+ onValueChange = setPassword,
+ placeholder = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Password,
+ capitalization = KeyboardCapitalization.None,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(onDone = {
+ login()
+ }),
+ maxLines = 1
)
Button(
- modifier = Modifier.fillMaxWidth(),
- enabled = enableLogin,
- onClick = login
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(loginButton),
+ 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?")
}
@@ -132,24 +259,18 @@ fun LoginForm(
@Composable
@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_Preview() {
TwigsApp {
- LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
+ LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
}
}
@Composable
@Preview(showBackground = true, device = Devices.PIXEL_C)
+@Preview(showBackground = true, device = Devices.PIXEL_C, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_PreviewTablet() {
TwigsApp {
- LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
- }
-}
-
-@Composable
-@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
-fun LoginScreen_PreviewDark() {
- TwigsApp {
- LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
+ LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
}
}
\ No newline at end of file
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
deleted file mode 100644
index 5711dfc..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/base/ListWithAddButtonFragment.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.wbrawner.budget.ui.base
-
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.StringRes
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.AsyncViewModel
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.Identifiable
-import com.wbrawner.budget.ui.hideFabOnScroll
-import kotlinx.android.synthetic.main.fragment_list_with_add_button.*
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-
-abstract class ListWithAddButtonFragment>> : Fragment() {
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? = inflater.inflate(R.layout.fragment_list_with_add_button, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- recyclerView.layoutManager = LinearLayoutManager(view.context)
- recyclerView.hideFabOnScroll(addFab)
- val adapter = BindableAdapter(constructors, diffUtilItemCallback)
- recyclerView.adapter = adapter
- addFab.setOnClickListener {
- addItem()
- }
- noItemsTextView.setText(noItemsStringRes)
- lifecycleScope.launch {
- viewModel.state.collect { state ->
- when (state) {
- is AsyncState.Loading -> {
- progressBar.visibility = View.VISIBLE
- listContainer.visibility = View.GONE
- noItemsTextView.visibility = View.GONE
- }
- is AsyncState.Success -> {
- progressBar.visibility = View.GONE
- listContainer.visibility = View.VISIBLE
- noItemsTextView.visibility = View.GONE
- adapter.submitList(state.data.map { bindData(it) })
- }
- is AsyncState.Error -> {
- // TODO: Show an error message
- progressBar.visibility = View.GONE
- listContainer.visibility = View.GONE
- noItemsTextView.visibility = View.VISIBLE
- }
- else -> { /* no-op */ }
- }
- }
- }
- }
-
- override fun onStart() {
- super.onStart()
- reloadItems()
- }
-
- abstract val viewModel: ViewModel
- abstract fun reloadItems()
- @get:StringRes
- abstract val noItemsStringRes: Int
- abstract fun addItem()
- abstract fun bindData(data: Data): BindableData
- abstract val constructors: Map BindableAdapter.BindableViewHolder>
- open val diffUtilItemCallback: DiffUtil.ItemCallback> = object: DiffUtil.ItemCallback>() {
- override fun areItemsTheSame(oldItem: BindableData, newItem: BindableData): Boolean {
- return oldItem.data.id === newItem.data.id
- }
-
- @SuppressLint("DiffUtilEquals")
- override fun areContentsTheSame(oldItem: BindableData, newItem: BindableData): Boolean {
- return oldItem.data == newItem.data
- }
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/base/RecyclerViewInterfaces.kt b/android/src/main/java/com/wbrawner/budget/ui/base/RecyclerViewInterfaces.kt
deleted file mode 100644
index eb2ea14..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/base/RecyclerViewInterfaces.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.wbrawner.budget.ui.base
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.LayoutRes
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlin.coroutines.CoroutineContext
-
-class BindableAdapter(
- private val constructors: Map BindableViewHolder>,
- diffUtilItemCallback: DiffUtil.ItemCallback>
-) : ListAdapter, BindableAdapter.BindableViewHolder>(diffUtilItemCallback) {
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
- : BindableViewHolder = constructors[viewType]
- ?.invoke(LayoutInflater.from(parent.context).inflate(viewType, parent, false))
- ?: throw IllegalStateException("Attempted to create ViewHolder without proper constructor provided")
-
- override fun onBindViewHolder(holder: BindableViewHolder, position: Int) {
- holder.onBind(getItem(position))
- }
-
- override fun onViewRecycled(holder: BindableViewHolder) {
- holder.onUnbind()
- }
-
- override fun getItemViewType(position: Int): Int = getItem(position).viewType
-
- abstract class BindableViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), Bindable>
-
- abstract class CoroutineViewHolder(itemView: View) : BindableViewHolder(itemView), CoroutineScope {
- override val coroutineContext: CoroutineContext = Dispatchers.Main
-
- override fun onUnbind() {
- coroutineContext[Job]?.cancel()
- super.onUnbind()
- }
- }
-}
-
-interface Bindable {
- fun onBind(item: T) {}
- fun onUnbind() {}
-}
-
-data class BindableData(
- val data: T,
- @get:LayoutRes
- val viewType: Int
-)
\ No newline at end of file
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 1d03479..b0e101f 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,44 +1,64 @@
package com.wbrawner.budget.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material.Surface
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
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.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import com.wbrawner.budget.R
val lightColors = lightColorScheme(
primary = Green500,
+ primaryContainer = Green300,
secondary = Green700,
+ secondaryContainer = Green300,
)
val darkColors = darkColorScheme(
primary = Green300,
+ primaryContainer = Green500,
secondary = Green500,
+ secondaryContainer = Green700,
background = Color.Black,
surface = Color.Black,
)
+val ubuntu = FontFamily(
+ Font(R.font.ubuntu_bold, weight = FontWeight.Bold),
+ Font(R.font.ubuntu_regular, weight = FontWeight.Normal),
+ Font(R.font.ubuntu_light, weight = FontWeight.Light),
+ Font(R.font.ubuntu_bolditalic, weight = FontWeight.Bold, style = FontStyle.Italic),
+ Font(R.font.ubuntu_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
+ Font(R.font.ubuntu_lightitalic, weight = FontWeight.Light, style = FontStyle.Italic),
+)
+
@Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme(
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))),
-// h3 = MaterialTheme.typography.h3.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// h4 = MaterialTheme.typography.h4.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// h5 = MaterialTheme.typography.h5.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// h6 = MaterialTheme.typography.h6.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// subtitle1 = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// subtitle2 = MaterialTheme.typography.subtitle2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// body1 = MaterialTheme.typography.body1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// body2 = MaterialTheme.typography.body2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// button = MaterialTheme.typography.button.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// caption = MaterialTheme.typography.caption.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// overline = MaterialTheme.typography.overline.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
-// ),
+ typography = MaterialTheme.typography.copy(
+ displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
+ displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu),
+ displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = ubuntu),
+ headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = ubuntu),
+ headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = ubuntu),
+ headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = ubuntu),
+ titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = ubuntu),
+ titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = ubuntu),
+ titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = ubuntu),
+ bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = ubuntu),
+ bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = ubuntu),
+ bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = ubuntu),
+ labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = ubuntu),
+ labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = ubuntu),
+ labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = ubuntu),
+ ),
content = content
)
}
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
deleted file mode 100644
index a1a3b8f..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/budgets/AddEditBudgetFragment.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package com.wbrawner.budget.ui.budgets
-
-
-import android.content.Context
-import android.os.Bundle
-import android.view.*
-import android.widget.ArrayAdapter
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.Observer
-import androidx.navigation.fragment.findNavController
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.user.User
-import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.android.synthetic.main.fragment_add_edit_budget.*
-
-@AndroidEntryPoint
-class AddEditBudgetFragment : Fragment() {
- private val viewModel: BudgetFormViewModel by viewModels()
-
- var id: String? = null
- var menu: Menu? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- viewModel.getBudget(arguments?.getString(EXTRA_BUDGET_ID))
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? = inflater.inflate(R.layout.fragment_add_edit_budget, container, false)
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.menu_add_edit, menu)
- if (id != null) {
- menu.findItem(R.id.action_delete)?.isVisible = true
- }
- this.menu = menu
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- val suggestionsAdapter = ArrayAdapter(
- requireContext(),
- android.R.layout.simple_spinner_item,
- mutableListOf()
- )
- usersSearch.setAdapter(suggestionsAdapter)
- viewModel.userSuggestions.observe(viewLifecycleOwner, Observer {
- suggestionsAdapter.clear()
- suggestionsAdapter.addAll(it)
- })
- viewModel.state.observe(viewLifecycleOwner, Observer { state ->
- when (state) {
- is BudgetFormState.Loading -> {
- progressBar.visibility = View.VISIBLE
- budgetForm.visibility = View.GONE
- }
- is BudgetFormState.Success -> {
- budgetForm.visibility = View.VISIBLE
- progressBar.visibility = View.GONE
- activity?.setTitle(state.titleRes)
- menu?.findItem(R.id.action_delete)?.isVisible = state.showDeleteButton
- id = state.budget.id
- name.setText(state.budget.name)
- description.setText(state.budget.description)
- }
- is BudgetFormState.Exit -> {
- findNavController().navigateUp()
- }
- else -> { /* no-op */ }
- }
- })
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> findNavController().navigateUp()
- R.id.action_save -> {
- viewModel.saveBudget(Budget(
- id = id,
- name = name.text.toString(),
- description = description.text.toString(),
- users = emptyList()
- ))
- }
- R.id.action_delete -> {
- viewModel.deleteBudget(this@AddEditBudgetFragment.id!!)
- }
- }
- return true
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetFormViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetFormViewModel.kt
deleted file mode 100644
index 03fd817..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetFormViewModel.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-package com.wbrawner.budget.ui.budgets
-
-import androidx.annotation.StringRes
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.user.User
-import com.wbrawner.budget.common.user.UserRepository
-import com.wbrawner.budget.common.util.randomId
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class BudgetFormViewModel @Inject constructor(
- val budgetRepository: BudgetRepository,
- val userRepository: UserRepository
-) : ViewModel() {
-
- val state = MutableLiveData(BudgetFormState.Loading)
- val users = MutableLiveData>()
- val userSuggestions = MutableLiveData>()
-
- fun getBudget(id: String? = null) {
- viewModelScope.launch {
- state.postValue(BudgetFormState.Loading)
- try {
- val budget = id?.let {
- budgetRepository.findById(it)
- } ?: Budget(name = "")
- state.postValue(BudgetFormState.Success(budget))
- } catch (e: Exception) {
- state.postValue(BudgetFormState.Failed(e))
- }
- }
- }
-
- fun saveBudget(budget: Budget) {
- viewModelScope.launch {
- state.postValue(BudgetFormState.Loading)
- try {
- if (budget.id != null) {
- budgetRepository.update(budget)
- } else {
- budgetRepository.create(budget.copy(id = randomId()))
- }
- state.postValue(BudgetFormState.Exit)
- } catch (e: Exception) {
- state.postValue(BudgetFormState.Failed(e))
- }
- }
- }
-
- fun deleteBudget(budgetId: String) {
- viewModelScope.launch {
- state.postValue(BudgetFormState.Loading)
- try {
- budgetRepository.delete(budgetId)
- state.postValue(BudgetFormState.Exit)
- } catch (e: Exception) {
- state.postValue(BudgetFormState.Failed(e))
- }
- }
- }
-
- fun searchUsers(query: String) {
- if (query.isBlank()) {
- userSuggestions.value = emptyList()
- return
- }
- viewModelScope.launch {
- userSuggestions.value = userRepository.findAllByNameLike(query).toList()
- }
- }
-
- fun addUser(user: User) {
- users.value
- }
-}
-
-sealed class BudgetFormState {
- object Loading: BudgetFormState()
- class Success(
- @StringRes val titleRes: Int,
- val showDeleteButton: Boolean,
- val budget: Budget
- ): BudgetFormState() {
- constructor(budget: Budget): this(
- budget.id?.let { R.string.title_edit_budget }?: R.string.title_add_budget,
- budget.id != null,
- budget
- )
- }
- class Failed(val exception: Exception): BudgetFormState()
- object Exit: BudgetFormState()
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetListFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetListFragment.kt
deleted file mode 100644
index 8af1457..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetListFragment.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package com.wbrawner.budget.ui.budgets
-
-
-import android.os.Bundle
-import android.view.View
-import android.widget.TextView
-import androidx.fragment.app.viewModels
-import androidx.navigation.NavController
-import androidx.navigation.fragment.findNavController
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
-import com.wbrawner.budget.ui.base.BindableAdapter
-import com.wbrawner.budget.ui.base.BindableData
-import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class BudgetListFragment : ListWithAddButtonFragment() {
- override val noItemsStringRes: Int = R.string.overview_no_data
-
- override val viewModel: BudgetListViewModel by viewModels()
-
- override fun reloadItems() {
- viewModel.getBudgets()
- }
-
- override fun bindData(data: Budget): BindableData = BindableData(data, BUDGET_VIEW)
-
- override val constructors: Map BindableAdapter.BindableViewHolder>
- get() = mapOf(BUDGET_VIEW to { v -> BudgetViewHolder(v, findNavController()) })
-
- override fun addItem() {
- findNavController().navigate(R.id.addEditBudget)
- }
-}
-
-const val BUDGET_VIEW = R.layout.list_item_budget
-
-class BudgetViewHolder(itemView: View, val navController: NavController) : BindableAdapter.BindableViewHolder(itemView) {
- private val name: TextView = itemView.findViewById(R.id.budgetName)
- private val description: TextView = itemView.findViewById(R.id.budgetDescription)
-// private val balance: TextView = itemView.findViewById(R.id.budgetBalance)
-
- override fun onBind(item: BindableData) {
- val budget = item.data
- name.text = budget.name
- if (budget.description.isNullOrBlank()) {
- description.visibility = View.GONE
- } else {
- description.visibility = View.VISIBLE
- description.text = budget.description
- }
- itemView.setOnClickListener {
- val bundle = Bundle().apply {
- putString(EXTRA_BUDGET_ID, budget.id)
- }
- navController.navigate(R.id.categoryListFragment, bundle)
- }
- }
-}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetListViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetListViewModel.kt
deleted file mode 100644
index e18ea29..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/budgets/BudgetListViewModel.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.wbrawner.budget.ui.budgets
-
-import androidx.lifecycle.ViewModel
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.AsyncViewModel
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.load
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import javax.inject.Inject
-
-@HiltViewModel
-class BudgetListViewModel @Inject constructor(
- val budgetRepository: BudgetRepository
-) : ViewModel(), AsyncViewModel> {
- override val state: MutableStateFlow>> =
- MutableStateFlow(AsyncState.Loading)
-
- fun getBudgets() {
- load {
- budgetRepository.findAll().toList()
- }
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryDetailsFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryDetailsFragment.kt
deleted file mode 100644
index 60a9425..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryDetailsFragment.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-package com.wbrawner.budget.ui.categories
-
-
-import android.os.Build
-import android.os.Bundle
-import android.view.*
-import android.widget.Toast
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.lifecycleScope
-import androidx.navigation.fragment.findNavController
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.R
-import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
-import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
-import com.wbrawner.budget.ui.EXTRA_CATEGORY_NAME
-import com.wbrawner.budget.ui.toAmountSpannable
-import com.wbrawner.budget.ui.transactions.TransactionListFragment
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.android.synthetic.main.fragment_category_details.*
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-
-@AndroidEntryPoint
-class CategoryDetailsFragment : Fragment() {
- val viewModel: CategoryDetailsViewModel by viewModels()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? =
- inflater.inflate(R.layout.fragment_category_details, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- activity?.title = arguments?.getString(EXTRA_CATEGORY_NAME)
- lifecycleScope.launch {
- viewModel.state.collect { state ->
- when (state) {
- is AsyncState.Loading -> {
- categoryDetails.visibility = View.GONE
- progressBar.visibility = View.VISIBLE
- }
- is AsyncState.Success -> {
- categoryDetails.visibility = View.VISIBLE
- progressBar.visibility = View.GONE
- val category = state.data.category
- activity?.title = category.title
- val tintColor =
- if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
- val colorStateList = with(view.context) {
- android.content.res.ColorStateList.valueOf(getColor(tintColor))
- }
- categoryProgress.progressTintList = colorStateList
- categoryProgress.max = category.amount.toInt()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- categoryProgress.setProgress(
- state.data.balance.toInt(),
- true
- )
- } else {
- categoryProgress.progress = state.data.balance.toInt()
- }
- total.text = category.amount.toAmountSpannable()
- balance.text = state.data.balance.toAmountSpannable()
- remaining.text = state.data.remaining.toAmountSpannable()
- if (category.description.isNullOrBlank()) {
- categoryDescription.visibility = View.GONE
- } else {
- categoryDescription.visibility = View.VISIBLE
- categoryDescription.text = category.description
- }
- childFragmentManager.fragments.firstOrNull()?.let {
- if (it !is TransactionListFragment) return@let
- it.reloadItems()
- } ?: run {
- val transactionsFragment = TransactionListFragment().apply {
- arguments = Bundle().apply {
- putString(EXTRA_BUDGET_ID, category.budgetId)
- putString(EXTRA_CATEGORY_ID, category.id)
- }
- }
- childFragmentManager.beginTransaction()
- .replace(R.id.transactionsFragmentContainer, transactionsFragment)
- .commit()
- }
- }
- is AsyncState.Error -> {
- categoryDetails.visibility = View.VISIBLE
- progressBar.visibility = View.GONE
- Toast.makeText(view.context, "Failed to load context", Toast.LENGTH_SHORT)
- .show()
- }
- is AsyncState.Exit -> {
- findNavController().navigateUp()
- }
- }
- }
- }
- viewModel.getCategory(arguments?.getString(EXTRA_CATEGORY_ID))
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId == R.id.action_edit) {
- val bundle = Bundle().apply {
- putString(EXTRA_CATEGORY_ID, arguments?.getString(EXTRA_CATEGORY_ID))
- }
- findNavController().navigate(R.id.addEditCategoryActivity, bundle)
- } else if (item.itemId == android.R.id.home) {
- return findNavController().navigateUp()
- }
- return super.onOptionsItemSelected(item)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.menu_editable, menu)
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryDetailsViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryDetailsViewModel.kt
deleted file mode 100644
index 55a8046..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryDetailsViewModel.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.wbrawner.budget.ui.categories
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.AsyncViewModel
-import com.wbrawner.budget.common.category.Category
-import com.wbrawner.budget.common.category.CategoryRepository
-import com.wbrawner.budget.load
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class CategoryDetailsViewModel @Inject constructor(
- val categoryRepo: CategoryRepository
-) : ViewModel(), AsyncViewModel {
- override val state: MutableStateFlow> =
- MutableStateFlow(AsyncState.Loading)
-
- fun getCategory(id: String? = null) {
- viewModelScope.launch {
- if (id == null) {
- state.emit(AsyncState.Error("Invalid category ID"))
- return@launch
- }
- load {
- val category = categoryRepo.findById(id)
- val multiplier = if (category.expense) -1 else 1
- val balance = categoryRepo.getBalance(id) * multiplier
- CategoryDetails(
- category,
- balance,
- category.amount - balance
- )
- }
- }
- }
-}
-
-data class CategoryDetails(
- val category: Category,
- val balance: Long,
- val remaining: Long
-)
\ No newline at end of file
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
deleted file mode 100644
index b341ad3..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormActivity.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-package com.wbrawner.budget.ui.categories
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.widget.ArrayAdapter
-import android.widget.Toast
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.app.NavUtils
-import androidx.core.app.TaskStackBuilder
-import androidx.lifecycle.lifecycleScope
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.category.Category
-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
-
-@AndroidEntryPoint
-class CategoryFormActivity : AppCompatActivity() {
- val viewModel: CategoryFormViewModel by viewModels()
- var id: String? = null
- var menu: Menu? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_add_edit_category)
- setSupportActionBar(action_bar)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- lifecycleScope.launch {
- viewModel.state.collect { state ->
- when (state) {
- is AsyncState.Loading -> {
- categoryForm.visibility = View.GONE
- progressBar.visibility = View.VISIBLE
- }
- is AsyncState.Success -> {
- categoryForm.visibility = View.VISIBLE
- progressBar.visibility = View.GONE
- val category = state.data.category
- id = category.id
- 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",
- (category.amount.toBigDecimal() / 100.toBigDecimal()).toFloat()
- )
- )
- expense.isChecked = category.expense
- income.isChecked = !category.expense
- archived.isChecked = category.archived
- budgetSpinner.adapter = ArrayAdapter(
- this@CategoryFormActivity,
- android.R.layout.simple_list_item_1,
- state.data.budgets
- )
- val budget = state.data.budgets.firstOrNull { it.id == category.budgetId }
- ?: viewModel.budgetRepository.currentBudget.replayCache.firstOrNull()
- budgetSpinner.setSelection(state.data.budgets.indexOf(budget))
-
- }
- is AsyncState.Error -> {
- // TODO: Show error message
- categoryForm.visibility = View.VISIBLE
- progressBar.visibility = View.GONE
- Toast.makeText(this@CategoryFormActivity, "Failed to save Category", Toast.LENGTH_SHORT).show()
- }
- is AsyncState.Exit -> finish()
- }
- }
- }
- viewModel.loadCategory(intent?.extras?.getString(EXTRA_CATEGORY_ID))
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.menu_add_edit, menu)
- if (id != null) {
- menu?.findItem(R.id.action_delete)?.isVisible = true
- }
- this.menu = menu
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- val upIntent: Intent? = NavUtils.getParentActivityIntent(this)
-
- when {
- upIntent == null -> throw IllegalStateException("No Parent Activity Intent")
- NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
- TaskStackBuilder.create(this)
- .addNextIntentWithParentStack(upIntent)
- .startActivities()
- }
- else -> {
- NavUtils.navigateUpTo(this, upIntent)
- }
- }
- }
- R.id.action_save -> {
- if (!validateFields()) return true
- viewModel.saveCategory(
- 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,
- archived = archived.isChecked
- )
- )
- }
- R.id.action_delete -> {
- viewModel.deleteCategoryById(this@CategoryFormActivity.id!!)
- }
- }
- return true
- }
-
- private fun validateFields(): Boolean {
- var errors = false
- if (edit_category_name.text?.isEmpty() == true) {
- edit_category_name.error = getString(R.string.required_field_name)
- errors = true
- }
-
- if (edit_category_amount.text.toString().isEmpty()) {
- edit_category_amount.error = getString(R.string.required_field_amount)
- errors = true
- }
-
- return !errors
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormViewModel.kt
deleted file mode 100644
index 8fa8aae..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryFormViewModel.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package com.wbrawner.budget.ui.categories
-
-import androidx.annotation.StringRes
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.AsyncViewModel
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.category.Category
-import com.wbrawner.budget.common.category.CategoryRepository
-import com.wbrawner.budget.common.util.randomId
-import com.wbrawner.budget.load
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class CategoryFormViewModel @Inject constructor(
- val categoryRepository: CategoryRepository,
- val budgetRepository: BudgetRepository
-) : ViewModel(), AsyncViewModel {
- override val state: MutableStateFlow> =
- MutableStateFlow(AsyncState.Loading)
-
- fun loadCategory(categoryId: String? = null) {
- load {
- val category = categoryId?.let {
- categoryRepository.findById(it)
- } ?: Category("", title = "", amount = 0)
- CategoryFormState(
- category,
- budgetRepository.findAll().toList()
- )
- }
- }
-
- fun saveCategory(category: Category) {
- viewModelScope.launch {
- state.emit(AsyncState.Loading)
- try {
- if (category.id == null)
- categoryRepository.create(category.copy(id = randomId()))
- else
- categoryRepository.update(category)
- state.emit(AsyncState.Exit)
- } catch (e: Exception) {
- state.emit(AsyncState.Error(e))
- }
- }
- }
-
- fun deleteCategoryById(id: String) {
- viewModelScope.launch {
- state.emit(AsyncState.Loading)
- try {
- categoryRepository.delete(id)
- state.emit(AsyncState.Exit)
- } catch (e: Exception) {
- state.emit(AsyncState.Error(e))
- }
- }
- }
-}
-
-data class CategoryFormState(
- val category: Category,
- val budgets: List,
- @StringRes val titleRes: Int,
- val showDeleteButton: Boolean
-) {
- constructor(category: Category, budgets: List) : this(
- category,
- budgets,
- category.id?.let { R.string.title_edit_category } ?: R.string.title_add_category,
- category.id != null
- )
-}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryListFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryListFragment.kt
deleted file mode 100644
index baec7d2..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryListFragment.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.wbrawner.budget.ui.categories
-
-import android.annotation.SuppressLint
-import android.content.Intent
-import android.os.Bundle
-import android.view.View
-import android.widget.ProgressBar
-import android.widget.TextView
-import androidx.fragment.app.viewModels
-import androidx.navigation.NavController
-import androidx.navigation.fragment.findNavController
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.category.Category
-import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
-import com.wbrawner.budget.ui.EXTRA_CATEGORY_NAME
-import com.wbrawner.budget.ui.base.BindableAdapter
-import com.wbrawner.budget.ui.base.BindableData
-import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.launch
-
-@AndroidEntryPoint
-class CategoryListFragment : ListWithAddButtonFragment() {
- override val noItemsStringRes: Int = R.string.categories_no_data
- override val viewModel: CategoryListViewModel by viewModels()
- override fun reloadItems() {
- viewModel.getCategories()
- }
-
- override fun bindData(data: Category): BindableData = BindableData(data, CATEGORY_VIEW)
-
- override val constructors: Map BindableAdapter.BindableViewHolder> = mapOf(CATEGORY_VIEW to { v -> CategoryViewHolder(v, viewModel, findNavController()) })
-
- override fun addItem() {
- startActivity(Intent(activity, CategoryFormActivity::class.java))
- }
-
- companion object {
- const val TAG_FRAGMENT = "categories"
- }
-}
-
-const val CATEGORY_VIEW = R.layout.list_item_category
-
-class CategoryViewHolder(
- itemView: View,
- private val viewModel: CategoryListViewModel,
- private val navController: NavController
-) : BindableAdapter.CoroutineViewHolder(itemView) {
- private val name: TextView = itemView.findViewById(R.id.category_title)
- private val amount: TextView = itemView.findViewById(R.id.category_amount)
- private val progressBar: ProgressBar = itemView.findViewById(R.id.category_progress)
-
- @SuppressLint("NewApi")
- override fun onBind(item: BindableData) {
- val category = item.data
- name.text = category.title
- // TODO: Format according to budget's currency
- amount.text = String.format("${'$'}%.02f", category.amount / 100.0f)
- val tintColor = if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
- val colorStateList = with(itemView.context) {
- android.content.res.ColorStateList.valueOf(getColor(tintColor))
- }
- progressBar.progressTintList = colorStateList
- progressBar.indeterminateTintList = colorStateList
- progressBar.max = category.amount.toInt()
- launch {
- val balance = viewModel.getBalance(category).toInt()
- progressBar.isIndeterminate = false
- progressBar.setProgress(
- balance,
- true
- )
- amount.text = itemView.context.getString(
- R.string.balance_remaning,
- (category.amount - balance) / 100.0f
- )
- }
- itemView.setOnClickListener {
- val bundle = Bundle().apply {
- putString(EXTRA_CATEGORY_ID, category.id)
- putString(EXTRA_CATEGORY_NAME, category.title)
- }
- navController.navigate(R.id.categoryFragment, bundle)
- }
- }
-}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryListViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryListViewModel.kt
deleted file mode 100644
index fdcc81c..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/categories/CategoryListViewModel.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.wbrawner.budget.ui.categories
-
-import androidx.lifecycle.*
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.AsyncViewModel
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.category.Category
-import com.wbrawner.budget.common.category.CategoryRepository
-import com.wbrawner.budget.load
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class CategoryListViewModel @Inject constructor(
- val budgetRepo: BudgetRepository,
- val categoryRepo: CategoryRepository
-) : ViewModel(), AsyncViewModel> {
- override val state: MutableStateFlow>> =
- MutableStateFlow(AsyncState.Loading)
-
- fun getCategories() {
- viewModelScope.launch {
- budgetRepo.currentBudget.collect {
- val budgetId = budgetRepo.currentBudget.replayCache.firstOrNull()?.id
- if (budgetId == null) {
- state.emit(AsyncState.Error("Invalid budget ID"))
- return@collect
- }
- load {
- categoryRepo.findAll(arrayOf(budgetId)).toList()
- }
- }
- }
- }
-
- suspend fun getBalance(category: Category): Long {
- val multiplier = if (category.expense) -1 else 1
- return categoryRepo.getBalance(category.id!!) * multiplier
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/overview/AccountsAdapter.kt b/android/src/main/java/com/wbrawner/budget/ui/overview/AccountsAdapter.kt
deleted file mode 100644
index 3de17ba..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/overview/AccountsAdapter.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.wbrawner.budget.ui.overview
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
-
-class AccountsAdapter() : RecyclerView.Adapter() {
- private val accounts = mutableListOf()
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AccountViewHolder(
- LayoutInflater.from(parent.context!!).inflate(R.layout.list_item_budget, parent, false)
- )
-
- override fun getItemCount(): Int = accounts.size
-
- override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
- val account = accounts[holder.adapterPosition]
- holder.title.text = account.name
- holder.balance.text = account.name
- }
-
- class AccountViewHolder(
- itemView: View,
- val title: TextView = itemView.findViewById(R.id.budgetName),
- val balance: TextView = itemView.findViewById(R.id.budgetBalance)
- ) : RecyclerView.ViewHolder(itemView)
-}
\ No newline at end of file
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
deleted file mode 100644
index 216fc47..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewFragment.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package com.wbrawner.budget.ui.overview
-
-import android.os.Bundle
-import android.util.Log
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.Observer
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.R
-import com.wbrawner.budget.ui.toAmountSpannable
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.android.synthetic.main.fragment_overview.*
-
-@AndroidEntryPoint
-class OverviewFragment : Fragment() {
- val viewModel: OverviewViewModel by viewModels()
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? = inflater.inflate(R.layout.fragment_overview, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- viewModel.state.observe(viewLifecycleOwner, Observer { state ->
- when (state) {
- is AsyncState.Loading -> {
- overviewContent.visibility = View.GONE
- noData.visibility = View.GONE
- progressBar.visibility = View.VISIBLE
- }
- is AsyncState.Success -> {
- overviewContent.visibility = View.VISIBLE
- noData.visibility = View.GONE
- progressBar.visibility = View.GONE
- activity?.title = state.data.budget.name
- balance.text = state.data.balance.toAmountSpannable(view.context)
- }
- is AsyncState.Error -> {
- overviewContent.visibility = View.GONE
- progressBar.visibility = View.GONE
- noData.visibility = View.VISIBLE
- Log.e("OverviewFragment", "Failed to load overview", state.exception)
- }
- else -> { /* no-op */ }
- }
- })
- }
-
- override fun onStart() {
- super.onStart()
- viewModel.loadOverview()
- }
-
- companion object {
- const val EXTRA_BUDGET_ID = "budgetId"
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewViewModel.kt
deleted file mode 100644
index 1f74f61..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/overview/OverviewViewModel.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.wbrawner.budget.ui.overview
-
-import androidx.lifecycle.*
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.transaction.TransactionRepository
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class OverviewViewModel @Inject constructor(
- val budgetRepo: BudgetRepository,
- val transactionRepo: TransactionRepository
-) : ViewModel() {
- val state = MutableLiveData>(AsyncState.Loading)
-
- fun loadOverview() {
- viewModelScope.launch {
- budgetRepo.currentBudget.collect { budget ->
- if (budget == null) {
- state.postValue(AsyncState.Error("Invalid Budget ID"))
- return@collect
- }
- viewModelScope.launch {
- state.postValue(AsyncState.Loading)
- try {
- // TODO: Load expected and actual income/expense amounts as well
- state.postValue(
- AsyncState.Success(
- OverviewState(
- budget,
- budgetRepo.getBalance(budget.id!!)
- )
- )
- )
- } catch (e: Exception) {
- state.postValue(AsyncState.Error(e))
- }
- }
- }
- }
- }
-}
-
-data class OverviewState(
- val budget: Budget,
- val balance: Long
-)
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/profile/ProfileFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/profile/ProfileFragment.kt
deleted file mode 100644
index ac3ce5b..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/profile/ProfileFragment.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.wbrawner.budget.ui.profile
-
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import com.wbrawner.budget.R
-
-/**
- * A simple [Fragment] subclass.
- */
-class ProfileFragment : Fragment() {
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
- savedInstanceState: Bundle?): View? {
- // Inflate the layout for this fragment
- return inflater.inflate(R.layout.fragment_profile, container, false)
- }
-
-
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionDetailsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionDetailsScreen.kt
new file mode 100644
index 0000000..bf09c89
--- /dev/null
+++ b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionDetailsScreen.kt
@@ -0,0 +1,144 @@
+package com.wbrawner.budget.ui.transaction
+
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.wbrawner.budget.ui.TwigsScaffold
+import com.wbrawner.budget.ui.base.TwigsApp
+import com.wbrawner.budget.ui.util.format
+import com.wbrawner.twigs.shared.Action
+import com.wbrawner.twigs.shared.Store
+import com.wbrawner.twigs.shared.budget.Budget
+import com.wbrawner.twigs.shared.category.Category
+import com.wbrawner.twigs.shared.transaction.Transaction
+import com.wbrawner.twigs.shared.transaction.TransactionAction
+import com.wbrawner.twigs.shared.user.User
+import kotlinx.datetime.Clock
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TransactionDetailsScreen(store: Store) {
+ val state by store.state.collectAsState()
+ val transaction = remember { state.transactions!!.first { it.id == state.selectedTransaction } }
+ val createdBy = state.selectedTransactionCreatedBy ?: run {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ return
+ }
+ val category = state.categories?.firstOrNull { it.id == transaction.categoryId }
+ val budget = state.budgets!!.first { it.id == transaction.budgetId }
+
+ TwigsScaffold(
+ store = store,
+ title = "Transaction Details",
+ navigationIcon = {
+ IconButton(onClick = { store.dispatch(Action.Back) }) {
+ Icon(Icons.Default.ArrowBack, "Go back")
+ }
+ },
+ actions = {
+ IconButton({ store.dispatch(TransactionAction.EditTransaction(requireNotNull(transaction.id))) }) {
+ Icon(Icons.Default.Edit, "Edit")
+ }
+ }
+ ) { padding ->
+ TransactionDetails(
+ modifier = Modifier.padding(padding),
+ transaction = transaction,
+ category = category,
+ budget = budget,
+ createdBy = createdBy
+ )
+ if (state.editingTransaction) {
+ TransactionFormDialog(store = store)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TransactionDetails(
+ modifier: Modifier = Modifier,
+ transaction: Transaction,
+ category: Category? = null,
+ budget: Budget,
+ createdBy: User
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .scrollable(scrollState, Orientation.Vertical)
+ .padding(16.dp),
+ verticalArrangement = spacedBy(8.dp)
+ ) {
+ Text(
+ text = transaction.title,
+ style = MaterialTheme.typography.headlineMedium
+ )
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ text = transaction.date.format(LocalContext.current),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = transaction.amount.toCurrencyString(),
+ style = MaterialTheme.typography.headlineSmall,
+ color = if (transaction.expense) Color.Red else Color.Green
+ )
+ }
+ LabeledField("Description", transaction.description ?: "")
+ LabeledField("Category", category?.title ?: "")
+ LabeledField("Created By", createdBy.username)
+ }
+}
+
+@Composable
+fun LabeledField(label: String, field: String) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(text = label, style = MaterialTheme.typography.bodySmall)
+ Text(text = field, style = MaterialTheme.typography.bodyLarge)
+ }
+}
+
+@Composable
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+fun TransactionDetails_Preview() {
+ TwigsApp {
+ TransactionDetails(
+ transaction = Transaction(
+ title = "DAZBOG",
+ description = "Chokolat Cappuccino",
+ date = Clock.System.now(),
+ amount = 550,
+ categoryId = "coffee",
+ budgetId = "budget",
+ createdBy = "user",
+ expense = true
+ ),
+ category = Category(title = "Coffee", budgetId = "budget", amount = 1000),
+ budget = Budget(name = "Monthly Budget"),
+ createdBy = User(username = "user")
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionForm.kt b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionForm.kt
new file mode 100644
index 0000000..1bad149
--- /dev/null
+++ b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionForm.kt
@@ -0,0 +1,325 @@
+package com.wbrawner.budget.ui.transaction
+
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.isShiftPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.wbrawner.budget.ui.base.TwigsApp
+import com.wbrawner.budget.ui.util.DatePicker
+import com.wbrawner.twigs.shared.Store
+import com.wbrawner.twigs.shared.category.Category
+import com.wbrawner.twigs.shared.transaction.Transaction
+import com.wbrawner.twigs.shared.transaction.TransactionAction
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import java.util.*
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun TransactionFormDialog(store: Store) {
+ Dialog(
+ onDismissRequest = { store.dispatch(TransactionAction.CancelEditTransaction) },
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ decorFitsSystemWindows = false
+ )
+ ) {
+ TransactionForm(store)
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TransactionForm(store: Store) {
+ val state by store.state.collectAsState()
+ val transaction = remember {
+ val defaultTransaction = Transaction(
+ title = "",
+ date = Clock.System.now(),
+ amount = 0L,
+ budgetId = state.selectedBudget!!,
+ categoryId = state.selectedCategory,
+ expense = true,
+ createdBy = state.user!!.id!!
+ )
+ if (state.selectedTransaction.isNullOrBlank()) {
+ defaultTransaction
+ } else {
+ state.transactions?.first { it.id == state.selectedTransaction } ?: defaultTransaction
+ }
+ }
+ val (title, setTitle) = remember { mutableStateOf(transaction.title) }
+ val (description, setDescription) = remember { mutableStateOf(transaction.description ?: "") }
+ val (date, setDate) = remember { mutableStateOf(transaction.date) }
+ val (amount, setAmount) = remember { mutableStateOf(transaction.amount.toDecimalString()) }
+ val (expense, setExpense) = remember { mutableStateOf(transaction.expense) }
+ val budget = remember { state.budgets!!.first { it.id == transaction.budgetId } }
+ val (category, setCategory) = remember { mutableStateOf(transaction.categoryId?.let { categoryId -> state.categories?.firstOrNull { it.id == categoryId } }) }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ navigationIcon = {
+ IconButton(onClick = { store.dispatch(TransactionAction.CancelEditTransaction) }) {
+ Icon(Icons.Default.Close, "Cancel")
+ }
+ },
+ title = {
+ Text(if (transaction.id.isNullOrBlank()) "New Transaction" else "Edit Transaction")
+ }
+ )
+ }
+ ) {
+ TransactionForm(
+ modifier = Modifier.padding(it),
+ title = title,
+ setTitle = setTitle,
+ description = description,
+ setDescription = setDescription,
+ date = date,
+ setDate = setDate,
+ amount = amount,
+ setAmount = setAmount,
+ expense = expense,
+ setExpense = setExpense,
+ categories = state.categories?.filter { c -> c.expense == expense } ?: emptyList(),
+ category = category,
+ setCategory = setCategory
+ ) {
+ store.dispatch(
+ transaction.id?.let { id ->
+ TransactionAction.UpdateTransaction(
+ id = id,
+ title = title,
+ amount = (amount.toDouble() * 100).toLong(),
+ date = date,
+ expense = expense,
+ category = category,
+ budget = budget
+ )
+ } ?: TransactionAction.CreateTransaction(
+ title = title,
+ description = description,
+ amount = (amount.toDouble() * 100).toLong(),
+ date = date,
+ expense = expense,
+ category = category,
+ budget = budget
+ )
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun TransactionForm(
+ modifier: Modifier,
+ title: String,
+ setTitle: (String) -> Unit,
+ description: String,
+ setDescription: (String) -> Unit,
+ date: Instant,
+ setDate: (Instant) -> Unit,
+ amount: String,
+ setAmount: (String) -> Unit,
+ expense: Boolean,
+ setExpense: (Boolean) -> Unit,
+ categories: List,
+ category: Category?,
+ setCategory: (Category?) -> Unit,
+ save: () -> Unit
+) {
+ val scrollState = rememberScrollState()
+ val (titleInput, descriptionInput, amountInput, dateInput) = FocusRequester.createRefs()
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(16.dp),
+ verticalArrangement = spacedBy(8.dp, Alignment.Top),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+// if (error.isNotBlank()) {
+// Text(text = error, color = Color.Red)
+// }
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(titleInput)
+ .onPreviewKeyEvent {
+ if (it.key == Key.Tab && !it.isShiftPressed) {
+ descriptionInput.requestFocus()
+ true
+ } else {
+ false
+ }
+ },
+ value = title,
+ onValueChange = setTitle,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Text,
+ capitalization = KeyboardCapitalization.Words,
+ imeAction = ImeAction.Next
+ ),
+ label = { Text("Title") },
+ keyboardActions = KeyboardActions(onNext = {
+ descriptionInput.requestFocus()
+ }),
+ maxLines = 1
+ )
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(descriptionInput)
+ .onPreviewKeyEvent {
+ if (it.key == Key.Tab) {
+ if (it.isShiftPressed) {
+ titleInput.requestFocus()
+ } else {
+ amountInput.requestFocus()
+ }
+ true
+ } else {
+ false
+ }
+ },
+ value = description,
+ onValueChange = setDescription,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Text,
+ capitalization = KeyboardCapitalization.Sentences,
+ imeAction = ImeAction.Next
+ ),
+ label = { Text("Description") },
+ keyboardActions = KeyboardActions(onNext = {
+ amountInput.requestFocus()
+ }),
+ )
+ val keyboardController = LocalSoftwareKeyboardController.current
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(amountInput)
+ .onPreviewKeyEvent {
+ if (it.key == Key.Tab && it.isShiftPressed) {
+ descriptionInput.requestFocus()
+ true
+ } else {
+ false
+ }
+ },
+ value = amount,
+ onValueChange = setAmount,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Decimal,
+ imeAction = ImeAction.Next
+ ),
+ label = { Text("Amount") },
+ keyboardActions = KeyboardActions(onNext = {
+ keyboardController?.hide()
+ }),
+ )
+ val (datePickerVisible, setDatePickerVisible) = remember { mutableStateOf(false) }
+ DatePicker(
+ modifier = Modifier.fillMaxWidth(),
+ date = date,
+ setDate = setDate,
+ dialogVisible = datePickerVisible,
+ setDialogVisible = setDatePickerVisible
+ )
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = spacedBy(8.dp)) {
+ Row(
+ modifier = Modifier.clickable {
+ setExpense(true)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(selected = expense, onClick = { setExpense(true) })
+ Text(text = "Expense")
+ }
+ Row(
+ modifier = Modifier.clickable {
+ setExpense(false)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(selected = !expense, onClick = { setExpense(false) })
+ Text(text = "Income")
+ }
+ }
+ val (categoriesExpanded, setCategoriesExpanded) = remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(
+ modifier = Modifier
+ .fillMaxWidth(),
+ expanded = categoriesExpanded,
+ onExpandedChange = setCategoriesExpanded,
+ ) {
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(),
+ value = category?.title ?: "",
+ onValueChange = {},
+ readOnly = true,
+ label = {
+ Text("Category")
+ },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoriesExpanded)
+ }
+ )
+ ExposedDropdownMenu(expanded = categoriesExpanded, onDismissRequest = {
+ setCategoriesExpanded(false)
+ }) {
+ categories.forEach { c ->
+ DropdownMenuItem(
+ text = { Text(c.title) },
+ onClick = {
+ setCategory(c)
+ setCategoriesExpanded(false)
+ }
+ )
+ }
+ }
+ }
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = save
+ ) {
+ Text("Save")
+ }
+ }
+}
+
+@Composable
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+fun TransactionForm_Preview() {
+ TwigsApp {
+ TransactionForm(store = Store(reducers = emptyList()))
+ }
+}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionsScreen.kt
new file mode 100644
index 0000000..e34a6e3
--- /dev/null
+++ b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionsScreen.kt
@@ -0,0 +1,141 @@
+package com.wbrawner.budget.ui.transaction
+
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.wbrawner.budget.ui.TwigsScaffold
+import com.wbrawner.budget.ui.base.TwigsApp
+import com.wbrawner.budget.ui.util.format
+import com.wbrawner.twigs.shared.Store
+import com.wbrawner.twigs.shared.transaction.Transaction
+import com.wbrawner.twigs.shared.transaction.TransactionAction
+import com.wbrawner.twigs.shared.transaction.groupByDate
+import kotlinx.datetime.Clock
+import kotlinx.datetime.toInstant
+import java.text.NumberFormat
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TransactionsScreen(store: Store) {
+ TwigsScaffold(
+ store = store,
+ title = "Transactions",
+ onClickFab = {
+ store.dispatch(TransactionAction.NewTransactionClicked)
+ }
+ ) {
+ val state by store.state.collectAsState()
+ state.transactions?.let { transactions ->
+ val transactionGroups = remember { transactions.groupByDate() }
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(it)
+ ) {
+ transactionGroups.forEach { (timestamp, transactions) ->
+ item(timestamp) {
+ Text(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ text = timestamp.toInstant().format(LocalContext.current),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ itemsIndexed(transactions) { i, transaction ->
+ TransactionListItem(transaction) {
+ store.dispatch(TransactionAction.SelectTransaction(transaction.id))
+ }
+ if (i != transactions.lastIndex) {
+ Divider(modifier = Modifier.padding(horizontal = 8.dp))
+ }
+ }
+ item {
+ Spacer(
+ Modifier
+ .fillMaxWidth()
+ .height(32.dp))
+ }
+ }
+ }
+ } ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ if (state.editingTransaction) {
+ TransactionFormDialog(store = store)
+ }
+ }
+}
+
+@Composable
+fun TransactionListItem(transaction: Transaction, onClick: (Transaction) -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick(transaction) }
+ .padding(8.dp)
+ .heightIn(min = 56.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = spacedBy(4.dp)
+ ) {
+ Text(transaction.title, style = MaterialTheme.typography.bodyLarge)
+ if (!transaction.description.isNullOrBlank()) {
+ Text(
+ transaction.description!!,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ Text(
+ transaction.amount.toCurrencyString(),
+ color = if (transaction.expense) Color.Red else Color.Green,
+ )
+ }
+}
+
+@Composable
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+fun TransactionListItem_Preview() {
+ TwigsApp {
+ TransactionListItem(
+ transaction = Transaction(
+ title = "Google Store",
+ description = "Pixel 7 Pro",
+ date = Clock.System.now(),
+ amount = 129999,
+ budgetId = "budgetId",
+ expense = true,
+ createdBy = "createdBy"
+ )
+ ) {}
+ }
+}
+
+fun Long.toCurrencyString(): String =
+ NumberFormat.getCurrencyInstance().format(this.toDouble() / 100.0)
+
+fun Long.toDecimalString(): String = if (this > 0) (this.toDouble() / 100.0).toString() else ""
\ No newline at end of file
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
deleted file mode 100644
index 62e2ee7..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormActivity.kt
+++ /dev/null
@@ -1,442 +0,0 @@
-package com.wbrawner.budget.ui.transactions
-
-import android.app.TimePickerDialog
-import android.content.Intent
-import android.os.Bundle
-import android.text.Editable
-import android.text.format.DateFormat
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.widget.AdapterView
-import android.widget.ArrayAdapter
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-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
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.tooling.preview.Devices
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.core.app.NavUtils
-import androidx.core.app.TaskStackBuilder
-import com.google.android.material.datepicker.MaterialDatePicker
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.budget.Budget
-import com.wbrawner.budget.common.category.Category
-import com.wbrawner.budget.common.transaction.Transaction
-import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
-import com.wbrawner.budget.ui.MainActivity
-import com.wbrawner.budget.ui.base.TwigsApp
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.android.synthetic.main.activity_add_edit_transaction.*
-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
-
-@AndroidEntryPoint
-class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
- override val coroutineContext: CoroutineContext = Dispatchers.Main
-
- private val viewModel: TransactionFormViewModel by viewModels()
- var id: String? = null
- var menu: Menu? = null
- var transaction: Transaction? = null
-
- @Suppress("DEPRECATION")
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_add_edit_transaction)
- setSupportActionBar(action_bar)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- setTitle(R.string.title_add_transaction)
- edit_transaction_type_expense.isChecked = true
- launch {
- val accounts = viewModel.getAccounts().toTypedArray()
- setCategories()
- budgetSpinner.adapter = ArrayAdapter(
- this@TransactionFormActivity,
- android.R.layout.simple_list_item_1,
- accounts
- )
- container_edit_transaction_type.setOnCheckedChangeListener { _, _ ->
- this@TransactionFormActivity.launch {
- val budget = budgetSpinner.selectedItem as Budget
- setCategories(
- viewModel.getCategories(
- budget.id!!,
- edit_transaction_type_expense.isChecked
- )
- )
- }
- }
- budgetSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onNothingSelected(parent: AdapterView<*>?) {
- }
-
- override fun onItemSelected(
- parent: AdapterView<*>?,
- view: View?,
- position: Int,
- id: Long
- ) {
- this@TransactionFormActivity.launch {
- val budget = budgetSpinner.selectedItem as Budget
- setCategories(
- viewModel.getCategories(
- budget.id!!,
- edit_transaction_type_expense.isChecked
- )
- )
- }
- }
- }
- val budgetIndex = if (transaction?.budgetId != null) {
- accounts.indexOfFirst { it.id == transaction?.budgetId }
- } else {
- accounts.indexOfFirst { it.id == viewModel.budgetRepository.currentBudget.replayCache.firstOrNull()?.id }
- }
- budgetSpinner.setSelection(max(0, budgetIndex))
- loadTransaction()
- transactionDate.setOnClickListener {
- val currentDate = DateFormat.getDateFormat(this@TransactionFormActivity)
- .parse(transactionDate.text.toString()) ?: Date()
- MaterialDatePicker.Builder.datePicker()
- .setSelection(currentDate.time)
- .setTheme(R.style.DateTimePickerDialogTheme)
- .build()
- .also { picker ->
- picker.addOnPositiveButtonClickListener {
- transactionDate.text =
- DateFormat.getDateFormat(this@TransactionFormActivity)
- .apply {
- timeZone = TimeZone.getTimeZone("UTC")
- }
- .format(Date(it))
- }
- }
- .show(supportFragmentManager, null)
- }
- transactionTime.setOnClickListener {
- val currentDate = DateFormat.getTimeFormat(this@TransactionFormActivity)
- .parse(transactionTime.text.toString()) ?: Date()
- TimePickerDialog(
- this@TransactionFormActivity,
- { _, hourOfDay, minute ->
- val newTime = Date().apply {
- hours = hourOfDay
- minutes = minute
- }
- transactionTime.text =
- DateFormat.getTimeFormat(this@TransactionFormActivity)
- .format(newTime)
- },
- currentDate.hours,
- currentDate.minutes,
- DateFormat.is24HourFormat(this@TransactionFormActivity)
- ).show()
- }
- }
- }
-
- private suspend fun loadTransaction() {
- transaction = try {
- viewModel.getTransaction(intent!!.extras!!.getString(EXTRA_TRANSACTION_ID)!!)
- } catch (e: Exception) {
- menu?.findItem(R.id.action_delete)?.isVisible = false
- val date = Date()
- transactionDate.text = DateFormat.getDateFormat(this).format(date)
- transactionTime.text = DateFormat.getTimeFormat(this).format(date)
- return
- }
- setTitle(R.string.title_edit_transaction)
- id = transaction?.id
- menu?.findItem(R.id.action_delete)?.isVisible = true
- edit_transaction_title.setText(transaction?.title)
- edit_transaction_description.setText(transaction?.description)
- edit_transaction_amount.setText(String.format("%.02f", transaction!!.amount / 100.0f))
- if (transaction!!.expense) {
- edit_transaction_type_expense.isChecked = true
- } else {
- edit_transaction_type_income.isChecked = true
- }
- transactionDate.text = DateFormat.getDateFormat(this).format(transaction!!.date)
- transactionTime.text = DateFormat.getTimeFormat(this).format(transaction!!.date)
- transaction?.categoryId?.let {
- for (i in 0 until edit_transaction_category.adapter.count) {
- if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
- edit_transaction_category.setSelection(i)
- break
- }
- }
- }
- }
-
- private fun setCategories(categories: List = emptyList()) {
- val adapter = ArrayAdapter(
- this@TransactionFormActivity,
- android.R.layout.simple_list_item_1
- )
- adapter.add(
- Category(
- title = getString(R.string.uncategorized),
- amount = 0, budgetId = ""
- )
- )
- adapter.addAll(categories)
- edit_transaction_category.adapter = adapter
- transaction?.categoryId?.let {
- for (i in 0 until edit_transaction_category.adapter.count) {
- if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
- edit_transaction_category.setSelection(i)
- break
- }
- }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.menu_add_edit, menu)
- if (id != null) {
- menu?.findItem(R.id.action_delete)?.isVisible = true
- }
- this.menu = menu
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> onNavigateUp()
- R.id.action_save -> {
- val date = GregorianCalendar.getInstance().apply {
- DateFormat.getDateFormat(this@TransactionFormActivity)
- .parse(transactionDate.text.toString())
- ?.let {
- time = it
- }
- DateFormat.getTimeFormat(this@TransactionFormActivity)
- .parse(transactionTime.text.toString())
- ?.let { GregorianCalendar.getInstance().apply { time = it } }
- ?.let {
- set(Calendar.HOUR_OF_DAY, it.get(Calendar.HOUR_OF_DAY))
- set(Calendar.MINUTE, it.get(Calendar.MINUTE))
- }
- }
- val categoryId = (edit_transaction_category.selectedItem as? Category)?.id
- launch {
- viewModel.saveTransaction(
- Transaction(
- id = id,
- budgetId = (budgetSpinner.selectedItem as Budget).id!!,
- title = edit_transaction_title.text.toString(),
- date = date.time,
- description = edit_transaction_description.text.toString(),
- amount = (BigDecimal(edit_transaction_amount.text.toString()) * 100.toBigDecimal()).toLong(),
- expense = edit_transaction_type_expense.isChecked,
- categoryId = categoryId,
- createdBy = viewModel.currentUserId!!
- )
- )
- onNavigateUp()
- }
- }
- R.id.action_delete -> {
- launch {
- viewModel.deleteTransaction(this@TransactionFormActivity.id!!)
- onNavigateUp()
- }
- }
- }
- return true
- }
-
- override fun onNavigateUp(): Boolean {
- val upIntent: Intent = NavUtils.getParentActivityIntent(this)
- ?: throw IllegalStateException("No Parent Activity Intent")
-
- upIntent.putExtra(MainActivity.EXTRA_OPEN_FRAGMENT, TransactionListFragment.TAG_FRAGMENT)
- when {
- NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
- TaskStackBuilder.create(this)
- .addNextIntentWithParentStack(upIntent)
- .startActivities()
- }
- else -> {
- finish()
- }
- }
-
- return true
- }
-}
-
-fun Editable?.toLong(): Long = toString().toDouble().toLong() * 100
-
-@Composable
-fun TransactionForm(
- title: String,
- setTitle: (String) -> Unit,
- description: String,
- setDescription: (String) -> Unit,
- amount: String,
- setAmount: (String) -> Unit,
- expense: Boolean,
- setExpense: (Boolean) -> Unit,
- date: Long,
- setDate: (Long) -> Unit,
- budget: Budget,
- setBudget: (Budget) -> Unit,
- budgets: List,
- category: Category,
- setCategory: (Category) -> Unit,
- categories: List,
-) {
- val scrollState = rememberScrollState()
- val (budgetsExpanded, setBudgetsExpanded) = remember { mutableStateOf(false) }
- val (categoriesExpanded, setCategoriesExpanded) = remember { mutableStateOf(false) }
- val context = LocalContext.current
- val formattedDate = remember(date) { DateFormat.getDateFormat(context).format(Date(date)) }
- val formattedTime = remember(date) { DateFormat.getTimeFormat(context).format(Date(date)) }
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(16.dp)
- .verticalScroll(scrollState),
- verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically)
- ) {
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = title,
- onValueChange = setTitle,
- placeholder = { Text("Title") },
- maxLines = 1
- )
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = description,
- onValueChange = setDescription,
- placeholder = { Text("Description") }
- )
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = amount,
- onValueChange = setAmount,
- placeholder = { Text("Amount") },
- maxLines = 1,
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number)
- )
- Row(modifier = Modifier.fillMaxWidth()) {
- RadioButton(selected = expense, onClick = { setExpense(true) })
- Text("Expense")
- Spacer(modifier = Modifier.width(8.dp))
- RadioButton(selected = !expense, onClick = { setExpense(false) })
- Text("Income")
- }
- Text(text = "Date", style = MaterialTheme.typography.caption)
- Row(modifier = Modifier.fillMaxWidth()) {
- TextButton(
- onClick = { /*TODO: Show date picker dialog */ },
- colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.onSurface)
- ) {
- Text(formattedDate)
- }
- TextButton(
- onClick = { /*TODO: Show time picker dialog */ },
- colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.onSurface)
- ) {
- Text(formattedTime)
- }
- }
- Spinner("Budget", budget.name, { it.name }, budgets, setBudget, budgetsExpanded, setBudgetsExpanded)
- Spinner("Categories", category.title, { it.title }, categories, setCategory, categoriesExpanded, setCategoriesExpanded)
- }
-}
-
-@Composable
-fun Spinner(
- title: String,
- selectedItemTitle: String,
- itemTitle: (T) -> String,
- items: List,
- selectItem: (T) -> Unit,
- expanded: Boolean,
- setExpanded: (Boolean) -> Unit
-) {
- Column(modifier = Modifier.fillMaxWidth()) {
- Text(text = title, style = MaterialTheme.typography.caption)
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = selectedItemTitle,
- onValueChange = {},
- readOnly = true
- )
- DropdownMenu(
- modifier = Modifier.fillMaxWidth(),
- expanded = expanded,
- onDismissRequest = { setExpanded(false) }
- ) {
- items.forEach {
- DropdownMenuItem(onClick = { selectItem(it) }) {
- Text(itemTitle(it))
- }
- }
- }
- }
-}
-
-@Composable
-@Preview
-fun TransactionForm_Preview() {
- TwigsApp {
- TransactionForm(
- title = "",
- setTitle = { },
- description = "",
- setDescription = { },
- amount = "",
- setAmount = { },
- expense = false,
- setExpense = { },
- date = System.currentTimeMillis(),
- setDate = {},
- budget = Budget(name = "Uncategorized"),
- setBudget = {},
- budgets = emptyList(),
- category = Category("budget", title = "Uncategorized", amount = 0L),
- setCategory = {},
- categories = emptyList()
- )
- }
-}
-
-@Composable
-@Preview(heightDp = 400)
-fun Spinner_ExpandedPreview() {
- TwigsApp {
- Spinner(
- "Items",
- "Uncategorized",
- { it },
- listOf("one", "two", "three"),
- {},
- true,
- {}
- )
- }
-}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormViewModel.kt
deleted file mode 100644
index 0af8b43..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionFormViewModel.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.wbrawner.budget.ui.transactions
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.category.CategoryRepository
-import com.wbrawner.budget.common.transaction.Transaction
-import com.wbrawner.budget.common.transaction.TransactionRepository
-import com.wbrawner.budget.common.user.UserRepository
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class TransactionFormViewModel @Inject constructor(
- val budgetRepository: BudgetRepository,
- val categoryRepository: CategoryRepository,
- val transactionRepository: TransactionRepository,
- val userRepository: UserRepository,
-) : ViewModel() {
-
- var currentUserId: String? = null
- private set
-
- init {
- viewModelScope.launch {
- userRepository.currentUser.collect {
- currentUserId = it?.id
- }
- }
- }
-
- suspend fun getCategories(budgetId: String, expense: Boolean) = categoryRepository.findAll(arrayOf(budgetId)).filter {
- it.expense == expense
- }
-
- suspend fun getTransaction(id: String) = transactionRepository.findById(id)
-
- suspend fun saveTransaction(transaction: Transaction) = if (transaction.id == null)
- transactionRepository.create(transaction)
- else
- transactionRepository.update(transaction)
-
- suspend fun deleteTransaction(id: String) = transactionRepository.delete(id)
-
- suspend fun getAccounts() = budgetRepository.findAll()
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionListFragment.kt b/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionListFragment.kt
deleted file mode 100644
index 6e87555..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionListFragment.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-package com.wbrawner.budget.ui.transactions
-
-import android.annotation.SuppressLint
-import android.content.Intent
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuInflater
-import android.view.MenuItem
-import android.view.View
-import android.widget.TextView
-import androidx.appcompat.app.AlertDialog
-import androidx.core.content.ContextCompat
-import androidx.fragment.app.viewModels
-import androidx.navigation.NavController
-import androidx.navigation.fragment.findNavController
-import com.wbrawner.budget.R
-import com.wbrawner.budget.common.transaction.Transaction
-import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
-import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
-import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
-import com.wbrawner.budget.ui.base.BindableAdapter
-import com.wbrawner.budget.ui.base.BindableData
-import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
-import dagger.hilt.android.AndroidEntryPoint
-import java.text.SimpleDateFormat
-
-@AndroidEntryPoint
-class TransactionListFragment : ListWithAddButtonFragment() {
- override val noItemsStringRes: Int = R.string.transactions_no_data
- override val viewModel: TransactionListViewModel by viewModels()
- override val constructors: Map BindableAdapter.BindableViewHolder>
- get() = mapOf(TRANSACTION_VIEW to { v -> TransactionViewHolder(v, findNavController()) })
-
- override fun reloadItems() {
- viewModel.getTransactions(categoryId = arguments?.getString(EXTRA_CATEGORY_ID))
- }
-
- override fun bindData(data: Transaction): BindableData = BindableData(data, TRANSACTION_VIEW)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.menu_transaction_list, menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId != R.id.filter) {
- return super.onOptionsItemSelected(item)
- }
- // TODO: Launch a Google Drive-style search/filter screen
- AlertDialog.Builder(requireContext(), R.style.DialogTheme)
- .setTitle("Filter Transactions")
- .setPositiveButton(R.string.action_submit) { _, _ ->
- reloadItems()
- }
- .setNegativeButton(R.string.action_cancel) { _, _ ->
- // Do nothing
- }
- .create()
- .show()
- return true
- }
-
- override fun addItem() {
- startActivity(Intent(activity, TransactionFormActivity::class.java))
- }
-
- companion object {
- const val TAG_FRAGMENT = "transactions"
- }
-}
-
-const val TRANSACTION_VIEW = R.layout.list_item_transaction
-
-class TransactionViewHolder(itemView: View, val navController: NavController) : BindableAdapter.CoroutineViewHolder(itemView) {
- private val name: TextView = itemView.findViewById(R.id.transaction_title)
- private val description: TextView = itemView.findViewById(R.id.transaction_description)
- private val date: TextView = itemView.findViewById(R.id.transaction_date)
- private val amount: TextView = itemView.findViewById(R.id.transaction_amount)
-
- @SuppressLint("NewApi")
- override fun onBind(item: BindableData) {
- val transaction = item.data
- name.text = transaction.title
- if (transaction.description.isNullOrBlank()) {
- description.visibility = View.GONE
- } else {
- description.visibility = View.VISIBLE
- description.text = transaction.description
- }
- date.text = SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT).format(transaction.date)
- amount.text = String.format("${'$'}%.02f", transaction.amount / 100.0f)
- val context = itemView.context
- val color = if (transaction.expense) R.color.colorTextRed else R.color.colorTextGreen
- amount.setTextColor(ContextCompat.getColor(context, color))
- itemView.setOnClickListener {
- val bundle = Bundle().apply {
- putString(EXTRA_BUDGET_ID, transaction.budgetId)
- putString(EXTRA_TRANSACTION_ID, transaction.id)
- }
- navController.navigate(R.id.addEditTransactionActivity, bundle)
- }
- }
-}
\ No newline at end of file
diff --git a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionListViewModel.kt b/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionListViewModel.kt
deleted file mode 100644
index a30fea6..0000000
--- a/android/src/main/java/com/wbrawner/budget/ui/transactions/TransactionListViewModel.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.wbrawner.budget.ui.transactions
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.wbrawner.budget.AsyncState
-import com.wbrawner.budget.AsyncViewModel
-import com.wbrawner.budget.common.budget.BudgetRepository
-import com.wbrawner.budget.common.transaction.Transaction
-import com.wbrawner.budget.common.transaction.TransactionRepository
-import com.wbrawner.budget.load
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@HiltViewModel
-class TransactionListViewModel @Inject constructor(
- val budgetRepository: BudgetRepository,
- val transactionRepo: TransactionRepository,
-) : ViewModel(), AsyncViewModel> {
- override val state: MutableStateFlow>> =
- MutableStateFlow(AsyncState.Loading)
-
- fun getTransactions(
- categoryId: String? = null,
- ) {
- viewModelScope.launch {
- budgetRepository.currentBudget.collect { budget ->
- val budgets = budget?.id?.let { listOf(it) }
- val categories = categoryId?.let { listOf(it) }
- load {
- transactionRepo.findAll(budgets, categories).toList()
- }
- }
- }
- }
-}
diff --git a/android/src/main/java/com/wbrawner/budget/ui/util/DatePicker.kt b/android/src/main/java/com/wbrawner/budget/ui/util/DatePicker.kt
new file mode 100644
index 0000000..c5a2832
--- /dev/null
+++ b/android/src/main/java/com/wbrawner/budget/ui/util/DatePicker.kt
@@ -0,0 +1,101 @@
+package com.wbrawner.budget.ui.util
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.text.format.DateFormat
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.clickable
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.fragment.app.FragmentManager
+import com.google.android.material.datepicker.MaterialDatePicker
+import com.wbrawner.budget.R
+import kotlinx.datetime.Instant
+import java.util.TimeZone
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DatePicker(
+ modifier: Modifier,
+ date: Instant,
+ setDate: (Instant) -> Unit,
+ dialogVisible: Boolean,
+ setDialogVisible: (Boolean) -> Unit
+) {
+ Log.d("DatePicker", "date input: ${date.toEpochMilliseconds()}")
+ val context = LocalContext.current
+ OutlinedTextField(
+ modifier = modifier
+ .clickable {
+ Log.d("DatePicker", "click!")
+ setDialogVisible(true)
+ }
+ .focusRequester(FocusRequester())
+ .onFocusChanged {
+ setDialogVisible(it.hasFocus)
+ },
+ value = date.format(context),
+ onValueChange = {},
+ readOnly = true,
+ label = {
+ Text("Date")
+ }
+ )
+ val dialog = remember {
+ MaterialDatePicker.Builder.datePicker()
+ .setSelection(date.toEpochMilliseconds())
+ .setTheme(R.style.DateTimePickerDialogTheme)
+ .build()
+ .also { picker ->
+ picker.addOnPositiveButtonClickListener {
+ setDate(Instant.fromEpochMilliseconds(it))
+ }
+ picker.addOnDismissListener {
+ setDialogVisible(false)
+ }
+ }
+ }
+ DisposableEffect(key1 = dialogVisible) {
+ if (dialogVisible) {
+ context.fragmentManager?.let {
+ dialog.show(it, null)
+ }
+ } else if (dialog.isVisible) {
+ dialog.dismiss()
+ }
+ onDispose {
+ if (dialog.isVisible) {
+ dialog.dismiss()
+ }
+ }
+ }
+}
+
+val Context.activity: AppCompatActivity?
+ get() = when (this) {
+ is AppCompatActivity -> this
+ is ContextWrapper -> baseContext.activity
+ else -> null
+ }
+
+val Context.fragmentManager: FragmentManager?
+ get() = this.activity?.supportFragmentManager
+
+fun Instant.format(context: Context): String =
+ DateFormat.getDateFormat(context)
+ .format(this.toEpochMilliseconds() - TimeZone.getDefault().rawOffset).also {
+ Log.d(
+ "DatePicker",
+ "offset: ${TimeZone.getDefault().rawOffset} adjusted time: ${this.toEpochMilliseconds() - TimeZone.getDefault().rawOffset}"
+ )
+ }
diff --git a/android/src/main/res/layout/activity_add_edit_category.xml b/android/src/main/res/layout/activity_add_edit_category.xml
deleted file mode 100644
index 013d398..0000000
--- a/android/src/main/res/layout/activity_add_edit_category.xml
+++ /dev/null
@@ -1,112 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/src/main/res/layout/activity_add_edit_transaction.xml b/android/src/main/res/layout/activity_add_edit_transaction.xml
deleted file mode 100644
index 1e35fff..0000000
--- a/android/src/main/res/layout/activity_add_edit_transaction.xml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/src/main/res/layout/activity_main.xml b/android/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 70184a5..0000000
--- a/android/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/src/main/res/layout/fragment_accounts.xml b/android/src/main/res/layout/fragment_accounts.xml
deleted file mode 100644
index bb526ca..0000000
--- a/android/src/main/res/layout/fragment_accounts.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/fragment_add_edit_budget.xml b/android/src/main/res/layout/fragment_add_edit_budget.xml
deleted file mode 100644
index f5dfff7..0000000
--- a/android/src/main/res/layout/fragment_add_edit_budget.xml
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/fragment_category_details.xml b/android/src/main/res/layout/fragment_category_details.xml
deleted file mode 100644
index 5350c9a..0000000
--- a/android/src/main/res/layout/fragment_category_details.xml
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/src/main/res/layout/fragment_list_with_add_button.xml b/android/src/main/res/layout/fragment_list_with_add_button.xml
deleted file mode 100644
index aba4f4d..0000000
--- a/android/src/main/res/layout/fragment_list_with_add_button.xml
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/fragment_overview.xml b/android/src/main/res/layout/fragment_overview.xml
deleted file mode 100644
index 026e5ce..0000000
--- a/android/src/main/res/layout/fragment_overview.xml
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/fragment_profile.xml b/android/src/main/res/layout/fragment_profile.xml
deleted file mode 100644
index 2717c25..0000000
--- a/android/src/main/res/layout/fragment_profile.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/fragment_transaction_list.xml b/android/src/main/res/layout/fragment_transaction_list.xml
deleted file mode 100644
index 138998e..0000000
--- a/android/src/main/res/layout/fragment_transaction_list.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/header_navigation_menu.xml b/android/src/main/res/layout/header_navigation_menu.xml
deleted file mode 100644
index 71b8c00..0000000
--- a/android/src/main/res/layout/header_navigation_menu.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/list_item_budget.xml b/android/src/main/res/layout/list_item_budget.xml
deleted file mode 100644
index 239c3b5..0000000
--- a/android/src/main/res/layout/list_item_budget.xml
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/list_item_category.xml b/android/src/main/res/layout/list_item_category.xml
deleted file mode 100644
index e9cfad4..0000000
--- a/android/src/main/res/layout/list_item_category.xml
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/src/main/res/layout/list_item_transaction.xml b/android/src/main/res/layout/list_item_transaction.xml
deleted file mode 100644
index 7be9fcd..0000000
--- a/android/src/main/res/layout/list_item_transaction.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/android/src/main/res/layout/list_item_user.xml b/android/src/main/res/layout/list_item_user.xml
deleted file mode 100644
index 9a8157e..0000000
--- a/android/src/main/res/layout/list_item_user.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4788bd9..8f811ff 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,8 +2,11 @@
androidx-core = "1.9.0"
androidx-appcompat = "1.5.1"
androidx-splash = "1.0.0"
-compose = "1.2.1"
+androidx-test-runner = "1.5.1"
+androidx-test-orchestrator = "1.4.2"
+compose = "1.3.1"
compose-compiler = "1.3.2"
+compose-material3 = "1.0.1"
espresso = "3.3.0"
hilt-android = "2.44"
kotlin = "1.7.20"
@@ -25,15 +28,22 @@ 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" }
+androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
+androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
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-material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }
+compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
+compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" }
+compose-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
+compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", 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-android-testing = { module = "com.google.dagger:hilt-android-testing", 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" }
@@ -60,7 +70,16 @@ 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"]
+compose = [
+ "compose-activity",
+ "compose-material",
+ "compose-material-icons",
+ "compose-material3",
+ "compose-material3-window",
+ "compose-tooling",
+ "compose-ui",
+ "navigation-compose"
+]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index 961d00a..85ba00e 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -20,7 +20,7 @@ kotlin {
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.kotlinx.coroutines.core)
- implementation(libs.kotlinx.datetime)
+ api(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
api(libs.multiplatform.settings)
}
diff --git a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt
index 56e79da..dc452e5 100644
--- a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt
+++ b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt
@@ -1,22 +1,37 @@
package com.wbrawner.pihelper.shared
+import com.russhwolf.settings.Settings
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.category.CategoryReducer
+import com.wbrawner.twigs.shared.category.NetworkCategoryRepository
import com.wbrawner.twigs.shared.network.KtorAPIService
import com.wbrawner.twigs.shared.network.commonConfig
-import io.ktor.client.*
-import io.ktor.client.engine.android.*
+import com.wbrawner.twigs.shared.transaction.NetworkTransactionRepository
+import com.wbrawner.twigs.shared.transaction.TransactionReducer
+import com.wbrawner.twigs.shared.user.ConfigReducer
+import com.wbrawner.twigs.shared.user.NetworkUserRepository
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.android.Android
val apiService = KtorAPIService(HttpClient(Android) {
commonConfig()
})
+val preferences = Settings()
+
val budgetRepository = NetworkBudgetRepository(apiService)
+val categoryRepository = NetworkCategoryRepository(apiService)
+val transactionRepository = NetworkTransactionRepository(apiService)
+val userRepository = NetworkUserRepository(apiService)
fun Store.Companion.create(
reducers: List = listOf(
- BudgetReducer(budgetRepository)
+ BudgetReducer(budgetRepository, preferences),
+ CategoryReducer(categoryRepository),
+ ConfigReducer(apiService, preferences),
+ TransactionReducer(transactionRepository, userRepository)
),
) = Store(reducers)
diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt
index 170df93..ebc1f00 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt
@@ -1,75 +1,78 @@
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.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
-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"),
+sealed class Route(val path: String) {
+ object Welcome : Route("welcome")
+ object Login : Route("login")
+ object Register : Route("register")
+ object Overview : Route("overview")
+ data class Transactions(val selected: String? = null) :
+ Route(if (selected != null) "transactions/${selected}" else "transactions")
+
+ data class Categories(val selected: String? = null) :
+ Route(if (selected != null) "categories/${selected}" else "categories")
+
+ // data class RecurringTransactions(val selected: RecurringTransaction?): Route(if (selected != null) "transactions/${selected.id}" else "transactions")
+ object Profile : Route("profile")
+ object Settings : Route("settings")
+ object About : Route("about")
}
data class State(
- val host: String? = null,
val user: User? = null,
val budgets: List? = null,
+ val budgetBalance: Long? = null,
val selectedBudget: String? = null,
val editingBudget: Boolean = false,
val categories: List? = null,
+ val categoryBalances: Map? = null,
val selectedCategory: String? = null,
val editingCategory: Boolean = false,
val transactions: List? = null,
val selectedTransaction: String? = null,
+ val selectedTransactionCreatedBy: User? = null,
val editingTransaction: Boolean = false,
val loading: Boolean = false,
- val route: Route = Route.WELCOME,
- val initialRoute: Route = Route.WELCOME
-)
+ val route: Route = Route.Login,
+ val initialRoute: Route = Route.Login
+) {
+ override fun toString(): String {
+ return "State(budget=$selectedBudget, selectedTransaction=$selectedTransaction, route=$route)"
+ }
+}
interface Action {
- object About : Action
+ object AboutClicked : Action
object Back : Action
}
-interface AsyncAction : Action
-
interface Effect {
object Exit : Effect
+ data class Error(val message: String) : Effect
object Empty: Effect
}
-abstract class Reducer {
+abstract class Reducer : CoroutineScope by CoroutineScope(Dispatchers.Main) {
lateinit var dispatch: (Action) -> Unit
lateinit var emit: (Effect) -> Unit
+ internal val initialActions = ArrayDeque()
- abstract fun reduce(action: Action, state: State): State
+ 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)
@@ -80,22 +83,32 @@ class Store(
init {
reducers.forEach {
it.dispatch = this::dispatch
+ it.emit = {
+ launch {
+ _effects.emit(it)
+ }
+ }
+ var action = it.initialActions.removeFirstOrNull()
+ while (action != null) {
+ dispatch(action)
+ action = it.initialActions.removeFirstOrNull()
+ }
+ }
+ launch {
+ state.collect {
+ println(it)
+ }
}
}
fun dispatch(action: Action) {
+ println(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)
- }
+ var newState = _state.value
+ reducers.forEach {
+ newState = it.reduce(action) { newState }
}
- _state.emit(state)
+ _state.emit(newState)
}
}
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
index 729ab5f..b5515e1 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt
@@ -1,121 +1,163 @@
package com.wbrawner.twigs.shared.budget
-import com.wbrawner.twigs.shared.*
-import com.wbrawner.twigs.shared.user.UserAction
+import com.russhwolf.settings.Settings
+import com.russhwolf.settings.set
+import com.wbrawner.twigs.shared.Action
+import com.wbrawner.twigs.shared.Reducer
+import com.wbrawner.twigs.shared.Route
+import com.wbrawner.twigs.shared.State
+import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.shared.user.UserPermission
+import kotlinx.coroutines.launch
sealed interface BudgetAction : Action {
+ object OverviewClicked : BudgetAction
+ data class LoadBudgetsSuccess(val budgets: List) : BudgetAction
+ data class LoadBudgetsFailed(val error: Exception) : BudgetAction
data class CreateBudget(
val name: String,
val description: String? = null,
val users: List = emptyList()
- ) : BudgetAction {
- fun async() = BudgetAsyncAction.CreateBudgetAsync(name, description, users)
- }
+ ) : BudgetAction
+
+ data class SaveBudgetSuccess(val budget: Budget) : BudgetAction
+
+ data class SaveBudgetFailure(
+ val id: String? = null,
+ val name: String,
+ val description: String? = null,
+ val users: List = emptyList(),
+ val error: Exception
+ ) : BudgetAction
data class EditBudget(val id: String) : BudgetAction
- data class SelectBudget(val id: String) : BudgetAction
+ data class SelectBudget(val id: String?) : BudgetAction
+
+ data class BudgetSelected(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)
- }
+ ) : BudgetAction
- data class DeleteBudget(val id: String) : BudgetAction {
- fun async() = BudgetAsyncAction.DeleteBudgetAsync(id)
- }
+ data class DeleteBudget(val id: String) : BudgetAction
}
-sealed interface BudgetAsyncAction : AsyncAction {
- data class CreateBudgetAsync(
- val name: String,
- val description: String? = null,
- val users: List = emptyList()
- ) : BudgetAsyncAction
+const val KEY_LAST_BUDGET = "lastBudget"
- 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)
+class BudgetReducer(
+ private val budgetRepository: BudgetRepository,
+ private val settings: Settings
+) : Reducer() {
+ override fun reduce(action: Action, state: () -> State): State = when (action) {
+ is Action.Back -> {
+ val currentState = state()
+ currentState.copy(
+ editingBudget = false
)
- val budgets = state.budgets?.toMutableList() ?: mutableListOf()
- budgets.add(budget)
+ }
+
+ is BudgetAction.OverviewClicked -> state().copy(route = Route.Overview)
+ is BudgetAction.LoadBudgetsSuccess -> state().copy(budgets = action.budgets).also {
+ dispatch(BudgetAction.SelectBudget(settings.getStringOrNull(KEY_LAST_BUDGET)))
+ }
+
+ is BudgetAction.CreateBudget -> {
+ launch {
+ val budget = budgetRepository.create(
+ Budget(
+ name = action.name,
+ description = action.description,
+ users = action.users
+ )
+ )
+ dispatch(BudgetAction.SaveBudgetSuccess(budget))
+ }
+ state().copy(loading = true)
+ }
+
+ is BudgetAction.SaveBudgetSuccess -> {
+ val currentState = state()
+ val budgets = currentState.budgets?.toMutableList() ?: mutableListOf()
+ budgets.add(action.budget)
budgets.sortBy { it.name }
- state.copy(
+ currentState.copy(
loading = false,
budgets = budgets.toList(),
- selectedBudget = budget.id
+ selectedBudget = action.budget.id,
+ editingBudget = false
)
}
- 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 ConfigAction.LoginSuccess -> {
+ launch {
+ try {
+ val budgets = budgetRepository.findAll()
+ dispatch(BudgetAction.LoadBudgetsSuccess(budgets))
+ } catch (e: Exception) {
+ dispatch(BudgetAction.LoadBudgetsFailed(e))
+ }
+ }
+ state().copy(loading = true)
}
- 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
- )
+
+ is ConfigAction.Logout -> state().copy(
+ budgets = null,
+ selectedBudget = null,
+ editingBudget = false
+ )
+// is BudgetAction.EditBudget -> state.copy(
+// editingBudget = true,
+// selectedBudget = action.id
+// )
+ is BudgetAction.SelectBudget -> {
+ val currentState = state()
+ val budgetId = currentState.budgets
+ ?.firstOrNull { it.id == action.id }
+ ?.id
+ ?: currentState.budgets?.firstOrNull()?.id
+ settings[KEY_LAST_BUDGET] = budgetId
+ dispatch(BudgetAction.BudgetSelected(budgetId!!))
+ state()
}
- else -> state
+
+ is BudgetAction.BudgetSelected -> 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 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 currentState = state()
+// val budgets = currentState.budgets?.filterNot { it.id == action.id }
+// currentState.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/category/CategoryAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryAction.kt
new file mode 100644
index 0000000..22219cd
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryAction.kt
@@ -0,0 +1,186 @@
+package com.wbrawner.twigs.shared.category
+
+import com.wbrawner.twigs.shared.Action
+import com.wbrawner.twigs.shared.Reducer
+import com.wbrawner.twigs.shared.Route
+import com.wbrawner.twigs.shared.State
+import com.wbrawner.twigs.shared.budget.BudgetAction
+import kotlinx.coroutines.launch
+
+sealed interface CategoryAction : Action {
+ object CategoriesClicked : CategoryAction
+ data class BalancesCalculated(
+ val budgetBalance: Long,
+ val categoryBalances: Map
+ ) : CategoryAction
+
+ data class LoadCategoriesSuccess(val categories: List) : CategoryAction
+ data class LoadCategoriesFailed(val error: Exception) : CategoryAction
+ data class CreateCategory(
+ val name: String,
+ val description: String? = null,
+ val amount: Long,
+ val expense: Boolean
+ ) : CategoryAction
+
+ data class SaveCategorySuccess(val category: Category) : CategoryAction
+
+ data class SaveCategoryFailure(
+ val id: String? = null,
+ val name: String,
+ val description: String? = null,
+ val amount: Long,
+ val expense: Boolean,
+ val error: Exception
+ ) : CategoryAction
+
+ data class EditCategory(val id: String) : CategoryAction
+
+ data class SelectCategory(val id: String?) : CategoryAction
+
+ data class CategorySelected(val id: String) : CategoryAction
+
+ data class UpdateCategory(
+ val id: String,
+ val name: String,
+ val description: String? = null,
+ val amount: Long,
+ val expense: Boolean
+ ) : CategoryAction
+
+ data class DeleteCategory(val id: String) : CategoryAction
+}
+
+class CategoryReducer(private val categoryRepository: CategoryRepository) : Reducer() {
+ override fun reduce(action: Action, state: () -> State): State = when (action) {
+ is Action.Back -> {
+ val currentState = state()
+ currentState.copy(
+ editingCategory = false,
+ selectedCategory = if (currentState.editingCategory) currentState.selectedCategory else null
+ )
+ }
+
+ is CategoryAction.CategoriesClicked -> state().copy(route = Route.Categories())
+ is BudgetAction.BudgetSelected -> {
+ launch {
+ try {
+ val categories = categoryRepository.findAll(budgetIds = arrayOf(action.id))
+ dispatch(CategoryAction.LoadCategoriesSuccess(categories))
+ } catch (e: Exception) {
+ dispatch(CategoryAction.LoadCategoriesFailed(e))
+ }
+ }
+ state().copy(categories = null)
+ }
+
+ is CategoryAction.LoadCategoriesSuccess -> state().copy(categories = action.categories)
+ .also {
+ launch {
+ var budgetBalance = 0L
+ val categoryBalances = mutableMapOf()
+ action.categories.forEach { category ->
+ val balance = categoryRepository.getBalance(category.id!!)
+ categoryBalances[category.id] = balance
+ budgetBalance += balance
+ }
+ dispatch(CategoryAction.BalancesCalculated(budgetBalance, categoryBalances))
+ }
+ }
+
+ is CategoryAction.BalancesCalculated -> state().copy(
+ budgetBalance = action.budgetBalance,
+ categoryBalances = action.categoryBalances
+ )
+// is BudgetAction.CreateBudget -> {
+// launch {
+// val budget = budgetRepository.create(
+// Budget(
+// name = action.name,
+// description = action.description,
+// users = action.users
+// )
+// )
+// dispatch(BudgetAction.SaveBudgetSuccess(budget))
+// }
+// state().copy(loading = true)
+// }
+// is BudgetAction.SaveBudgetSuccess -> {
+// val currentState = state()
+// val budgets = currentState.budgets?.toMutableList() ?: mutableListOf()
+// budgets.add(action.budget)
+// budgets.sortBy { it.name }
+// currentState.copy(
+// loading = false,
+// budgets = budgets.toList(),
+// selectedBudget = action.budget.id,
+// editingBudget = false
+// )
+// }
+// is ConfigAction.LoginSuccess -> {
+// launch {
+// try {
+// val budgets = budgetRepository.findAll()
+// dispatch(BudgetAction.LoadBudgetsSuccess(budgets))
+// } catch (e: Exception) {
+// dispatch(BudgetAction.LoadBudgetsFailed(e))
+// }
+// }
+// state().copy(loading = true)
+// }
+// is ConfigAction.Logout -> state().copy(
+// budgets = null,
+// selectedBudget = null,
+// editingBudget = false
+// )
+//// is BudgetAction.EditBudget -> state.copy(
+//// editingBudget = true,
+//// selectedBudget = action.id
+//// )
+// is BudgetAction.SelectBudget -> {
+// val currentState = state()
+// val budgetId = currentState.budgets
+// ?.firstOrNull { it.id == action.id }
+// ?.id
+// ?: currentState.budgets?.firstOrNull()?.id
+// settings[KEY_LAST_BUDGET] = budgetId
+// dispatch(BudgetAction.BudgetSelected(budgetId!!))
+// state()
+// }
+// is BudgetAction.BudgetSelected -> 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 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 currentState = state()
+//// val budgets = currentState.budgets?.filterNot { it.id == action.id }
+//// currentState.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/category/CategoryRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt
index a35b9b4..b54bc2e 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt
@@ -1,9 +1,28 @@
package com.wbrawner.twigs.shared.category
import com.wbrawner.twigs.shared.Repository
-import com.wbrawner.twigs.shared.category.Category
+import com.wbrawner.twigs.shared.network.APIService
interface CategoryRepository : Repository {
suspend fun findAll(budgetIds: Array? = null): List
suspend fun getBalance(id: String): Long
+}
+
+class NetworkCategoryRepository(private val apiService: APIService) : CategoryRepository {
+ override suspend fun findAll(budgetIds: Array?): List =
+ apiService.getCategories(budgetIds = budgetIds)
+
+ override suspend fun findAll(): List = findAll(null)
+
+ override suspend fun getBalance(id: String): Long =
+ apiService.sumTransactions(categoryId = id).balance
+
+ override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem)
+
+ 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)
}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt
index adf2d73..3418148 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt
@@ -8,11 +8,11 @@ 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 io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.HttpClientEngineConfig
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
const val BASE_PATH = "/api"
@@ -116,8 +116,8 @@ fun HttpClientConfig.commonConfig() {
})
}
install(HttpTimeout) {
- requestTimeoutMillis = 1000
- connectTimeoutMillis = 1000
- socketTimeoutMillis = 1000
+ requestTimeoutMillis = 60_000
+ connectTimeoutMillis = 60_000
+ socketTimeoutMillis = 60_000
}
}
diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt
new file mode 100644
index 0000000..bf65ec9
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt
@@ -0,0 +1,191 @@
+package com.wbrawner.twigs.shared.transaction
+
+import com.wbrawner.twigs.shared.Action
+import com.wbrawner.twigs.shared.Reducer
+import com.wbrawner.twigs.shared.Route
+import com.wbrawner.twigs.shared.State
+import com.wbrawner.twigs.shared.budget.Budget
+import com.wbrawner.twigs.shared.budget.BudgetAction
+import com.wbrawner.twigs.shared.category.Category
+import com.wbrawner.twigs.shared.user.ConfigAction
+import com.wbrawner.twigs.shared.user.User
+import com.wbrawner.twigs.shared.user.UserPermission
+import com.wbrawner.twigs.shared.user.UserRepository
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.nanoseconds
+import kotlin.time.Duration.Companion.seconds
+
+sealed interface TransactionAction : Action {
+ object TransactionsClicked : TransactionAction
+ data class LoadTransactionsSuccess(val transactions: List) : TransactionAction
+ data class LoadTransactionsFailed(val error: Exception) : TransactionAction
+ object NewTransactionClicked : TransactionAction
+ data class CreateTransaction(
+ val title: String,
+ val description: String? = null,
+ val amount: Long,
+ val date: Instant,
+ val expense: Boolean,
+ val category: Category? = null,
+ val budget: Budget,
+ ) : TransactionAction
+
+ data class SaveTransactionSuccess(val transaction: Transaction) : TransactionAction
+
+ data class SaveTransactionFailure(
+ val id: String? = null,
+ val name: String,
+ val description: String? = null,
+ val users: List = emptyList(),
+ val error: Exception
+ ) : TransactionAction
+
+ object CancelEditTransaction : TransactionAction
+
+ data class EditTransaction(val id: String) : TransactionAction
+
+ data class SelectTransaction(val id: String?) : TransactionAction
+
+ data class TransactionSelected(val transaction: Transaction, val createdBy: User) :
+ TransactionAction
+
+ data class UpdateTransaction(
+ val id: String,
+ val title: String,
+ val description: String? = null,
+ val amount: Long,
+ val date: Instant,
+ val expense: Boolean,
+ val category: Category? = null,
+ val budget: Budget,
+ ) : TransactionAction
+
+ data class DeleteTransaction(val id: String) : TransactionAction
+}
+
+class TransactionReducer(
+ private val transactionRepository: TransactionRepository,
+ private val userRepository: UserRepository,
+) : Reducer() {
+ override fun reduce(action: Action, state: () -> State): State = when (action) {
+ is Action.Back -> {
+ val currentState = state()
+ currentState.copy(
+ editingTransaction = false,
+ selectedTransaction = if (currentState.editingTransaction) currentState.selectedTransaction else null,
+ route = if (currentState.route is Route.Transactions && !currentState.route.selected.isNullOrBlank() && !currentState.editingTransaction) {
+ Route.Transactions()
+ } else {
+ currentState.route
+ }
+ )
+ }
+
+ is TransactionAction.TransactionsClicked -> state().copy(route = Route.Transactions(null))
+ is TransactionAction.LoadTransactionsSuccess -> state().copy(transactions = action.transactions)
+ is TransactionAction.NewTransactionClicked -> state().copy(editingTransaction = true)
+ is TransactionAction.CancelEditTransaction -> state().copy(editingTransaction = false)
+ is TransactionAction.CreateTransaction -> {
+ launch {
+ val transaction = transactionRepository.create(
+ Transaction(
+ title = action.title,
+ description = action.description,
+ amount = action.amount,
+ date = action.date,
+ expense = action.expense,
+ categoryId = action.category?.id,
+ budgetId = action.budget.id!!,
+ createdBy = state().user!!.id!!
+ )
+ )
+ dispatch(TransactionAction.SaveTransactionSuccess(transaction))
+ }
+ state().copy(loading = true)
+ }
+
+ is TransactionAction.SaveTransactionSuccess -> {
+ val currentState = state()
+ val transactions = currentState.transactions?.toMutableList() ?: mutableListOf()
+ transactions.add(action.transaction)
+ transactions.sortByDescending { it.date }
+ currentState.copy(
+ loading = false,
+ transactions = transactions.toList(),
+ selectedTransaction = action.transaction.id,
+ selectedTransactionCreatedBy = currentState.user,
+ editingTransaction = false
+ )
+ }
+
+ is BudgetAction.BudgetSelected -> {
+ launch {
+ try {
+ val transactions = transactionRepository.findAll(budgetIds = listOf(action.id))
+ dispatch(TransactionAction.LoadTransactionsSuccess(transactions))
+ } catch (e: Exception) {
+ dispatch(TransactionAction.LoadTransactionsFailed(e))
+ }
+ }
+ state().copy(transactions = null)
+ }
+
+ is ConfigAction.Logout -> state().copy(
+ transactions = null,
+ selectedTransaction = null,
+ editingTransaction = false
+ )
+
+ is TransactionAction.EditTransaction -> state().copy(
+ editingTransaction = true,
+ selectedTransaction = action.id
+ )
+
+ is TransactionAction.SelectTransaction -> {
+ launch {
+ val currentState = state()
+ val transaction = currentState.transactions!!.first { it.id == action.id }
+ val createdBy = userRepository.findById(transaction.createdBy)
+ dispatch(TransactionAction.TransactionSelected(transaction, createdBy))
+ }
+ state().copy(
+ loading = true,
+ selectedTransaction = action.id,
+ route = Route.Transactions(action.id)
+ )
+ }
+
+ is TransactionAction.TransactionSelected -> state().copy(
+ selectedTransaction = action.transaction.id,
+ selectedTransactionCreatedBy = action.createdBy
+ )
+
+ else -> state()
+ }
+}
+
+fun Instant.stripTime(): Instant {
+ val localDateTime = toLocalDateTime(TimeZone.UTC)
+ return minus(localDateTime.hour.hours)
+ .minus(localDateTime.minute.minutes)
+ .minus(localDateTime.second.seconds)
+ .minus(localDateTime.nanosecond.nanoseconds)
+}
+
+fun List.groupByDate(): Map> {
+ val groups = mutableMapOf>()
+ forEach { transaction ->
+ println("Groups: ${groups.size}")
+ val key = transaction.date.stripTime().toString()
+ val list = groups[key]?.toMutableList() ?: mutableListOf()
+ list.add(transaction)
+ list.sortByDescending { t -> t.date }
+ groups[key] = list
+ }
+ return groups
+}
\ 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
index 8c81615..34a0286 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt
@@ -2,14 +2,41 @@ package com.wbrawner.twigs.shared.transaction
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.endOfMonth
+import com.wbrawner.twigs.shared.network.APIService
import com.wbrawner.twigs.shared.startOfMonth
-import kotlinx.datetime.*
+import kotlinx.datetime.Instant
interface TransactionRepository : Repository {
suspend fun findAll(
- budgetIds: List? = null,
- categoryIds: List? = null,
- start: Instant? = startOfMonth(),
- end: Instant? = endOfMonth()
+ budgetIds: List? = null,
+ categoryIds: List? = null,
+ start: Instant? = startOfMonth(),
+ end: Instant? = endOfMonth()
): List
+}
+
+class NetworkTransactionRepository(private val apiService: APIService) : TransactionRepository {
+ override suspend fun findAll(
+ budgetIds: List?,
+ categoryIds: List?,
+ start: Instant?,
+ end: Instant?
+ ): List = apiService.getTransactions(
+ budgetIds,
+ categoryIds,
+ from = start.toString(),
+ to = end.toString()
+ )
+
+ override suspend fun findAll(): List = findAll(null, null)
+
+ override suspend fun create(newItem: Transaction): Transaction =
+ apiService.newTransaction(newItem)
+
+ 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)
}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/ConfigAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/ConfigAction.kt
new file mode 100644
index 0000000..73794f8
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/ConfigAction.kt
@@ -0,0 +1,116 @@
+package com.wbrawner.twigs.shared.user
+
+import com.russhwolf.settings.Settings
+import com.russhwolf.settings.set
+import com.wbrawner.twigs.shared.Action
+import com.wbrawner.twigs.shared.Effect
+import com.wbrawner.twigs.shared.Reducer
+import com.wbrawner.twigs.shared.Route
+import com.wbrawner.twigs.shared.State
+import com.wbrawner.twigs.shared.network.APIService
+import kotlinx.coroutines.launch
+
+const val KEY_AUTH_TOKEN = "authToken"
+const val KEY_BASE_URL = "baseUrl"
+const val KEY_USER_ID = "userId"
+
+sealed interface ConfigAction : Action {
+ data class SetServer(val server: String) : ConfigAction
+ data class Login(val username: String, val password: String) : ConfigAction
+ data class TokenLogin(val baseUrl: String, val authToken: String, val userId: String) :
+ ConfigAction
+
+ data class TokenLoginFailed(val error: Exception) : ConfigAction
+ data class LoginSuccess(val user: User) : ConfigAction
+ data class LoginFailed(
+ val username: String,
+ val password: String,
+ val error: Exception
+ ) : ConfigAction
+
+ data class Register(val username: String, val password: String, val confirmPassword: String) :
+ ConfigAction
+
+ data class RegistrationSuccess(val user: User) : ConfigAction
+ data class RegistrationFailed(val user: User) : ConfigAction
+ object Logout : ConfigAction
+ object ForgotPasswordClicked : Action
+ object RegisterClicked : Action
+}
+
+class ConfigReducer(private val apiService: APIService, private val settings: Settings) :
+ Reducer() {
+ init {
+ run {
+ val baseUrl = settings.getStringOrNull(KEY_BASE_URL) ?: return@run
+ val authToken = settings.getStringOrNull(KEY_AUTH_TOKEN) ?: return@run
+ val userId = settings.getStringOrNull(KEY_USER_ID) ?: return@run
+ initialActions.addLast(ConfigAction.TokenLogin(baseUrl, authToken, userId))
+ }
+ }
+
+ override fun reduce(action: Action, state: () -> State): State = when (action) {
+ is ConfigAction.SetServer -> {
+ var baseUrl = action.server
+ if (!baseUrl.startsWith("http")) {
+ baseUrl = "https://$baseUrl"
+ }
+ settings[KEY_BASE_URL] = baseUrl
+ apiService.baseUrl = baseUrl
+ state()
+ }
+
+ is ConfigAction.Login -> {
+ launch {
+ try {
+ val session = apiService.login(LoginRequest(action.username, action.password))
+ settings[KEY_AUTH_TOKEN] = session.token
+ apiService.authToken = session.token
+ val user = apiService.getUser(session.userId)
+ settings[KEY_USER_ID] = session.userId
+ dispatch(ConfigAction.LoginSuccess(user))
+ } catch (e: Exception) {
+ dispatch(ConfigAction.LoginFailed(action.username, action.password, e))
+ }
+ }
+ state().copy(loading = true)
+ }
+
+ is ConfigAction.TokenLogin -> {
+ launch {
+ try {
+ apiService.authToken = action.authToken
+ apiService.baseUrl = action.baseUrl
+ val user = apiService.getUser(action.userId)
+ dispatch(ConfigAction.LoginSuccess(user))
+ } catch (e: Exception) {
+ dispatch(ConfigAction.TokenLoginFailed(e))
+ }
+ }
+ state().copy(loading = true)
+ }
+
+ is ConfigAction.TokenLoginFailed -> {
+ emit(Effect.Error(action.error.message ?: "Invalid auth token"))
+ settings.remove(KEY_AUTH_TOKEN)
+ settings.remove(KEY_BASE_URL)
+ settings.remove(KEY_USER_ID)
+ state().copy(loading = false)
+ }
+
+ is ConfigAction.LoginSuccess -> state().copy(user = action.user, route = Route.Overview)
+ is ConfigAction.LoginFailed -> {
+ emit(Effect.Error(action.error.message ?: "Login failed"))
+ state().copy(loading = false)
+ }
+
+ is ConfigAction.Logout -> {
+ settings.remove(KEY_AUTH_TOKEN)
+ settings.remove(KEY_BASE_URL)
+ settings.remove(KEY_USER_ID)
+ state().copy(user = null, route = Route.Login)
+ }
+
+ else -> state()
+ }
+}
\ 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
deleted file mode 100644
index a974a10..0000000
--- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserAction.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-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
index 19bcc27..0d2a932 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserRepository.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/user/UserRepository.kt
@@ -1,10 +1,37 @@
package com.wbrawner.twigs.shared.user
import com.wbrawner.twigs.shared.Repository
+import com.wbrawner.twigs.shared.network.APIService
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
+}
+
+class NetworkUserRepository(private val apiService: APIService) : UserRepository {
+ override suspend fun findAll(budgetId: String?): List {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun findAll(): List {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun findAllByNameLike(query: String): List {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun create(newItem: User): User {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun findById(id: String): User = apiService.getUser(id)
+
+ override suspend fun update(updatedItem: User): User {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun delete(id: String) {
+ TODO("Not yet implemented")
+ }
}
\ No newline at end of file