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