Compare commits

..

2 commits
main ... graphs

181 changed files with 4377 additions and 6967 deletions

View file

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"

View file

@ -1,21 +0,0 @@
name: Enable Auto Merge
on:
pull_request_target:
types:
- opened
- reopened
- edited
branches:
- main
jobs:
auto-merge:
runs-on: ubuntu-latest
if: ${{ github.actor == 'wbrawner' || github.actor == 'dependabot[bot]' }}
steps:
- name: Enable auto-merge
run: gh pr merge --auto --rebase "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GH_TOKEN: ${{secrets.GH_TOKEN}}

View file

@ -1,74 +0,0 @@
name: Test
on:
pull_request:
permissions:
statuses: write
checks: write
jobs:
validate:
runs-on: ubuntu-latest
name: Validate
steps:
- uses: actions/checkout@v3
- name: set up JDK
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
unit_test:
name: Run Unit Tests
runs-on: ubuntu-latest
needs:
- validate
steps:
- uses: actions/checkout@v3
- name: set up JDK
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Run unit tests
uses: gradle/gradle-build-action@v2
with:
arguments: testDebugUnitTest
- name: Publish JUnit Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Unit Test Results
path: "*/build/test-results/*/*.xml"
reporter: java-junit
fail-on-error: true
# TODO: Uncomment the UI test workflow when I actually have UI tests
# ui_tests:
# runs-on: ubuntu-latest
# name: Run UI Tests
# needs:
# - validate
# steps:
# - uses: actions/checkout@v3
# - name: set up JDK
# uses: actions/setup-java@v3
# with:
# distribution: 'zulu'
# java-version: '17'
# - name: Build with Gradle
# uses: gradle/gradle-build-action@v2
# with:
# arguments: assembleDebug assembleDebugAndroidTest
# - name: Grant execute permission for flank_auth.sh
# run: chmod +x flank_auth.sh
# - name: Add auth for flank
# env:
# GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }}
# run: |
# ./flank_auth.sh
# - name: Run UI tests
# uses: gradle/gradle-build-action@v2
# with:
# arguments: runFlank

1
android/.gitignore vendored
View file

@ -1,3 +1,2 @@
/build
acra.properties
release/

77
android/build.gradle Normal file
View file

@ -0,0 +1,77 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 30
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
defaultConfig {
applicationId "com.wbrawner.budget"
minSdkVersion 23
targetSdkVersion 30
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'
buildConfigField "String", "API_URL", "\"https://budget-api.intra.wbrawner.com/\""
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
dependencies {
implementation project(':common')
implementation project(':budgetlib')
implementation project(':storage')
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.media:media:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.emoji:emoji-bundled:1.1.0'
implementation 'com.github.BlacKCaT27:CurrencyEditText:2.0.2'
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
// Dagger
implementation "com.google.dagger:dagger:$dagger"
kapt "com.google.dagger:dagger-compiler:$dagger"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi"
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.2'
debugImplementation "com.willowtreeapps.hyperion:hyperion-core:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-attr:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-build-config:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-crash:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-disk:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-geiger-counter:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-measurement:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-phoenix:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-recorder:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-shared-preferences:$hyperion"
}

View file

@ -1,92 +0,0 @@
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 {
namespace = "com.wbrawner.twigs.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 = project.ext["jvm"] as JavaVersion
targetCompatibility = project.ext["jvm"] as JavaVersion
}
kotlinOptions {
jvmTarget = (project.ext["jvm"] as JavaVersion).majorVersion
}
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)
}

View file

@ -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.kts.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs
package com.wbrawner.budget
import androidx.test.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4

View file

@ -1,8 +1,4 @@
package com.wbrawner.twigs;
import static junit.framework.Assert.assertNull;
import static junit.framework.TestCase.assertEquals;
import static junit.framework.TestCase.assertTrue;
package com.wbrawner.budget;
import android.arch.persistence.db.SupportSQLiteDatabase;
import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory;
@ -12,9 +8,9 @@ import android.database.Cursor;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import com.wbrawner.twigs.data.BudgetDatabase;
import com.wbrawner.twigs.data.migrations.MIGRATION_1_2;
import com.wbrawner.twigs.data.migrations.MIGRATION_2_3;
import com.wbrawner.budget.data.BudgetDatabase;
import com.wbrawner.budget.data.migrations.MIGRATION_1_2;
import com.wbrawner.budget.data.migrations.MIGRATION_2_3;
import org.junit.Rule;
import org.junit.Test;
@ -22,6 +18,10 @@ import org.junit.runner.RunWith;
import java.io.IOException;
import static junit.framework.Assert.assertNull;
import static junit.framework.TestCase.assertEquals;
import static junit.framework.TestCase.assertTrue;
@RunWith(AndroidJUnit4.class)
public class MigrationTests {
private static final String TEST_DB = "migration-test";

View file

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
package="com.wbrawner.budget">
<application
android:name=".TwigsApplication"
android:name=".AllowanceApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@ -11,12 +12,11 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning">
tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="n">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:resizeableActivity="true"
android:theme="@style/Theme.App.Starting"
android:name=".ui.SplashActivity"
android:theme="@style/SplashTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -27,6 +27,25 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".ui.MainActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".ui.transactions.TransactionFormActivity"
android:parentActivityName=".ui.MainActivity"
android:theme="@style/AppTheme">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity" />
</activity>
<activity
android:name=".ui.categories.CategoryFormActivity"
android:parentActivityName=".ui.MainActivity"
android:theme="@style/AppTheme">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity" />
</activity>
</application>
</manifest>

View file

@ -0,0 +1,16 @@
package com.wbrawner.budget
import android.app.Application
class AllowanceApplication : Application() {
lateinit var appComponent: AppComponent
private set
override fun onCreate() {
super.onCreate()
appComponent = DaggerAppComponent.builder()
.context(this)
.build()
appComponent.errorHandler.init(this)
}
}

View file

@ -0,0 +1,44 @@
package com.wbrawner.budget
import android.content.Context
import com.wbrawner.budget.common.util.ErrorHandler
import com.wbrawner.budget.lib.network.NetworkModule
import com.wbrawner.budget.storage.StorageModule
import com.wbrawner.budget.ui.MainViewModel
import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.budgets.BudgetFormViewModel
import com.wbrawner.budget.ui.budgets.BudgetListViewModel
import com.wbrawner.budget.ui.categories.CategoryDetailsViewModel
import com.wbrawner.budget.ui.categories.CategoryFormViewModel
import com.wbrawner.budget.ui.categories.CategoryListViewModel
import com.wbrawner.budget.ui.overview.OverviewViewModel
import com.wbrawner.budget.ui.transactions.TransactionFormViewModel
import com.wbrawner.budget.ui.transactions.TransactionListViewModel
import dagger.BindsInstance
import dagger.Component
import javax.inject.Singleton
@Singleton
@Component(modules = [AppModule::class, StorageModule::class, NetworkModule::class])
interface AppComponent {
fun inject(viewModel: OverviewViewModel)
fun inject(viewModel: SplashViewModel)
fun inject(viewModel: MainViewModel)
fun inject(viewMode: BudgetListViewModel)
fun inject(viewModel: BudgetFormViewModel)
fun inject(viewModel: CategoryListViewModel)
fun inject(viewModel: CategoryDetailsViewModel)
fun inject(viewModel: CategoryFormViewModel)
fun inject(viewModel: TransactionListViewModel)
fun inject(viewModel: TransactionFormViewModel)
@Singleton
val errorHandler: ErrorHandler
@Component.Builder
interface Builder {
@BindsInstance
fun context(context: Context): Builder
fun build(): AppComponent
}
}

View file

@ -1,24 +1,21 @@
package com.wbrawner.twigs.android
package com.wbrawner.budget
import android.app.Application
import android.util.Log
import com.wbrawner.twigs.shared.ErrorHandler
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.create
import com.wbrawner.budget.common.util.ErrorHandler
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
override fun init(application: Application) {
// no-op
}
override fun reportException(t: Throwable, message: String?) {
Log.e("ErrorHandler", "Report exception: $message", t)
}
}
@Provides
fun providesStore() = Store.create()
}

View file

@ -0,0 +1,35 @@
package com.wbrawner.budget
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
sealed class AsyncState<out T> {
object Loading : AsyncState<Nothing>()
class Success<T>(val data: T) : AsyncState<T>()
class Error(val exception: Exception) : AsyncState<Nothing>() {
constructor(message: String) : this(RuntimeException(message))
}
object Exit : AsyncState<Nothing>()
}
interface AsyncViewModel<T> {
val state: MutableLiveData<AsyncState<T>>
}
fun <T> MutableLiveData<AsyncState<T>>.postValue(value: T) = postValue(AsyncState.Success(value))
fun <VM, T> VM.launch(block: suspend () -> T): Job where VM : ViewModel, VM : AsyncViewModel<T> = viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
state.postValue(AsyncState.Success(block()))
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
Log.e("AsyncViewModel", "Failed to load data", e)
}
}

View file

@ -0,0 +1,6 @@
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"

View file

@ -0,0 +1,96 @@
package com.wbrawner.budget.ui
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.emoji.text.EmojiCompat
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.navigation.NavigationView
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import kotlinx.android.synthetic.main.activity_main.*
private const val MENU_GROUP_BUDGETS = 50
private const val MENU_ITEM_ADD_BUDGET = 100
private const val MENU_ITEM_SETTINGS = 101
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
private lateinit var toggle: ActionBarDrawerToggle
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EmojiCompat.init(androidx.emoji.bundled.BundledEmojiCompatConfig(this))
setContentView(R.layout.activity_main)
(application as AllowanceApplication).appComponent.inject(viewModel)
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
}
supportActionBar?.setHomeAsUpIndicator(homeAsUpIndicator)
}
viewModel.loadBudgets().observe(this, { 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)
}
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
}
}

View file

@ -0,0 +1,47 @@
package com.wbrawner.budget.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
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 kotlinx.coroutines.launch
import javax.inject.Inject
class MainViewModel : ViewModel() {
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var userRepository: UserRepository
private val budgets = MutableLiveData<BudgetList>()
fun loadBudgets(): LiveData<BudgetList> {
viewModelScope.launch {
val list = budgetRepository.findAll().sortedBy { it.name }
budgets.postValue(BudgetList(
list,
budgetRepository.currentBudget.value?.let {
list.indexOf(it)
}
))
}
return budgets
}
fun loadBudget(index: Int) {
val list = budgets.value ?: return
viewModelScope.launch {
budgetRepository.findById(list.budgets[index].id!!, true)
budgets.postValue(list.copy(selectedIndex = index))
}
}
}
data class BudgetList(
val budgets: List<Budget>,
val selectedIndex: Int? = null
)

View file

@ -0,0 +1,52 @@
package com.wbrawner.budget.ui
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.navigation.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class SplashActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private val viewModel: SplashViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
(application as AllowanceApplication).appComponent.inject(viewModel)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
window.decorView.apply {
systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
val navController = findNavController(R.id.auth_content)
viewModel.state.observe(this, { state ->
when (state) {
is AsyncState.Success -> {
when (state.data) {
is AuthenticationState.Authenticated -> {
navController.navigate(R.id.mainActivity)
finish()
}
is AuthenticationState.Unauthenticated -> {
navController.navigate(R.id.loginFragment)
}
}
}
}
})
launch {
viewModel.checkForExistingCredentials()
}
}
}

View file

@ -0,0 +1,80 @@
package com.wbrawner.budget.ui
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.launch
import com.wbrawner.budget.lib.network.BaseUrlHelper
import com.wbrawner.budget.lib.network.DEFAULT_URL
import com.wbrawner.budget.lib.network.PREF_KEY_BASE_URL
import com.wbrawner.budget.postValue
import javax.inject.Inject
class SplashViewModel : ViewModel(), AsyncViewModel<AuthenticationState> {
override val state: MutableLiveData<AsyncState<AuthenticationState>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var baseUrlHelper: BaseUrlHelper
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var sharedPreferences: SharedPreferences
@Inject
lateinit var userRepository: UserRepository
suspend fun checkForExistingCredentials() {
if (baseUrlHelper.url == DEFAULT_URL) {
state.postValue(AuthenticationState.Unauthenticated())
return
}
state.postValue(AsyncState.Success(AuthenticationState.Splash))
val authState = try {
userRepository.getProfile()
AuthenticationState.Authenticated
} catch (ignored: Exception) {
AuthenticationState.Unauthenticated()
}
state.postValue(authState)
}
fun login(server: String, username: String, password: String) = launch {
try {
val correctServer = if (server.startsWith("http://") || server.startsWith("https://")) server
else "https://$server"
baseUrlHelper.url = correctServer
userRepository.login(username, password).also {
loadBudgetData()
}
sharedPreferences.edit {
putString(PREF_KEY_BASE_URL, correctServer)
}
AuthenticationState.Authenticated
} catch (e: Exception) {
// TODO: Return error message here
AuthenticationState.Unauthenticated(server, username, password, e.message)
}
}
private suspend fun loadBudgetData() {
budgetRepository.prefetchData()
}
}
sealed class AuthenticationState {
object Splash : AuthenticationState()
class Unauthenticated(
val server: String? = null,
val username: String? = null,
val password: String? = null,
val errorMessage: String? = null
) : AuthenticationState()
object Authenticated : AuthenticationState()
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs.android.ui
package com.wbrawner.budget.ui
import android.content.Context
import android.text.Spannable
@ -9,7 +9,7 @@ import android.widget.EditText
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.wbrawner.twigs.android.R
import com.wbrawner.budget.R
import java.text.NumberFormat
import java.util.*

View file

@ -0,0 +1,98 @@
package com.wbrawner.budget.ui.auth
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.AuthenticationState
import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.ensureNotEmpty
import com.wbrawner.budget.ui.show
import kotlinx.android.synthetic.main.fragment_login.*
/**
* A simple [Fragment] subclass.
*/
class LoginFragment : Fragment() {
private val viewModel: SplashViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_login, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is AsyncState.Loading -> {
handleLoading(true)
}
is AsyncState.Error -> {
handleLoading(false)
username.error = "Invalid username/password"
password.error = "Invalid username/password"
state.exception.printStackTrace()
}
is AsyncState.Success -> {
if (state.data is AuthenticationState.Unauthenticated) {
server.setText(state.data.server ?: "")
username.setText(state.data.username ?: "")
password.setText(state.data.password ?: "")
state.data.errorMessage?.let {
AlertDialog.Builder(view.context)
.setTitle("Login Failed")
.setMessage(it)
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> }
.show()
}
}
}
}
})
password.setOnEditorActionListener { _, _, _ ->
submit.performClick()
}
submit.setOnClickListener {
if (!server.ensureNotEmpty() || !username.ensureNotEmpty() || !password.ensureNotEmpty()) {
return@setOnClickListener
}
viewModel.login(server.text.toString(), username.text.toString(), password.text.toString())
}
val serverString = arguments?.getString(EXTRA_SERVER)
val usernameString = arguments?.getString(EXTRA_USERNAME)
val passwordString = arguments?.getString(EXTRA_PASSWORD)
if (!serverString.isNullOrBlank() && !usernameString.isNullOrBlank() && !passwordString.isNullOrBlank()) {
server.setText(serverString)
username.setText(usernameString)
password.setText(passwordString)
submit.performClick()
}
}
private fun handleLoading(isLoading: Boolean) {
formPrompt.show(!isLoading)
serverContainer.show(!isLoading)
usernameContainer.show(!isLoading)
passwordContainer.show(!isLoading)
submit.show(!isLoading)
registerButton.show(!isLoading)
forgotPasswordLink.show(!isLoading)
progressBar.show(isLoading)
}
companion object {
const val EXTRA_SERVER = "server"
const val EXTRA_USERNAME = "username"
const val EXTRA_PASSWORD = "password"
}
}

View file

@ -0,0 +1,23 @@
package com.wbrawner.budget.ui.auth
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 RegisterFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_login, container, false)
}
}

View file

@ -0,0 +1,83 @@
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.Observer
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.*
abstract class ListWithAddButtonFragment<Data : Identifiable, ViewModel : AsyncViewModel<List<Data>>> : 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)
viewModel.state.observe(viewLifecycleOwner, Observer { 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
}
}
})
}
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<Data>
abstract val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Data>>
open val diffUtilItemCallback: DiffUtil.ItemCallback<BindableData<Data>> = object: DiffUtil.ItemCallback<BindableData<Data>>() {
override fun areItemsTheSame(oldItem: BindableData<Data>, newItem: BindableData<Data>): Boolean {
return oldItem.data.id === newItem.data.id
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: BindableData<Data>, newItem: BindableData<Data>): Boolean {
return oldItem.data == newItem.data
}
}
}

View file

@ -0,0 +1,55 @@
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<Data>(
private val constructors: Map<Int, (view: View) -> BindableViewHolder<Data>>,
diffUtilItemCallback: DiffUtil.ItemCallback<BindableData<Data>>
) : ListAdapter<BindableData<Data>, BindableAdapter.BindableViewHolder<Data>>(diffUtilItemCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: BindableViewHolder<Data> = 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<Data>, position: Int) {
holder.onBind(getItem(position))
}
override fun onViewRecycled(holder: BindableViewHolder<Data>) {
holder.onUnbind()
}
override fun getItemViewType(position: Int): Int = getItem(position).viewType
abstract class BindableViewHolder<T>(itemView: View) : RecyclerView.ViewHolder(itemView), Bindable<BindableData<T>>
abstract class CoroutineViewHolder<T>(itemView: View) : BindableViewHolder<T>(itemView), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onUnbind() {
coroutineContext[Job]?.cancel()
super.onUnbind()
}
}
}
interface Bindable<T> {
fun onBind(item: T) {}
fun onUnbind() {}
}
data class BindableData<T>(
val data: T,
@get:LayoutRes
val viewType: Int
)

View file

@ -0,0 +1,102 @@
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.AllowanceApplication
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 kotlinx.android.synthetic.main.fragment_add_edit_budget.*
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) {
(requireActivity().application as AllowanceApplication)
.appComponent
.inject(viewModel)
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<User>(
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()
}
}
})
}
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 = viewModel.users.value ?: emptyList()
))
}
R.id.action_delete -> {
viewModel.deleteBudget(this@AddEditBudgetFragment.id!!)
}
}
return true
}
}

View file

@ -0,0 +1,100 @@
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 kotlinx.coroutines.launch
import javax.inject.Inject
class BudgetFormViewModel : ViewModel() {
val state = MutableLiveData<BudgetFormState>(BudgetFormState.Loading)
val users = MutableLiveData<List<User>>()
val userSuggestions = MutableLiveData<List<User>>()
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var userRepository: UserRepository
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()
}

View file

@ -0,0 +1,66 @@
package com.wbrawner.budget.ui.budgets
import android.content.Context
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.AllowanceApplication
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
class BudgetListFragment : ListWithAddButtonFragment<Budget, BudgetListViewModel>() {
override val noItemsStringRes: Int = R.string.overview_no_data
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
override val viewModel: BudgetListViewModel by viewModels()
override fun reloadItems() {
viewModel.getBudgets()
}
override fun bindData(data: Budget): BindableData<Budget> = BindableData(data, BUDGET_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Budget>>
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<Budget>(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<Budget>) {
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)
}
}
}

View file

@ -0,0 +1,23 @@
package com.wbrawner.budget.ui.budgets
import androidx.lifecycle.MutableLiveData
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.launch
import javax.inject.Inject
class BudgetListViewModel : ViewModel(), AsyncViewModel<List<Budget>> {
override val state: MutableLiveData<AsyncState<List<Budget>>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var budgetRepo: BudgetRepository
fun getBudgets() {
launch {
budgetRepo.findAll().toList()
}
}
}

View file

@ -0,0 +1,121 @@
package com.wbrawner.budget.ui.categories
import android.content.Context
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.Observer
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
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 kotlinx.android.synthetic.main.fragment_category_details.*
/**
* A simple [Fragment] subclass.
*/
class CategoryDetailsFragment : Fragment() {
val viewModel: CategoryDetailsViewModel by viewModels()
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
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)
viewModel.state.observe(viewLifecycleOwner, Observer { 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 {
putLong(EXTRA_CATEGORY_ID, arguments?.getLong(EXTRA_CATEGORY_ID) ?: -1)
}
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)
}
}

View file

@ -0,0 +1,40 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
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.launch
import javax.inject.Inject
class CategoryDetailsViewModel : ViewModel(), AsyncViewModel<CategoryDetails> {
override val state: MutableLiveData<AsyncState<CategoryDetails>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var categoryRepo: CategoryRepository
fun getCategory(id: String? = null) {
if (id == null) {
state.postValue(AsyncState.Error("Invalid category ID"))
return
}
launch {
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
)

View file

@ -0,0 +1,129 @@
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.Observer
import com.wbrawner.budget.AllowanceApplication
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 kotlinx.android.synthetic.main.activity_add_edit_category.*
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)
(application as AllowanceApplication).appComponent.inject(viewModel)
viewModel.state.observe(this, Observer { 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_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<Budget>(
this@CategoryFormActivity,
android.R.layout.simple_list_item_1,
state.data.budgets
)
}
is AsyncState.Error -> {
// TODO: Show error message
categoryForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
Toast.makeText(this, "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(),
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
}
}

View file

@ -0,0 +1,79 @@
package com.wbrawner.budget.ui.categories
import androidx.annotation.StringRes
import androidx.lifecycle.MutableLiveData
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.launch
import kotlinx.coroutines.launch
import javax.inject.Inject
class CategoryFormViewModel : ViewModel(), AsyncViewModel<CategoryFormState> {
override val state: MutableLiveData<AsyncState<CategoryFormState>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var categoryRepository: CategoryRepository
@Inject
lateinit var budgetRepository: BudgetRepository
fun loadCategory(categoryId: String? = null) {
launch {
val category = categoryId?.let {
categoryRepository.findById(it)
} ?: Category("", title = "", amount = 0)
CategoryFormState(
category,
budgetRepository.findAll().toList()
)
}
}
fun saveCategory(category: Category) {
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
if (category.id == null)
categoryRepository.create(category.copy(id = randomId()))
else
categoryRepository.update(category)
state.postValue(AsyncState.Exit)
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
}
}
}
fun deleteCategoryById(id: String) {
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
categoryRepository.delete(id)
state.postValue(AsyncState.Exit)
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
}
}
}
}
data class CategoryFormState(
val category: Category,
val budgets: List<Budget>,
@StringRes val titleRes: Int,
val showDeleteButton: Boolean
) {
constructor(category: Category, budgets: List<Budget>) : this(
category,
budgets,
category.id?.let { R.string.title_edit_category } ?: R.string.title_add_category,
category.id != null
)
}

View file

@ -0,0 +1,91 @@
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.AllowanceApplication
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 kotlinx.coroutines.launch
class CategoryListFragment : ListWithAddButtonFragment<Category, CategoryListViewModel>() {
override val noItemsStringRes: Int = R.string.categories_no_data
override val viewModel: CategoryListViewModel by viewModels()
override fun reloadItems() {
viewModel.getCategories(viewLifecycleOwner)
}
override fun bindData(data: Category): BindableData<Category> = BindableData(data, CATEGORY_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Category>> = mapOf(CATEGORY_VIEW to { v -> CategoryViewHolder(v, viewModel, findNavController()) })
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onCreate(savedInstanceState)
}
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<Category>(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<Category>) {
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)
}
}
}

View file

@ -0,0 +1,41 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
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.launch
import javax.inject.Inject
class CategoryListViewModel : ViewModel(), AsyncViewModel<List<Category>> {
override val state: MutableLiveData<AsyncState<List<Category>>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var budgetRepo: BudgetRepository
@Inject
lateinit var categoryRepo: CategoryRepository
fun getCategories(lifecycleOwner: LifecycleOwner) {
budgetRepo.currentBudget.observe(lifecycleOwner, Observer {
val budgetId = budgetRepo.currentBudget.value?.id
if (budgetId == null) {
state.postValue(AsyncState.Error("Invalid budget ID"))
return@Observer
}
launch {
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
}
}

View file

@ -0,0 +1,30 @@
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<AccountsAdapter.AccountViewHolder>() {
private val accounts = mutableListOf<Budget>()
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)
}

View file

@ -0,0 +1,114 @@
package com.wbrawner.budget.ui.overview
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import com.github.mikephil.charting.components.AxisBase
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.data.BarData
import com.github.mikephil.charting.data.BarDataSet
import com.github.mikephil.charting.data.BarEntry
import com.github.mikephil.charting.formatter.ValueFormatter
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.toAmountSpannable
import kotlinx.android.synthetic.main.fragment_overview.*
class OverviewFragment : Fragment() {
val viewModel: OverviewViewModel by viewModels()
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
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?) {
expectedActualChart.apply {
setFitBars(true)
xAxis.valueFormatter = object : ValueFormatter() {
override fun getFormattedValue(value: Float): String {
return if (value == 0f) "Income" else "Expenses"
}
override fun getBarLabel(barEntry: BarEntry?): String {
return super.getBarLabel(barEntry)
}
}
xAxis.position = XAxis.XAxisPosition.BOTTOM
xAxis.granularity = 1f
xAxis.isGranularityEnabled = true
setDrawGridBackground(false)
axisLeft.axisMinimum = 0f
axisRight.axisMinimum = 0f
}
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)
val expectedIncome = (state.data.expectedIncome / 100).toFloat()
val expectedExpenses = (state.data.expectedExpenses / 100).toFloat()
val actualIncome = (state.data.actualIncome / 100).toFloat()
val actualExpenses = (state.data.actualExpenses / 100).toFloat()
val max = maxOf(expectedIncome, expectedExpenses, actualIncome, actualExpenses)
expectedActualChart.axisLeft.axisMaximum = max
expectedActualChart.axisRight.axisMaximum = max
expectedActualChart.data = BarData(
BarDataSet(
listOf(BarEntry(0f, expectedIncome), BarEntry(1f, expectedExpenses)),
"Expected"
).apply {
color = ResourcesCompat.getColor(resources, R.color.colorSecondary, requireContext().theme)
},
BarDataSet(
listOf(BarEntry(0f, actualIncome), BarEntry(1f, actualExpenses)),
"Actual"
).apply {
color = ResourcesCompat.getColor(resources, R.color.colorAccent, requireContext().theme)
}
).apply {
barWidth = 0.25f
}
expectedActualChart.groupBars(0f, 0.06f, 0.01f)
}
is AsyncState.Error -> {
overviewContent.visibility = View.GONE
progressBar.visibility = View.GONE
noData.visibility = View.VISIBLE
Log.e("OverviewFragment", "Failed to load overview", state.exception)
}
}
})
}
override fun onStart() {
super.onStart()
viewModel.loadOverview(viewLifecycleOwner)
}
companion object {
const val EXTRA_BUDGET_ID = "budgetId"
}
}

View file

@ -0,0 +1,73 @@
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.category.CategoryRepository
import com.wbrawner.budget.common.transaction.TransactionRepository
import kotlinx.coroutines.launch
import javax.inject.Inject
class OverviewViewModel : ViewModel() {
val state = MutableLiveData<AsyncState<OverviewState>>(AsyncState.Loading)
@Inject
lateinit var budgetRepo: BudgetRepository
@Inject
lateinit var categoryRepo: CategoryRepository
@Inject
lateinit var transactionRepo: TransactionRepository
fun loadOverview(lifecycleOwner: LifecycleOwner) {
budgetRepo.currentBudget.observe(lifecycleOwner, Observer { budget ->
val budgetId = budget?.id
if (budgetId == null) {
state.postValue(AsyncState.Error("Invalid Budget ID"))
return@Observer
}
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
// TODO: Load expected and actual income/expense amounts as well
var expectedExpenses = 0L
var expectedIncome = 0L
var actualExpenses = 0L
var actualIncome = 0L
categoryRepo.findAll(arrayOf(budgetId)).forEach { category ->
val categoryId = category.id ?: return@forEach
val balance = categoryRepo.getBalance(categoryId)
if (category.expense) {
expectedExpenses += category.amount
actualExpenses += (balance * -1)
} else {
expectedIncome += category.amount
actualIncome += balance
}
}
state.postValue(AsyncState.Success(OverviewState(
budget,
budgetRepo.getBalance(budgetId),
expectedIncome,
expectedExpenses,
actualIncome,
actualExpenses
)))
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
}
}
})
}
}
data class OverviewState(
val budget: Budget,
val balance: Long,
val expectedIncome: Long,
val expectedExpenses: Long,
val actualIncome: Long,
val actualExpenses: Long,
)

View file

@ -0,0 +1,23 @@
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)
}
}

View file

@ -0,0 +1,238 @@
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.core.app.NavUtils
import androidx.core.app.TaskStackBuilder
import com.google.android.material.datepicker.MaterialDatePicker
import com.wbrawner.budget.AllowanceApplication
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 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.util.*
import kotlin.coroutines.CoroutineContext
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
(application as AllowanceApplication).appComponent.inject(viewModel)
viewModel.init()
launch {
val accounts = viewModel.getAccounts().toTypedArray()
setCategories()
budgetSpinner.adapter = ArrayAdapter<Budget>(
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))
}
}
}
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)
.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<Category> = emptyList()) {
val adapter = ArrayAdapter<Category>(
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

View file

@ -0,0 +1,48 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.ViewModel
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 javax.inject.Inject
class TransactionFormViewModel : ViewModel() {
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var categoryRepository: CategoryRepository
@Inject
lateinit var transactionRepository: TransactionRepository
@Inject
lateinit var userRepository: UserRepository
var currentUserId: String? = null
private set
//TODO: Find a better way to handle this
fun init() {
userRepository.currentUser.observeForever {
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()
}

View file

@ -0,0 +1,112 @@
package com.wbrawner.budget.ui.transactions
import android.annotation.SuppressLint
import android.content.Context
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.AllowanceApplication
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 java.text.SimpleDateFormat
class TransactionListFragment : ListWithAddButtonFragment<Transaction, TransactionListViewModel>() {
override val noItemsStringRes: Int = R.string.transactions_no_data
override val viewModel: TransactionListViewModel by viewModels()
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Transaction>>
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<Transaction> = BindableData(data, TRANSACTION_VIEW)
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
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<Transaction>(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<Transaction>) {
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)
}
}
}

View file

@ -0,0 +1,33 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
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.launch
import javax.inject.Inject
class TransactionListViewModel : ViewModel(), AsyncViewModel<List<Transaction>> {
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var transactionRepo: TransactionRepository
override val state: MutableLiveData<AsyncState<List<Transaction>>> = MutableLiveData(AsyncState.Loading)
fun getTransactions(
categoryId: String? = null
) {
budgetRepository.currentBudget.observeForever { budget ->
val budgets = budget?.id?.let { listOf(it) }
val categories = categoryId?.let { listOf(it) }
launch {
transactionRepo.findAll(budgets, categories).toList()
}
}
}
}

View file

@ -1,7 +0,0 @@
package com.wbrawner.twigs.android
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TwigsApplication : Application()

View file

@ -1,239 +0,0 @@
package com.wbrawner.twigs.android.ui
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
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.navArgument
import com.wbrawner.twigs.android.R
import com.wbrawner.twigs.android.ui.auth.LoginScreen
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.category.CategoriesScreen
import com.wbrawner.twigs.android.ui.category.CategoryDetailsScreen
import com.wbrawner.twigs.android.ui.recurringtransaction.RecurringTransactionDetailsScreen
import com.wbrawner.twigs.android.ui.recurringtransaction.RecurringTransactionsScreen
import com.wbrawner.twigs.android.ui.transaction.TransactionDetailsScreen
import com.wbrawner.twigs.android.ui.transaction.TransactionsScreen
import com.wbrawner.twigs.shared.Action
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.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.ui.AuthViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalAnimationApi::class)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var store: Store
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val state by store.state.collectAsState()
val navController = rememberNavController()
LaunchedEffect(state.route) {
navController.navigate(state.route.path)
}
TwigsApp {
val authViewModel: AuthViewModel = hiltViewModel()
BackHandler {
store.dispatch(Action.Back)
}
NavHost(navController, state.initialRoute.path) {
composable(Route.Login.path) {
LoginScreen(store = store, viewModel = authViewModel)
}
composable(Route.Overview.path) {
OverviewScreen(store = store)
}
composable(Route.Transactions().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().path) {
CategoriesScreen(store = store)
}
composable(
Route.Categories(selected = "{id}").path,
arguments = listOf(navArgument("id") {
type = NavType.StringType
nullable = false
})
) {
CategoryDetailsScreen(store = store)
}
composable(Route.RecurringTransactions().path) {
RecurringTransactionsScreen(store = store)
}
composable(
Route.RecurringTransactions(selected = "{id}").path,
arguments = listOf(navArgument("id") {
type = NavType.StringType
nullable = false
})
) {
RecurringTransactionDetailsScreen(store = store)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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 = state.route is Route.RecurringTransactions,
onClick = { store.dispatch(RecurringTransactionAction.RecurringTransactionsClicked) },
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
) {
val image =
if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color
Image(painter = painterResource(id = image), null)
Text(
text = "twigs",
style = MaterialTheme.typography.titleLarge
)
}
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()
}
}
)
}
}
}
}
}

View file

@ -1,361 +0,0 @@
package com.wbrawner.twigs.android.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.base.TwigsColors
import com.wbrawner.twigs.android.ui.base.TwigsTheme
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetAction
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OverviewScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(store = store, title = budget?.name ?: "Select a Budget") { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.scrollable(rememberScrollState(), orientation = Orientation.Vertical)
.padding(8.dp),
verticalArrangement = spacedBy(8.dp, alignment = Alignment.Top)
) {
budget?.description?.let { description ->
if (description.isNotBlank()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
Text(description, style = MaterialTheme.typography.titleMedium)
}
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
val month =
remember(state.from) { state.from.toLocalDateTime(TimeZone.UTC).month }
val year =
remember(state.from) { state.from.toLocalDateTime(TimeZone.UTC).year }
var showMonthPicker by remember { mutableStateOf(false) }
LabeledField(
modifier = Modifier.clickable {
showMonthPicker = true
},
label = "Month",
value = "${
month.getDisplayName(
TextStyle.FULL,
LocalConfiguration.current.locales[0]
)
} $year"
)
LabeledField(
label = "Cash Flow",
value = state.budgetBalance?.toCurrencyString() ?: "-"
)
LabeledField(
label = "Transactions",
value = state.transactions?.size?.toString() ?: "-"
)
if (showMonthPicker) {
var monthField by remember { mutableStateOf(month) }
var yearField by remember { mutableStateOf(year.toString()) }
var yearError by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showMonthPicker = false },
confirmButton = {
TextButton({
if (!yearError) {
showMonthPicker = false
store.dispatch(
BudgetAction.SetDateRange(
monthField,
yearField.toInt()
)
)
}
}) {
Text("Change")
}
},
dismissButton = {
TextButton({
showMonthPicker = false
}) {
Text("Cancel")
}
},
title = {
Text("Select a month to view")
},
text = {
val (monthExpanded, setMonthExpanded) = remember {
mutableStateOf(false)
}
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = spacedBy(8.dp)
) {
ExposedDropdownMenuBox(
modifier = Modifier
.weight(1f),
expanded = monthExpanded,
onExpandedChange = setMonthExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = month.getDisplayName(
TextStyle.FULL,
Locale.getDefault()
),
label = {
Text("Month")
},
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = monthExpanded)
}
)
ExposedDropdownMenu(
expanded = monthExpanded,
onDismissRequest = {
setMonthExpanded(false)
}) {
Month.values().forEach { m ->
DropdownMenuItem(
text = {
Text(
m.getDisplayName(
TextStyle.FULL,
Locale.getDefault()
)
)
},
onClick = {
monthField = m
setMonthExpanded(false)
}
)
}
}
}
OutlinedTextField(
modifier = Modifier.weight(1f),
value = yearField,
onValueChange = { value ->
yearField = value
value.toIntOrNull()?.let {
yearError = false
} ?: run {
yearError = true
}
},
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
label = {
Text("Year")
},
isError = yearError,
supportingText = {
if (yearError) {
Text(
"Invalid year",
color = MaterialTheme.colorScheme.error
)
}
}
)
}
}
)
}
}
}
CashFlowChart(
expectedIncome = state.expectedIncome,
actualIncome = state.actualIncome,
expectedExpenses = state.expectedExpenses,
actualExpenses = state.actualExpenses
)
}
}
}
@Composable
fun CashFlowChart(
expectedIncome: Long?,
actualIncome: Long?,
expectedExpenses: Long?,
actualExpenses: Long?,
) {
val maxValue = listOfNotNull(expectedIncome, expectedExpenses, actualIncome, actualExpenses)
.maxOrNull()
?.toFloat()
?: 0f
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
CashFlowProgressBar(
label = "Expected Income",
value = expectedIncome,
maxValue = maxValue,
color = TwigsColors.DarkGreen,
trackColor = MaterialTheme.colorScheme.outline
)
CashFlowProgressBar(
label = "Actual Income",
value = actualIncome,
maxValue = maxValue,
color = TwigsColors.Green,
trackColor = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
CashFlowProgressBar(
label = "Expected Expenses",
value = expectedExpenses,
maxValue = maxValue,
color = TwigsColors.DarkRed,
trackColor = MaterialTheme.colorScheme.outline
)
CashFlowProgressBar(
label = "Actual Expenses",
value = actualExpenses,
maxValue = maxValue,
color = TwigsColors.Red,
trackColor = MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
fun CashFlowProgressBar(
label: String,
value: Long?,
maxValue: Float,
color: Color,
trackColor: Color
) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label)
Text(value?.toCurrencyString() ?: "-")
}
value?.let {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
progress = it.toFloat() / maxValue,
color = color,
trackColor = trackColor,
)
} ?: LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = color,
trackColor = trackColor,
)
}
}
@Composable
fun LabeledField(modifier: Modifier = Modifier, label: String, value: String) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(4.dp)
) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = value, style = MaterialTheme.typography.bodyLarge)
}
}
@Preview
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun CashFlowChart_Preview() {
TwigsTheme {
CashFlowChart(
expectedIncome = 100,
actualIncome = 50,
expectedExpenses = 80,
actualExpenses = 95
)
}
}
@Preview
@Composable
fun LabeledField_Preview() {
TwigsTheme {
LabeledField(
label = "Transactions",
value = "250"
)
}
}

View file

@ -1,19 +0,0 @@
package com.wbrawner.twigs.ui
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.wbrawner.twigs.shared.Store
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
val store: Store
) : ViewModel() {
val server = mutableStateOf("")
val username = mutableStateOf("")
val email = mutableStateOf("")
val password = mutableStateOf("")
val confirmPassword = mutableStateOf("")
val enableLogin = mutableStateOf(false)
}

View file

@ -1,288 +0,0 @@
package com.wbrawner.twigs.android.ui.auth
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
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.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.imePadding
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.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
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.input.key.type
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 com.wbrawner.twigs.android.R
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.shared.Effect
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.ui.AuthViewModel
@ExperimentalAnimationApi
@Composable
fun LoginScreen(
store: Store,
viewModel: AuthViewModel
) {
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 = !state.loading,
enter = fadeIn(),
exit = fadeOut()
) {
LoginForm(
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(
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)
.imePadding(),
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
)
Text("Log in to manage your budgets")
if (error.isNotBlank()) {
Text(text = error, color = MaterialTheme.colorScheme.error)
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(serverInput)
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
return@onPreviewKeyEvent false
}
usernameInput.requestFocus()
true
},
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
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(usernameInput)
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
serverInput.requestFocus()
} else {
passwordInput.requestFocus()
}
true
},
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
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordInput)
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
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()
.focusRequester(loginButton),
enabled = enableLogin,
onClick = login
) {
Text("Login")
}
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = forgotPassword
) {
Text("Forgot password?")
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = register
) {
Text("Need an account?")
}
}
}
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_Preview() {
TwigsApp {
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, {}, {}, { })
}
}

View file

@ -1,94 +0,0 @@
package com.wbrawner.twigs.android.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val Green300 = Color(0xFF81C784)
val Green500 = Color(0xFF4CAF50)
val Green700 = Color(0xFF388E3C)
val Green900 = Color(0xFF1B5E20)
val Red300 = Color(0xFFE57373)
val Red500 = Color(0xFFF44336)
val Red700 = Color(0xFFD32F2F)
val Red900 = Color(0xFFB71C1C)
object TwigsColors {
val Green
@Composable
get() = if (isSystemInDarkTheme()) Green300 else Green700
val DarkGreen
@Composable
get() = if (isSystemInDarkTheme()) Green500 else Green900
val Red
@Composable
get() = if (isSystemInDarkTheme()) Red300 else Red700
val DarkRed
@Composable
get() = if (isSystemInDarkTheme()) Red500 else Red900
}
val md_theme_light_primary = Color(0xFF006E26)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFF6CFF82)
val md_theme_light_onPrimaryContainer = Color(0xFF002106)
val md_theme_light_secondary = Color(0xFF526350)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFD5E8D0)
val md_theme_light_onSecondaryContainer = Color(0xFF101F10)
val md_theme_light_tertiary = Color(0xFF39656B)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFBCEBF2)
val md_theme_light_onTertiaryContainer = Color(0xFF001F23)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFCFDF7)
val md_theme_light_onBackground = Color(0xFF1A1C19)
val md_theme_light_surface = Color(0xFFFCFDF7)
val md_theme_light_onSurface = Color(0xFF1A1C19)
val md_theme_light_surfaceVariant = Color(0xFFDEE5D9)
val md_theme_light_onSurfaceVariant = Color(0xFF424940)
val md_theme_light_outline = Color(0xFF72796F)
val md_theme_light_inverseOnSurface = Color(0xFFF0F1EB)
val md_theme_light_inverseSurface = Color(0xFF2F312D)
val md_theme_light_inversePrimary = Color(0xFF47E266)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF006E26)
val md_theme_light_outlineVariant = Color(0xFFC2C9BD)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF47E266)
val md_theme_dark_onPrimary = Color(0xFF003910)
val md_theme_dark_primaryContainer = Color(0xFF00531A)
val md_theme_dark_onPrimaryContainer = Color(0xFF6CFF82)
val md_theme_dark_secondary = Color(0xFFB9CCB4)
val md_theme_dark_onSecondary = Color(0xFF243424)
val md_theme_dark_secondaryContainer = Color(0xFF3A4B39)
val md_theme_dark_onSecondaryContainer = Color(0xFFD5E8D0)
val md_theme_dark_tertiary = Color(0xFFA1CED5)
val md_theme_dark_onTertiary = Color(0xFF00363C)
val md_theme_dark_tertiaryContainer = Color(0xFF1F4D53)
val md_theme_dark_onTertiaryContainer = Color(0xFFBCEBF2)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1A1C19)
val md_theme_dark_onBackground = Color(0xFFE2E3DD)
val md_theme_dark_surface = Color(0xFF1A1C19)
val md_theme_dark_onSurface = Color(0xFFE2E3DD)
val md_theme_dark_surfaceVariant = Color(0xFF424940)
val md_theme_dark_onSurfaceVariant = Color(0xFFC2C9BD)
val md_theme_dark_outline = Color(0xFF8C9388)
val md_theme_dark_inverseOnSurface = Color(0xFF1A1C19)
val md_theme_dark_inverseSurface = Color(0xFFE2E3DD)
val md_theme_dark_inversePrimary = Color(0xFF006E26)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF47E266)
val md_theme_dark_outlineVariant = Color(0xFF424940)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF30D158)

View file

@ -1,119 +0,0 @@
package com.wbrawner.twigs.android.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
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.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.twigs.android.R
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
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(
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
)
}
@Composable
fun TwigsApp(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
TwigsTheme(darkMode = darkMode) {
Surface(content = content)
}
}

View file

@ -1,152 +0,0 @@
package com.wbrawner.twigs.android.ui.category
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.Arrangement
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.category.groupByType
import com.wbrawner.twigs.shared.recurringtransaction.capitalizedName
import kotlin.math.abs
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoriesScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(CategoryAction.NewCategoryClicked)
}
) {
state.categories?.let { categories ->
val categoryGroups = categories.groupByType()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(it)
.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
categoryGroups.toSortedMap().forEach { (group, c) ->
Text(
modifier = Modifier.padding(8.dp),
text = group.capitalizedName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Card {
c.forEach { category ->
CategoryListItem(category, state.categoryBalances?.get(category.id!!)) {
store.dispatch(CategoryAction.SelectCategory(category.id))
}
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingCategory) {
CategoryFormDialog(store = store)
}
}
}
@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)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(category.title, style = MaterialTheme.typography.bodyLarge)
balance?.let {
Text(
(category.amount - abs(it)).toCurrencyString() + " remaining",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(8.dp))
balance?.let {
val denominator = remember { max(abs(it), abs(category.amount)).toFloat() }
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp)),
progress = { if (denominator == 0f) 0f else abs(it).toFloat() / denominator },
color = if (category.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
trackColor = Color.LightGray
)
} ?: 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
) {}
}
}

View file

@ -1,177 +0,0 @@
package com.wbrawner.twigs.android.ui.category
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.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.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.TransactionFormDialog
import com.wbrawner.twigs.android.ui.transaction.TransactionListItem
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.android.ui.util.format
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
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 kotlin.math.abs
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val category =
remember(state.editingCategory) { state.categories!!.first { it.id == state.selectedCategory } }
TwigsScaffold(
store = store,
title = category.title,
navigationIcon = {
IconButton(onClick = { store.dispatch(Action.Back) }) {
Icon(Icons.Default.ArrowBack, "Go back")
}
},
actions = {
IconButton({ store.dispatch(CategoryAction.EditCategory(requireNotNull(category.id))) }) {
Icon(Icons.Default.Edit, "Edit")
}
},
onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked)
}
) { padding ->
CategoryDetails(
modifier = Modifier.padding(padding),
category = category,
balance = state.categoryBalances!![category.id!!] ?: 0,
transactions = state.transactions!!.filter { it.categoryId == category.id },
onTransactionClicked = { store.dispatch(TransactionAction.SelectTransaction(it.id)) }
)
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
if (state.editingCategory) {
CategoryFormDialog(store = store)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CategoryDetails(
modifier: Modifier = Modifier,
category: Category,
balance: Long,
transactions: List<Transaction>,
onTransactionClicked: (Transaction) -> Unit
) {
val transactionGroups = remember { transactions.groupByDate() }
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
) {
category.description?.let {
item {
Text(modifier = Modifier.padding(8.dp), text = it)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
LabeledCounter("Planned", category.amount.toCurrencyString())
LabeledCounter("Actual", abs(balance).toCurrencyString())
LabeledCounter("Remaining", (category.amount - abs(balance)).toCurrencyString())
}
}
transactionGroups.forEach { (timestamp, transactions) ->
item(timestamp) {
Text(
modifier = Modifier.padding(8.dp),
text = timestamp.toInstant().format(LocalContext.current),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
itemsIndexed(transactions) { index, transaction ->
TransactionListItem(
modifier = Modifier.animateItemPlacement(),
transaction = transaction,
isFirst = index == 0,
isLast = index == transactions.lastIndex,
onClick = onTransactionClicked
)
}
}
}
}
@Composable
fun LabeledCounter(label: String, counter: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(4.dp)
) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = counter, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun CategoryDetails_Preview() {
TwigsApp {
CategoryDetails(
category = Category(title = "Coffee", budgetId = "budget", amount = 1000),
balance = 500,
transactions = listOf(
Transaction(
title = "DAZBOG",
description = "Chokolat Cappuccino",
date = Clock.System.now(),
amount = 550,
categoryId = "coffee",
budgetId = "budget",
createdBy = "user",
expense = true
)
),
onTransactionClicked = {}
)
}
}

View file

@ -1,292 +0,0 @@
package com.wbrawner.twigs.android.ui.category
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.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.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.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.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.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toDecimalString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CategoryFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(CategoryAction.CancelEditCategory) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
CategoryForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryForm(store: Store) {
val state by store.state.collectAsState()
val category = remember {
val defaultCategory = Category(
title = "",
amount = 0L,
expense = true,
budgetId = state.selectedBudget!!,
)
if (state.selectedCategory.isNullOrBlank()) {
defaultCategory
} else {
state.categories?.first { it.id == state.selectedCategory } ?: defaultCategory
}
}
val (title, setTitle) = remember { mutableStateOf(category.title) }
val (description, setDescription) = remember { mutableStateOf(category.description ?: "") }
val (amount, setAmount) = remember { mutableStateOf(category.amount.toDecimalString()) }
val (expense, setExpense) = remember { mutableStateOf(category.expense) }
val (archived, setArchived) = remember { mutableStateOf(category.archived) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(CategoryAction.CancelEditCategory) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (category.id.isNullOrBlank()) "New Category" else "Edit Category")
}
)
}
) {
CategoryForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
archived = archived,
setArchived = setArchived
) {
store.dispatch(
category.id?.let { id ->
CategoryAction.UpdateCategory(
id = id,
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
archived = archived
)
} ?: CategoryAction.CreateCategory(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
archived = archived
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CategoryForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
archived: Boolean,
setArchived: (Boolean) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
val (titleInput, descriptionInput, amountInput) = 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()
}),
)
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")
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { setArchived(!archived) },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = archived, onCheckedChange = { setArchived(!archived) })
Text("Archived")
}
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 CategoryForm_Preview() {
TwigsApp {
CategoryForm(store = Store(reducers = emptyList()))
}
}

View file

@ -1,167 +0,0 @@
package com.wbrawner.twigs.android.ui.recurringtransaction
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.Arrangement
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.Row
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.android.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.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.Time
import com.wbrawner.twigs.shared.user.User
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val transaction =
remember { state.recurringTransactions!!.first { it.id == state.selectedRecurringTransaction } }
val createdBy = state.selectedRecurringTransactionCreatedBy ?: 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(
RecurringTransactionAction.EditRecurringTransaction(
requireNotNull(transaction.id)
)
)
}) {
Icon(Icons.Default.Edit, "Edit")
}
}
) { padding ->
RecurringTransactionDetails(
modifier = Modifier.padding(padding),
transaction = transaction,
category = category,
budget = budget,
createdBy = createdBy
)
if (state.editingRecurringTransaction) {
RecurringTransactionFormDialog(store = store)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionDetails(
modifier: Modifier = Modifier,
transaction: RecurringTransaction,
category: Category? = null,
budget: Budget,
createdBy: User
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
.scrollable(scrollState, Orientation.Vertical)
.padding(16.dp),
verticalArrangement = spacedBy(16.dp)
) {
Text(
text = transaction.title,
style = MaterialTheme.typography.headlineMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
LabeledField("Description", transaction.description ?: "")
LabeledField("Frequency", transaction.frequency.description)
LabeledField("Time", transaction.frequency.time.toString())
LabeledField("Start", transaction.start.format(LocalContext.current))
transaction.finish?.let {
LabeledField("End", it.format(LocalContext.current))
}
LabeledField("Category", category?.title ?: "")
LabeledField("Created By", createdBy.username)
}
}
@Composable
fun LabeledField(label: String, field: String) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
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 {
RecurringTransactionDetails(
transaction = RecurringTransaction(
title = "DAZBOG",
description = "Chokolat Cappuccino",
frequency = Frequency.Daily(1, Time(9, 0, 0)),
start = 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")
)
}
}

View file

@ -1,419 +0,0 @@
package com.wbrawner.twigs.android.ui.recurringtransaction
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.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.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.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.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.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toDecimalString
import com.wbrawner.twigs.android.ui.util.DatePicker
import com.wbrawner.twigs.android.ui.util.FrequencyPicker
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.Time
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun RecurringTransactionFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(RecurringTransactionAction.CancelEditRecurringTransaction) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
RecurringTransactionForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionForm(store: Store) {
val state by store.state.collectAsState()
val transaction = remember {
val defaultTransaction = RecurringTransaction(
title = "",
start = Clock.System.now(),
amount = 0L,
frequency = Frequency.Daily(1, Time(9, 0, 0)),
budgetId = state.selectedBudget!!,
categoryId = state.selectedCategory,
expense = true,
createdBy = state.user!!.id!!
)
if (state.selectedRecurringTransaction.isNullOrBlank()) {
defaultTransaction
} else {
state.recurringTransactions?.first { it.id == state.selectedRecurringTransaction }
?: defaultTransaction
}
}
val (title, setTitle) = remember { mutableStateOf(transaction.title) }
val (description, setDescription) = remember { mutableStateOf(transaction.description ?: "") }
val (frequency, setFrequency) = remember { mutableStateOf(transaction.frequency) }
val (start, setStart) = remember { mutableStateOf(transaction.start) }
val (end, setEnd) = remember { mutableStateOf(transaction.finish) }
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(RecurringTransactionAction.CancelEditRecurringTransaction) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (transaction.id.isNullOrBlank()) "New Transaction" else "Edit Transaction")
}
)
}
) {
RecurringTransactionForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
frequency = frequency,
setFrequency = setFrequency,
start = start,
setStart = setStart,
end = end,
setEnd = setEnd,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
categories = state.categories?.filter { c -> c.expense == expense && !c.archived }
?: emptyList(),
category = category,
setCategory = setCategory
) {
store.dispatch(
transaction.id?.let { id ->
RecurringTransactionAction.UpdateRecurringTransaction(
id = id,
title = title,
amount = (amount.toDouble() * 100).toLong(),
frequency = frequency,
start = start,
end = end,
expense = expense,
category = category,
budget = budget
)
} ?: RecurringTransactionAction.CreateRecurringTransaction(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
frequency = frequency,
start = start,
end = end,
expense = expense,
category = category,
budget = budget
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
frequency: Frequency,
setFrequency: (Frequency) -> Unit,
start: Instant,
setStart: (Instant) -> Unit,
end: Instant?,
setEnd: (Instant?) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
categories: List<Category>,
category: Category?,
setCategory: (Category?) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
val (titleInput, descriptionInput, amountInput) = 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()
}),
)
FrequencyPicker(frequency, setFrequency)
val (startPickerVisible, setStartPickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = start,
setDate = setStart,
label = "Start Date",
dialogVisible = startPickerVisible,
setDialogVisible = setStartPickerVisible
)
val (endExpanded, setEndExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = endExpanded,
onExpandedChange = setEndExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = end?.let { "On Date" } ?: "Never",
onValueChange = {},
readOnly = true,
label = {
Text("End Criteria")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = endExpanded)
}
)
ExposedDropdownMenu(expanded = endExpanded, onDismissRequest = {
setEndExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Never") },
onClick = {
setEnd(null)
setEndExpanded(false)
}
)
DropdownMenuItem(
text = { Text("On Date") },
onClick = {
setEnd(Clock.System.now())
setEndExpanded(false)
}
)
}
}
end?.let {
val (endPickerVisible, setEndPickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = end,
setDate = setEnd,
label = "End Date",
dialogVisible = endPickerVisible,
setDialogVisible = setEndPickerVisible
)
}
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 RecurringTransactionForm_Preview() {
TwigsApp {
RecurringTransactionForm(store = Store(reducers = emptyList()))
}
}

View file

@ -1,142 +0,0 @@
package com.wbrawner.twigs.android.ui.recurringtransaction
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.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.groupByStatus
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked)
}
) {
state.recurringTransactions?.let { transactions ->
val transactionGroups =
remember(state.editingRecurringTransaction) { transactions.groupByStatus() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
transactionGroups.forEach { (title, transactions) ->
Text(
modifier = Modifier.padding(8.dp),
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Card {
transactions.forEach { transaction ->
RecurringTransactionListItem(transaction) {
store.dispatch(
RecurringTransactionAction.SelectRecurringTransaction(
transaction.id
)
)
}
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingRecurringTransaction) {
RecurringTransactionFormDialog(store = store)
}
}
}
@Composable
fun RecurringTransactionListItem(
transaction: RecurringTransaction,
onClick: (RecurringTransaction) -> 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) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun RecurringTransactionListItem_Preview() {
TwigsApp {
RecurringTransactionListItem(
transaction = RecurringTransaction(
title = "Google Store",
description = "Pixel 7 Pro",
frequency = Frequency.parse("Y;1;12-31;12:00:00"),
start = Clock.System.now(),
amount = 129999,
budgetId = "budgetId",
expense = true,
createdBy = "createdBy"
)
) {}
}
}

View file

@ -1,195 +0,0 @@
package com.wbrawner.twigs.android.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.Arrangement
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.Row
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.util.formatWithTime
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.editingTransaction) { 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 }
val (confirmDeletionShown, setConfirmDeletionShown) = remember { mutableStateOf(false) }
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")
}
IconButton({ setConfirmDeletionShown(true) }) {
Icon(Icons.Default.Delete, "Delete")
}
}
) { padding ->
TransactionDetails(
modifier = Modifier.padding(padding),
transaction = transaction,
category = category,
budget = budget,
createdBy = createdBy
)
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
if (confirmDeletionShown) {
AlertDialog(
text = {
Text("Are you sure you want to delete this transaction?")
},
onDismissRequest = { setConfirmDeletionShown(false) },
confirmButton = {
TextButton(onClick = {
setConfirmDeletionShown(false)
store.dispatch(
TransactionAction.DeleteTransaction(
requireNotNull(
transaction.id
)
)
)
}) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = {
setConfirmDeletionShown(false)
}) {
Text("Cancel")
}
}
)
}
}
}
@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(16.dp)
) {
Text(
text = transaction.title,
style = MaterialTheme.typography.headlineMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = transaction.date.formatWithTime(),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
LabeledField("Description", transaction.description ?: "")
LabeledField("Category", category?.title ?: "")
LabeledField("Created By", createdBy.username)
}
}
@Composable
fun LabeledField(label: String, field: String) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
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")
)
}
}

View file

@ -1,360 +0,0 @@
package com.wbrawner.twigs.android.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.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.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.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.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.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.util.DatePicker
import com.wbrawner.twigs.android.ui.util.TimePicker
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
@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(state.editingTransaction) { mutableStateOf(transaction.title) }
val (description, setDescription) = remember(state.editingTransaction) {
mutableStateOf(
transaction.description ?: ""
)
}
val (date, setDate) = remember(state.editingTransaction) { mutableStateOf(transaction.date) }
val (amount, setAmount) = remember(state.editingTransaction) { mutableStateOf(transaction.amount.toDecimalString()) }
val (expense, setExpense) = remember(state.editingTransaction) { mutableStateOf(transaction.expense) }
val budget =
remember(state.editingTransaction) { state.budgets!!.first { it.id == transaction.budgetId } }
val (category, setCategory) = remember(state.editingTransaction) { 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 && !c.archived }
?: 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: 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
)
val (timePickerVisible, setTimePickerVisible) = remember { mutableStateOf(false) }
TimePicker(
modifier = Modifier.fillMaxWidth(),
date = date,
setDate = setDate,
dialogVisible = timePickerVisible,
setDialogVisible = setTimePickerVisible
)
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()))
}
}

View file

@ -1,174 +0,0 @@
package com.wbrawner.twigs.android.ui.transaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.CircularProgressIndicator
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.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.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.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(ExperimentalFoundationApi::class)
@Composable
fun TransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked)
}
) {
state.transactions?.let { transactions ->
val transactionGroups =
remember(state.editingTransaction) { transactions.groupByDate() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
transactionGroups.forEach { (timestamp, transactions) ->
item {
Text(
modifier = Modifier.padding(8.dp),
text = timestamp.toInstant().format(LocalContext.current),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
itemsIndexed(transactions) { index, transaction ->
TransactionListItem(
modifier = Modifier.animateItemPlacement(),
transaction,
index == 0,
index == transactions.lastIndex
) {
store.dispatch(TransactionAction.SelectTransaction(transaction.id))
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
}
}
@Composable
fun TransactionListItem(
modifier: Modifier = Modifier,
transaction: Transaction,
isFirst: Boolean,
isLast: Boolean,
onClick: (Transaction) -> Unit
) {
val top = if (isFirst) MaterialTheme.shapes.medium.topStart else CornerSize(0.dp)
val bottom = if (isLast) MaterialTheme.shapes.medium.bottomStart else CornerSize(0.dp)
Row(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium.copy(
topStart = top,
topEnd = top,
bottomStart = bottom,
bottomEnd = bottom
)
)
.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) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
}
@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"
),
isFirst = true,
isLast = true
) {}
}
}
fun Long.toCurrencyString(): String =
NumberFormat.getCurrencyInstance().format(this.toDouble() / 100.0)
fun Long.toDecimalString(): String = if (this > 0) {
val decimal = (this.toDouble() / 100.0).toString()
if (decimal.length - decimal.lastIndexOf('.') == 2) {
decimal + '0'
} else {
decimal
}
} else {
""
}

View file

@ -1,113 +0,0 @@
package com.wbrawner.twigs.android.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.twigs.android.R
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.text.DateFormat.getDateTimeInstance
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePicker(
modifier: Modifier,
date: Instant,
setDate: (Instant) -> Unit,
label: String = "Date",
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(label)
}
)
val dialog = remember {
val localTime = date.toLocalDateTime(TimeZone.UTC).time
MaterialDatePicker.Builder.datePicker()
.setSelection(date.toEpochMilliseconds())
.setTheme(R.style.DateTimePickerDialogTheme)
.build()
.also { picker ->
picker.addOnPositiveButtonClickListener {
setDate(
LocalDateTime(
Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date,
localTime
).toInstant(TimeZone.UTC)
)
}
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.toLocalDateTime(TimeZone.currentSystemDefault()).date.atStartOfDayIn(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
)
fun Instant.formatWithTime(): String = getDateTimeInstance().format(this.toEpochMilliseconds())

View file

@ -1,398 +0,0 @@
package com.wbrawner.twigs.android.ui.util
import androidx.compose.foundation.horizontalScroll
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.InputChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.shared.recurringtransaction.DayOfMonth
import com.wbrawner.twigs.shared.recurringtransaction.DayOfYear
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.Ordinal
import com.wbrawner.twigs.shared.recurringtransaction.capitalizedName
import com.wbrawner.twigs.shared.recurringtransaction.toMonth
import kotlinx.datetime.Clock
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle
import java.util.Locale
import kotlin.math.min
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FrequencyPicker(frequency: Frequency, setFrequency: (Frequency) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = frequency.count.toString(),
onValueChange = { setFrequency(frequency.update(count = it.toInt())) },
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
label = { Text("Repeat Every") },
)
val (unitExpanded, setUnitExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = unitExpanded,
onExpandedChange = setUnitExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = frequency.name,
onValueChange = {},
readOnly = true,
label = {
Text("Time Unit")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded)
}
)
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = {
setUnitExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Daily") },
onClick = {
setFrequency(Frequency.Daily(frequency.count, frequency.time))
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Weekly") },
onClick = {
setFrequency(Frequency.Weekly(frequency.count, setOf(), frequency.time))
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Monthly") },
onClick = {
setFrequency(
Frequency.Monthly(
frequency.count,
DayOfMonth.FixedDayOfMonth(
Clock.System.now().toLocalDateTime(
TimeZone.UTC
).dayOfMonth
),
frequency.time
)
)
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Yearly") },
onClick = {
val today = Clock.System.now().toLocalDateTime(
TimeZone.UTC
)
setFrequency(
Frequency.Yearly(
frequency.count,
DayOfYear.of(today.monthNumber, today.dayOfMonth),
frequency.time
)
)
setUnitExpanded(false)
}
)
}
}
}
when (frequency) {
is Frequency.Daily -> {
// No additional config needed
}
is Frequency.Weekly -> {
WeeklyFrequencyPicker(frequency, setFrequency)
}
is Frequency.Monthly -> {
MonthlyFrequencyPicker(frequency, setFrequency)
}
is Frequency.Yearly -> {
YearlyFrequencyPicker(frequency, setFrequency)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeeklyFrequencyPicker(frequency: Frequency.Weekly, setFrequency: (Frequency) -> Unit) {
val daysOfWeek = remember { DayOfWeek.values() }
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = spacedBy(8.dp, Alignment.CenterHorizontally)
) {
daysOfWeek.forEach {
val label = remember(it) { it.getDisplayName(TextStyle.SHORT, Locale.getDefault()) }
InputChip(
selected = frequency.daysOfWeek.contains(it),
onClick = {
val selection = frequency.daysOfWeek.toMutableSet()
if (selection.contains(it)) {
selection.remove(it)
} else {
selection.add(it)
}
setFrequency(frequency.copy(daysOfWeek = selection))
},
label = { Text(label) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MonthlyFrequencyPicker(frequency: Frequency.Monthly, setFrequency: (Frequency) -> Unit) {
val (fixedDay, setFixedDay) = remember {
mutableStateOf(
(frequency.dayOfMonth as? DayOfMonth.FixedDayOfMonth)?.day ?: 1
)
}
val (ordinal, setOrdinal) = remember { mutableStateOf((frequency.dayOfMonth as? DayOfMonth.OrdinalDayOfMonth)?.ordinal) }
val (dayOfWeek, setDayOfWeek) = remember {
mutableStateOf(
(frequency.dayOfMonth as? DayOfMonth.OrdinalDayOfMonth)?.dayOfWeek ?: DayOfWeek.SUNDAY
)
}
Row(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) {
val (ordinalExpanded, setOrdinalExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = ordinalExpanded,
onExpandedChange = setOrdinalExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = ordinal?.capitalizedName ?: "Day",
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = ordinalExpanded)
}
)
ExposedDropdownMenu(expanded = ordinalExpanded, onDismissRequest = {
setOrdinalExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Day") },
onClick = {
setOrdinal(null)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.FixedDayOfMonth(
fixedDay
)
)
)
setOrdinalExpanded(false)
}
)
Ordinal.values().forEach { ordinal ->
DropdownMenuItem(
text = { Text(ordinal.capitalizedName) },
onClick = {
setOrdinal(ordinal)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.OrdinalDayOfMonth(
ordinal,
dayOfWeek
)
)
)
setOrdinalExpanded(false)
}
)
}
}
}
}
Spacer(modifier = Modifier.width(8.dp))
Box(modifier = Modifier.weight(1f)) {
val (dayExpanded, setDayExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = dayExpanded,
onExpandedChange = setDayExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = ordinal?.let { dayOfWeek.capitalizedName } ?: fixedDay.toString(),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dayExpanded)
}
)
ExposedDropdownMenu(expanded = dayExpanded, onDismissRequest = {
setDayExpanded(false)
}) {
if (ordinal == null) {
for (day in 1..31) {
DropdownMenuItem(
text = { Text(day.toString()) },
onClick = {
setFixedDay(day)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.FixedDayOfMonth(
day
)
)
)
setDayExpanded(false)
}
)
}
} else {
DayOfWeek.values().forEach { dayOfWeek ->
DropdownMenuItem(
text = { Text(dayOfWeek.capitalizedName) },
onClick = {
setDayOfWeek(dayOfWeek)
setFrequency(
frequency.copy(
dayOfMonth = (frequency.dayOfMonth as DayOfMonth.OrdinalDayOfMonth).copy(
dayOfWeek = dayOfWeek
)
)
)
setDayExpanded(false)
}
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun YearlyFrequencyPicker(frequency: Frequency.Yearly, setFrequency: (Frequency) -> Unit) {
val (month, setMonth) = remember { mutableStateOf(frequency.dayOfYear.month) }
val (day, setDay) = remember { mutableStateOf(frequency.dayOfYear.day) }
Row(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) {
val (monthExpanded, setMonthExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = monthExpanded,
onExpandedChange = setMonthExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = Month.of(month).getDisplayName(TextStyle.FULL, Locale.getDefault()),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = monthExpanded)
}
)
ExposedDropdownMenu(expanded = monthExpanded, onDismissRequest = {
setMonthExpanded(false)
}) {
Month.values().forEach { m ->
DropdownMenuItem(
text = { Text(m.getDisplayName(TextStyle.FULL, Locale.getDefault())) },
onClick = {
setMonth(m.value)
setFrequency(
frequency.copy(
dayOfYear = DayOfYear.of(m.value, min(day, m.maxLength()))
)
)
setMonthExpanded(false)
}
)
}
}
}
}
Box(modifier = Modifier.weight(1f)) {
val (dayExpanded, setDayExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = dayExpanded,
onExpandedChange = setDayExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = day.toString(),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dayExpanded)
}
)
ExposedDropdownMenu(expanded = dayExpanded, onDismissRequest = {
setDayExpanded(false)
}) {
for (d in 1..month.toMonth().maxLength()) {
DropdownMenuItem(
text = { Text(d.toString()) },
onClick = {
setDay(d)
setFrequency(frequency.copy(dayOfYear = DayOfYear.of(month, d)))
setDayExpanded(false)
}
)
}
}
}
}
}
}

View file

@ -1,149 +0,0 @@
package com.wbrawner.twigs.android.ui.util
import android.app.TimePickerDialog
import android.content.Context
import android.text.format.DateFormat
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 com.wbrawner.twigs.shared.recurringtransaction.Time
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePicker(
modifier: Modifier,
date: Instant,
setDate: (Instant) -> Unit,
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
val context = LocalContext.current
OutlinedTextField(
modifier = modifier
.clickable {
setDialogVisible(true)
}
.focusRequester(FocusRequester())
.onFocusChanged {
setDialogVisible(it.hasFocus)
},
value = date.formatTime(context),
onValueChange = {},
readOnly = true,
label = {
Text("Time")
}
)
val dialog = remember {
val localTime = date.toLocalDateTime(TimeZone.currentSystemDefault())
TimePickerDialog(
context,
{ _, hour, minute ->
setDate(
LocalDateTime(
localTime.date,
LocalTime(hour, minute)
).toInstant(TimeZone.UTC)
.minus(java.util.TimeZone.getDefault().rawOffset.milliseconds)
)
},
localTime.hour,
localTime.minute,
DateFormat.is24HourFormat(context)
).also { picker ->
picker.setOnDismissListener {
setDialogVisible(false)
}
}
}
DisposableEffect(key1 = dialogVisible) {
if (dialogVisible) {
context.fragmentManager?.let {
dialog.show()
}
} else if (dialog.isShowing) {
dialog.dismiss()
}
onDispose {
if (dialog.isShowing) {
dialog.dismiss()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePicker(
modifier: Modifier,
time: Time,
setTime: (Time) -> Unit,
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
val context = LocalContext.current
OutlinedTextField(
modifier = modifier
.clickable {
setDialogVisible(true)
}
.focusRequester(FocusRequester())
.onFocusChanged {
setDialogVisible(it.hasFocus)
},
value = time.toString(),
onValueChange = {},
readOnly = true,
label = {
Text("Time")
}
)
val dialog = remember {
val localTime = Clock.System.now().toLocalDateTime(TimeZone.UTC)
TimePickerDialog(
context,
{ _, hour, minute -> setTime(Time(hour, minute, 0)) },
localTime.hour,
localTime.minute,
DateFormat.is24HourFormat(context)
).also { picker ->
picker.setOnDismissListener {
setDialogVisible(false)
}
}
}
DisposableEffect(key1 = dialogVisible) {
if (dialogVisible) {
context.fragmentManager?.let {
dialog.show()
}
} else if (dialog.isShowing) {
dialog.dismiss()
}
onDispose {
if (dialog.isShowing) {
dialog.dismiss()
}
}
}
}
fun Instant.formatTime(context: Context): String =
DateFormat.getTimeFormat(context).format(this.toEpochMilliseconds())

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -2,9 +2,8 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:fillColor="#FF000000"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View file

@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="2.7"
android:scaleY="2.7"
android:translateX="21.6"
android:translateY="21.6">
<path
android:fillColor="@color/colorTextGreen"
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.81 -4.7,-4.4z" />
</group>
</vector>

View file

@ -4,7 +4,7 @@
android:width="48dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#FFffffff"
android:fillColor="#ffffff"
android:pathData="M74.798,72.259L74.798,87.183L74.798,94.176C74.798,117.78 93.703,137.158 117.127,137.925L117.127,137.952L124.59,137.952L126.035,137.952L128.006,137.952L128.006,141.826L128.006,156.282L128.006,167.749C128.006,177.634 132.744,186.941 140.737,192.757C148.729,198.573 159.042,200.217 168.448,197.177L163.857,182.976C158.98,184.553 153.662,183.704 149.519,180.688C145.374,177.672 142.931,172.874 142.931,167.749L142.931,156.282L146.342,156.282L152.361,156.282L153.806,156.282L153.806,156.259C177.231,155.491 196.136,136.113 196.136,112.509L196.136,105.046L196.136,90.59L188.673,90.59L179.243,90.59L177.798,90.59L170.336,90.59L170.336,90.614C158.944,90.988 148.622,95.762 141.023,103.284C135.669,85.812 119.624,72.91 100.596,72.286L100.596,72.262L99.153,72.262L93.134,72.262L82.261,72.262L74.798,72.259zM171.781,105.512L177.798,105.512L179.243,105.512L181.211,105.512L181.211,112.506C181.211,128.527 168.382,141.355 152.361,141.355L146.342,141.355L142.931,141.355L142.931,134.363C142.931,118.342 155.759,105.512 171.781,105.512z"
android:strokeWidth="2.9615941" />
</vector>

View file

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/categoryForm"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_name"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_name"
style="@style/AppTheme.EditText"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_amount"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_amount">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_amount"
style="@style/AppTheme.EditText"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/categoryExpenseContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton
android:id="@+id/expense"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/type_expense" />
<RadioButton
android:id="@+id/income"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/type_income" />
</RadioGroup>
<CheckBox
android:id="@+id/archived"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/archived" />
<TextView
android:id="@+id/budgetHint"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/prompt_budget" />
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/budgetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/scrollView2"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_title"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_title"
style="@style/AppTheme.EditText"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_description"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_description"
style="@style/AppTheme.EditText"
android:inputType="textCapSentences|textMultiLine"
android:scrollHorizontally="false" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_amount"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_amount"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_amount"
style="@style/AppTheme.EditText"
android:inputType="numberDecimal"
android:scrollHorizontally="false" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/container_edit_transaction_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_amount">
<RadioButton
android:id="@+id/edit_transaction_type_expense"
android:text="@string/type_expense"
android:checked="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<RadioButton
android:id="@+id/edit_transaction_type_income"
android:text="@string/type_income"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RadioGroup>
<TextView
android:id="@+id/container_edit_transaction_date"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_transaction_date"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_type" />
<TextView
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:id="@+id/transactionDate"
tools:text="9/23/2019"
android:padding="8dp"
android:textColor="@color/colorTextPrimary"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintEnd_toStartOf="@+id/transactionTime"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_date" />
<TextView
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:id="@+id/transactionTime"
android:padding="8dp"
android:textColor="@color/colorTextPrimary"
tools:text="10:23 pm"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/transactionDate"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_date" />
<TextView
android:id="@+id/budgetHint"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_budget"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transactionDate" />
<Spinner
android:id="@+id/budgetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetHint" />
<TextView
android:id="@+id/container_edit_transaction_category"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_transaction_category"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetSpinner" />
<Spinner
android:id="@+id/edit_transaction_category"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_category" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/menu_main"
android:layout_width="0dp"
android:layout_height="56dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/bottom_navigation" />
<fragment
android:id="@+id/content_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@+id/menu_main"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
android:paddingTop="16dp"
app:headerLayout="@layout/header_navigation_menu"
app:menu="@menu/drawer_navigation" />
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/auth_content"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/auth_graph"
tools:context=".ui.SplashActivity" />

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.budgets.BudgetListFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:fillViewport="true"
android:padding="16dp"
tools:context=".ui.budgets.AddEditBudgetFragment">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/budgetForm"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/descriptionContainer"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_account_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/nameContainer">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/description"
style="@style/AppTheme.EditText"
android:inputType="textMultiLine|textCapSentences" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nameContainer"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_account_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/name"
style="@style/AppTheme.EditText"
android:inputType="text|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<AutoCompleteTextView
android:id="@+id/usersSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_share_with"
app:layout_constraintTop_toBottomOf="@+id/descriptionContainer" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/usersContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/usersSearch" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
</ScrollView>

View file

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/categoryDetails"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/categoryDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/totalLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_total"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toStartOf="@+id/balanceLabel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/balanceLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_balance"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toStartOf="@+id/remainingLabel"
app:layout_constraintStart_toEndOf="@+id/totalLabel"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/remainingLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_remaining"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/balanceLabel"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toStartOf="@+id/balance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/totalLabel"
tools:text="$60.00" />
<TextView
android:id="@+id/balance"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toStartOf="@+id/remaining"
app:layout_constraintStart_toEndOf="@+id/total"
app:layout_constraintTop_toBottomOf="@+id/balanceLabel"
tools:text="$40.00" />
<TextView
android:id="@+id/remaining"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/balance"
app:layout_constraintTop_toBottomOf="@+id/remainingLabel"
tools:text="$20.00" />
<ProgressBar
android:id="@+id/categoryProgress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/total"
tools:progress="40" />
<FrameLayout
android:id="@+id/transactionsFragmentContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/categoryProgress" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/listContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill_vertical|center_horizontal" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:clickable="true"
android:focusable="true"
app:backgroundTint="@color/colorPrimary"
app:srcCompat="@drawable/ic_add_white_24dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/noItemsTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="24sp"
android:visibility="gone"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View file

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/loginContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:background="@color/colorBackgroundPrimary"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/description_logo"
android:src="@drawable/ic_launcher_foreground" />
<TextView
android:id="@+id/formPrompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_login" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/serverContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/prompt_server"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/server"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1"
android:nextFocusRight="@+id/username"
android:nextFocusDown="@+id/username"
android:nextFocusForward="@+id/username" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/prompt_username"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1"
android:nextFocusRight="@+id/password"
android:nextFocusDown="@+id/password"
android:nextFocusForward="@+id/password" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/prompt_password"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:nextFocusLeft="@+id/username"
android:nextFocusUp="@+id/username" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/action_login"
app:cornerRadius="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/forgotPasswordLink"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/title_forgot_password"
app:cornerRadius="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/registerButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/title_register"
app:cornerRadius="16dp" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center">
<LinearLayout
android:id="@+id/overviewContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/balanceLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/label_current_balance" />
<TextView
android:id="@+id/balance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textSize="36sp" />
<com.github.mikephil.charting.charts.HorizontalBarChart
android:id="@+id/expectedActualChart"
android:layout_width="match_parent"
android:layout_height="200dp" />
</LinearLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/noData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="8dp"
android:text="@string/overview_no_data"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="24sp"
android:visibility="gone"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
</ScrollView>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.profile.ProfileFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.auth.LoginFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_transactions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill_vertical|center_horizontal" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/transaction_list_no_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textAlignment="center"
android:textSize="24sp"
android:layout_gravity="center"
android:textColor="@color/colorTextPrimary"
android:visibility="gone" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_transaction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
app:srcCompat="@drawable/ic_add_white_24dp"
android:focusable="true" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/twigsIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_twigs_color"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
style="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@+id/twigsIcon"
app:layout_constraintTop_toTopOf="@+id/twigsIcon"
app:layout_constraintStart_toEndOf="@+id/twigsIcon" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeight"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/budgetName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
style="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@+id/budgetDescription"
app:layout_constraintEnd_toStartOf="@+id/budgetBalance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="US Budget" />
<TextView
android:id="@+id/budgetDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/budgetBalance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetName"
tools:text="Monthly expenses in the US" />
<TextView
android:id="@+id/budgetBalance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="$1,000" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/category_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorTextPrimary"
android:textSize="18sp"
app:layout_constraintBottom_toTopOf="@id/barrier"
app:layout_constraintEnd_toStartOf="@+id/category_amount"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/category_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?android:attr/textAppearanceSmall"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintBottom_toTopOf="@id/barrier"
app:layout_constraintStart_toEndOf="@+id/category_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="category_title,category_amount" />
<ProgressBar
android:id="@+id/category_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toStartOf="@+id/barrier"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/barrier" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/transaction_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorTextPrimary"
android:textSize="18sp"
app:layout_constraintBottom_toTopOf="@+id/transaction_date"
app:layout_constraintEnd_toStartOf="@+id/barrier"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Starbucks" />
<TextView
android:id="@+id/transaction_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintBottom_toTopOf="@+id/transaction_date"
app:layout_constraintEnd_toStartOf="@+id/transaction_amount"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transaction_title"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Grande decaf vanilla latte with a shot of peppermint" />
<TextView
android:id="@+id/transaction_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/transaction_amount"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transaction_description"
app:layout_constraintVertical_chainStyle="packed"
tools:text="12/12/12" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="start"
app:constraint_referenced_ids="transaction_amount" />
<TextView
android:id="@+id/transaction_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="$4.68" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_twigs_outline" />
</adaptive-icon>

View file

@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_twigs_outline" />
</adaptive-icon>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/colorBackgroundPrimary" />
<foreground android:drawable="@drawable/ic_shortcut_add_transaction_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/placeholder">
<fragment
android:id="@+id/loginFragment"
android:name="com.wbrawner.budget.ui.auth.LoginFragment"
android:label="LoginFragment">
<argument
android:name="username"
app:argType="string"
app:nullable="true" />
<argument
android:name="password"
app:argType="string"
app:nullable="true" />
</fragment>
<fragment
android:id="@+id/registerFragment"
android:name="com.wbrawner.budget.ui.auth.RegisterFragment"
android:label="RegisterFragment" />
<fragment
android:id="@+id/placeholder"
android:name="androidx.fragment.app.Fragment" />
<activity
android:id="@+id/mainActivity"
android:name="com.wbrawner.budget.ui.MainActivity"
android:label="activity_transaction_list"
tools:layout="@layout/activity_main" />
</navigation>

View file

@ -6,7 +6,7 @@
app:startDestination="@id/overviewFragment">
<fragment
android:id="@+id/categoryListFragment"
android:name="com.wbrawner.twigs.ui.categories.CategoryListFragment"
android:name="com.wbrawner.budget.ui.categories.CategoryListFragment"
android:label="Categories"
tools:layout="@layout/fragment_transaction_list">
<action
@ -18,12 +18,12 @@
</fragment>
<fragment
android:id="@+id/transactionListFragment"
android:name="com.wbrawner.twigs.ui.transactions.TransactionListFragment"
android:name="com.wbrawner.budget.ui.transactions.TransactionListFragment"
android:label="@string/title_transactions"
tools:layout="@layout/fragment_transaction_list" />
<fragment
android:id="@+id/budgetsFragment"
android:name="com.wbrawner.twigs.ui.budgets.BudgetListFragment"
android:name="com.wbrawner.budget.ui.budgets.BudgetListFragment"
android:label="@string/title_budgets"
tools:layout="@layout/fragment_list_with_add_button">
<action
@ -32,35 +32,30 @@
</fragment>
<fragment
android:id="@+id/addEditBudget"
android:name="com.wbrawner.twigs.ui.budgets.AddEditBudgetFragment"
android:name="com.wbrawner.budget.ui.budgets.AddEditBudgetFragment"
android:label="@string/title_add_budget"
tools:layout="@layout/fragment_add_edit_budget" />
<fragment
android:id="@+id/overviewFragment"
android:name="com.wbrawner.twigs.ui.overview.OverviewFragment"
android:name="com.wbrawner.budget.ui.overview.OverviewFragment"
android:label="@string/title_overview"
tools:layout="@layout/fragment_overview" />
<fragment
android:id="@+id/profileFragment"
android:name="com.wbrawner.twigs.ui.profile.ProfileFragment"
android:name="com.wbrawner.budget.ui.profile.ProfileFragment"
android:label="@string/title_profile"
tools:layout="@layout/fragment_profile" />
<fragment
android:id="@+id/categoryFragment"
android:name="com.wbrawner.twigs.ui.categories.CategoryDetailsFragment"
android:name="com.wbrawner.budget.ui.categories.CategoryDetailsFragment"
android:label="fragment_category"
tools:layout="@layout/fragment_list_with_add_button" />
<activity
android:id="@+id/addEditTransactionActivity"
android:name="com.wbrawner.twigs.ui.transactions.TransactionFormActivity"
android:name="com.wbrawner.budget.ui.transactions.TransactionFormActivity"
android:label="AddEditTransactionActivity" />
<activity
android:id="@+id/addEditCategoryActivity"
android:name="com.wbrawner.twigs.ui.categories.CategoryFormActivity"
android:label="@string/title_add_category" >
<argument
android:name="categoryId"
app:argType="string"
app:nullable="true" />
</activity>
android:name="com.wbrawner.budget.ui.categories.CategoryFormActivity"
android:label="@string/title_add_category" />
</navigation>

View file

@ -8,4 +8,5 @@
<color name="colorTextGreen">#388e3c</color>
<color name="colorTextRed">#d32f2f</color>
<color name="colorTextPrimaryInverted">#FFDADADA</color>
<color name="colorSecondary">#888888</color>
</resources>

View file

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

View file

@ -7,9 +7,10 @@
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="fontFamily">@font/ubuntu</item>
<item name="android:statusBarColor">#00000000</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">?android:attr/windowBackground</item>
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
<item name="android:navigationBarColor">#00000000</item>
<item name="android:navigationBarColor">@color/colorBackgroundPrimary</item>
<item name="android:timePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
<item name="android:datePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
<item name="android:dialogTheme">@style/DialogTheme</item>
@ -29,18 +30,19 @@
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
</style>
<style name="AppTheme" parent="BaseTheme">
<item name="android:windowLightStatusBar">true</item>
</style>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/colorBackgroundPrimary</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
</style>
<style name="AppTheme" parent="BaseTheme" />
<style name="DateTimePickerDialogTheme" parent="BaseDateTimePickerDialogTheme" />
<style name="SplashTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<!-- <item name="android:windowBackground">@drawable/bg_splash</item>-->
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:windowTranslucentStatus">true</item>
</style>
<style name="AppTheme.EditText">
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_width">match_parent</item>

Some files were not shown because too many files have changed in this diff Show more