Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
cf4226d20c |
6
.github/dependabot.yml
vendored
|
@ -1,6 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
21
.github/workflows/auto-merge.yml
vendored
|
@ -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}}
|
||||
|
74
.github/workflows/test.yml
vendored
|
@ -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
|
@ -1,3 +1,2 @@
|
|||
/build
|
||||
acra.properties
|
||||
release/
|
||||
|
|
93
android/build.gradle
Normal file
|
@ -0,0 +1,93 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
def acraProperties = new Properties()
|
||||
try {
|
||||
def acraPropertiesFile = project.file("acra.properties")
|
||||
acraProperties.load(new FileInputStream(acraPropertiesFile))
|
||||
} catch (FileNotFoundException ignored) {
|
||||
logger.warn("Unable to load ACRA properties. Error reporting won't be available")
|
||||
acraProperties['url'] = ""
|
||||
acraProperties['user'] = ""
|
||||
acraProperties['pass'] = ""
|
||||
}
|
||||
|
||||
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"
|
||||
// buildConfigField "String", "API_URL", "\"http://192.168.86.163:8080/\""
|
||||
// buildConfigField "String", "API_URL", "\"http://10.0.2.2:8080/\""
|
||||
buildConfigField "String", "API_URL", "\"https://api.twigs.brawner.dev/\""
|
||||
buildConfigField "String", "ACRA_URL", "\"${acraProperties['url']}\""
|
||||
buildConfigField "String", "ACRA_USER", "\"${acraProperties['user']}\""
|
||||
buildConfigField "String", "ACRA_PASS", "\"${acraProperties['pass']}\""
|
||||
}
|
||||
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: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"
|
||||
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'
|
||||
implementation "ch.acra:acra-http:$acra_version"
|
||||
implementation "ch.acra:acra-advanced-scheduler:$acra_version"
|
||||
|
||||
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"
|
||||
}
|
|
@ -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)
|
||||
}
|
2
android/proguard-rules.pro
vendored
|
@ -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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package com.wbrawner.twigs
|
||||
package com.wbrawner.budget
|
||||
|
||||
import androidx.test.InstrumentationRegistry
|
||||
import androidx.test.runner.AndroidJUnit4
|
|
@ -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";
|
|
@ -1,22 +1,22 @@
|
|||
<?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"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
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>
|
|
@ -0,0 +1,17 @@
|
|||
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()
|
||||
.baseUrl(BuildConfig.API_URL)
|
||||
.context(this)
|
||||
.build()
|
||||
appComponent.errorHandler.init(this)
|
||||
}
|
||||
}
|
46
android/src/main/java/com/wbrawner/budget/AppComponent.kt
Normal file
|
@ -0,0 +1,46 @@
|
|||
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.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.Named
|
||||
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(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 baseUrl(@Named("baseUrl") baseUrl: String): Builder
|
||||
|
||||
@BindsInstance
|
||||
fun context(context: Context): Builder
|
||||
fun build(): AppComponent
|
||||
}
|
||||
}
|
12
android/src/main/java/com/wbrawner/budget/AppModule.kt
Normal file
|
@ -0,0 +1,12 @@
|
|||
package com.wbrawner.budget
|
||||
|
||||
import com.wbrawner.budget.common.util.ErrorHandler
|
||||
import com.wbrawner.budget.util.AcraErrorHandler
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
||||
@Module
|
||||
class AppModule {
|
||||
@Provides
|
||||
fun provideErrorHandler(): ErrorHandler = AcraErrorHandler()
|
||||
}
|
33
android/src/main/java/com/wbrawner/budget/AsyncState.kt
Normal file
|
@ -0,0 +1,33 @@
|
|||
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 <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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
44
android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt
Normal file
|
@ -0,0 +1,44 @@
|
|||
package com.wbrawner.budget.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.wbrawner.budget.R
|
||||
import kotlinx.android.synthetic.main.activity_transaction_list.*
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
EmojiCompat.init(androidx.emoji.bundled.BundledEmojiCompatConfig(this))
|
||||
setContentView(R.layout.activity_transaction_list)
|
||||
setSupportActionBar(action_bar)
|
||||
val navController = findNavController(R.id.content_container)
|
||||
menu_main.setupWithNavController(navController)
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
title = destination.label
|
||||
val showHomeAsUp = when (destination.label) {
|
||||
getString(R.string.title_overview) -> false
|
||||
getString(R.string.title_transactions) -> false
|
||||
getString(R.string.title_profile) -> false
|
||||
getString(R.string.title_categories) -> false
|
||||
else -> true
|
||||
}
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
findNavController(R.id.content_container).navigateUp()
|
||||
return true
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_OPEN_FRAGMENT = "com.wbrawner.budget.MainActivity.EXTRA_OPEN_FRAGMENT"
|
||||
}
|
||||
}
|
|
@ -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, Observer { 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package com.wbrawner.budget.ui
|
||||
|
||||
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 javax.inject.Inject
|
||||
|
||||
class SplashViewModel : ViewModel(), AsyncViewModel<AuthenticationState> {
|
||||
override val state: MutableLiveData<AsyncState<AuthenticationState>> = MutableLiveData(AsyncState.Loading)
|
||||
|
||||
@Inject
|
||||
lateinit var budgetRepository: BudgetRepository
|
||||
|
||||
@Inject
|
||||
lateinit var userRepository: UserRepository
|
||||
|
||||
suspend fun checkForExistingCredentials() {
|
||||
state.postValue(AsyncState.Success(AuthenticationState.Splash))
|
||||
val authState = try {
|
||||
userRepository.getProfile()
|
||||
AuthenticationState.Authenticated
|
||||
} catch (ignored: Exception) {
|
||||
AuthenticationState.Unauthenticated
|
||||
}
|
||||
state.postValue(AsyncState.Success(authState))
|
||||
}
|
||||
|
||||
fun login(username: String, password: String) = launch {
|
||||
try {
|
||||
userRepository.login(username, password).also {
|
||||
loadBudgetData()
|
||||
}
|
||||
AuthenticationState.Authenticated
|
||||
} catch (ignored: Exception) {
|
||||
// TODO: Return error message here
|
||||
AuthenticationState.Unauthenticated
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBudgetData() {
|
||||
budgetRepository.prefetchData()
|
||||
}
|
||||
}
|
||||
|
||||
sealed class AuthenticationState {
|
||||
object Splash : AuthenticationState()
|
||||
object Unauthenticated : AuthenticationState()
|
||||
object Authenticated : AuthenticationState()
|
||||
}
|
|
@ -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.*
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
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 androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import com.wbrawner.budget.AsyncState
|
||||
import com.wbrawner.budget.R
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
password.setOnEditorActionListener { _, _, _ ->
|
||||
submit.performClick()
|
||||
}
|
||||
submit.setOnClickListener {
|
||||
if (!username.ensureNotEmpty() || !password.ensureNotEmpty()) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
viewModel.login(username.text.toString(), password.text.toString())
|
||||
}
|
||||
val usernameString = arguments?.getString(EXTRA_USERNAME)
|
||||
val passwordString = arguments?.getString(EXTRA_PASSWORD)
|
||||
if (!usernameString.isNullOrBlank() && !passwordString.isNullOrBlank()) {
|
||||
username.setText(usernameString)
|
||||
password.setText(passwordString)
|
||||
submit.performClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLoading(isLoading: Boolean) {
|
||||
formPrompt.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_USERNAME = "username"
|
||||
const val EXTRA_PASSWORD = "password"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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: Long? = 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?.getLong(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
|
||||
}
|
||||
}
|
|
@ -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 kotlinx.coroutines.launch
|
||||
import java.lang.Exception
|
||||
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: Long? = 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)
|
||||
}
|
||||
state.postValue(BudgetFormState.Exit)
|
||||
} catch (e: Exception) {
|
||||
state.postValue(BudgetFormState.Failed(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBudget(accountId: Long) {
|
||||
viewModelScope.launch {
|
||||
state.postValue(BudgetFormState.Loading)
|
||||
try {
|
||||
budgetRepository.delete(accountId)
|
||||
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()
|
||||
}
|
|
@ -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 {
|
||||
putLong(EXTRA_BUDGET_ID, budget.id!!)
|
||||
}
|
||||
navController.navigate(R.id.categoryListFragment, bundle)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
putLong(EXTRA_BUDGET_ID, category.budgetId)
|
||||
putLong(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?.getLong(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)
|
||||
}
|
||||
}
|
|
@ -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: Long? = 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(category.id!!) * multiplier
|
||||
CategoryDetails(
|
||||
category,
|
||||
balance,
|
||||
category.amount - balance
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CategoryDetails(
|
||||
val category: Category,
|
||||
val balance: Long,
|
||||
val remaining: Long
|
||||
)
|
|
@ -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: Long? = 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?.getLong(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
|
||||
}
|
||||
}
|
|
@ -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.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: Long? = null) {
|
||||
launch {
|
||||
val category = categoryId?.let {
|
||||
categoryRepository.findById(it)
|
||||
} ?: Category(-1, 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)
|
||||
else
|
||||
categoryRepository.update(category)
|
||||
state.postValue(AsyncState.Exit)
|
||||
} catch (e: Exception) {
|
||||
state.postValue(AsyncState.Error(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCategoryById(id: Long) {
|
||||
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
|
||||
)
|
||||
}
|
|
@ -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 {
|
||||
putLong(EXTRA_CATEGORY_ID, category.id ?: -1)
|
||||
putString(EXTRA_CATEGORY_NAME, category.title)
|
||||
}
|
||||
navController.navigate(R.id.categoryFragment, bundle)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
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.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
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?) {
|
||||
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
|
||||
when (state) {
|
||||
is AsyncState.Loading -> {
|
||||
overviewContent.visibility = View.GONE
|
||||
noData.visibility = View.GONE
|
||||
progressBar.visibility = View.VISIBLE
|
||||
}
|
||||
is AsyncState.Success -> {
|
||||
overviewContent.visibility = View.VISIBLE
|
||||
noData.visibility = View.GONE
|
||||
progressBar.visibility = View.GONE
|
||||
activity?.title = state.data.budget.name
|
||||
balance.text = state.data.balance.toAmountSpannable(view.context)
|
||||
}
|
||||
is AsyncState.Error -> {
|
||||
overviewContent.visibility = View.GONE
|
||||
progressBar.visibility = View.GONE
|
||||
noData.visibility = View.VISIBLE
|
||||
Log.e("OverviewFragment", "Failed to load overview", state.exception)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.loadOverview(viewLifecycleOwner)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_BUDGET_ID = "budgetId"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package com.wbrawner.budget.ui.overview
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.*
|
||||
import com.wbrawner.budget.AsyncState
|
||||
import com.wbrawner.budget.common.budget.Budget
|
||||
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||
import com.wbrawner.budget.common.transaction.TransactionRepository
|
||||
import 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 transactionRepo: TransactionRepository
|
||||
|
||||
fun loadOverview(lifecycleOwner: LifecycleOwner) {
|
||||
budgetRepo.currentBudget.observe(lifecycleOwner, Observer { budget ->
|
||||
if (budget == 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 balance = 0L
|
||||
transactionRepo.findAll(listOf(budget.id!!)).forEach {
|
||||
Log.d("OverviewViewModel", "${it.title} - ${it.amount}")
|
||||
if (it.expense) {
|
||||
balance -= it.amount
|
||||
} else {
|
||||
balance += it.amount
|
||||
}
|
||||
}
|
||||
state.postValue(AsyncState.Success(OverviewState(
|
||||
budget,
|
||||
balance
|
||||
)))
|
||||
} catch (e: Exception) {
|
||||
state.postValue(AsyncState.Error(e))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data class OverviewState(
|
||||
val budget: Budget,
|
||||
val balance: Long
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
package com.wbrawner.budget.ui.transactions
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
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.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: Long? = 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()
|
||||
DatePickerDialog(
|
||||
this@TransactionFormActivity,
|
||||
{ _, year, month, dayOfMonth ->
|
||||
transactionDate.text = DateFormat.getDateFormat(this@TransactionFormActivity)
|
||||
.format(Date(year, month, dayOfMonth))
|
||||
},
|
||||
currentDate.year + 1900,
|
||||
currentDate.month,
|
||||
currentDate.date
|
||||
)
|
||||
.show()
|
||||
}
|
||||
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!!.getLong(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(id = 0, title = getString(R.string.uncategorized),
|
||||
amount = 0, budgetId = 0))
|
||||
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
|
||||
?.let {
|
||||
if (it > 0) it
|
||||
else null
|
||||
}
|
||||
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
|
|
@ -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: Long? = null
|
||||
private set
|
||||
|
||||
//TODO: Find a better way to handle this
|
||||
fun init() {
|
||||
userRepository.currentUser.observeForever {
|
||||
currentUserId = it?.id
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCategories(budgetId: Long, expense: Boolean) = categoryRepository.findAll(arrayOf(budgetId)).filter {
|
||||
it.expense == expense
|
||||
}
|
||||
|
||||
suspend fun getTransaction(id: Long) = transactionRepository.findById(id)
|
||||
|
||||
suspend fun saveTransaction(transaction: Transaction) = if (transaction.id == null)
|
||||
transactionRepository.create(transaction)
|
||||
else
|
||||
transactionRepository.update(transaction)
|
||||
|
||||
suspend fun deleteTransaction(id: Long) = transactionRepository.delete(id)
|
||||
|
||||
suspend fun getAccounts() = budgetRepository.findAll()
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
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?.getLong(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())
|
||||
.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 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
|
||||
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 {
|
||||
putLong(EXTRA_BUDGET_ID, transaction.budgetId)
|
||||
putLong(EXTRA_TRANSACTION_ID, transaction.id ?: -1)
|
||||
}
|
||||
navController.navigate(R.id.addEditTransactionActivity, bundle)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
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 java.util.*
|
||||
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: Long? = null,
|
||||
start: Calendar? = null,
|
||||
end: Calendar? = null
|
||||
) {
|
||||
budgetRepository.currentBudget.observeForever { budget ->
|
||||
val budgets = budget?.id?.let { listOf(it) }
|
||||
val categories = categoryId?.let { listOf(it) }
|
||||
launch {
|
||||
transactionRepo.findAll(budgets, categories, start, end).toList()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package com.wbrawner.budget.util
|
||||
|
||||
import android.app.Application
|
||||
import android.app.job.JobInfo
|
||||
import android.util.Log
|
||||
import com.wbrawner.budget.BuildConfig
|
||||
import com.wbrawner.budget.common.util.ErrorHandler
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.CoreConfigurationBuilder
|
||||
import org.acra.config.HttpSenderConfigurationBuilder
|
||||
import org.acra.config.SchedulerConfigurationBuilder
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.sender.HttpSender
|
||||
|
||||
class AcraErrorHandler : ErrorHandler {
|
||||
|
||||
override fun init(application: Application) {
|
||||
if (BuildConfig.ACRA_URL.isBlank()
|
||||
|| BuildConfig.ACRA_USER.isBlank()
|
||||
|| BuildConfig.ACRA_PASS.isBlank()) {
|
||||
return
|
||||
}
|
||||
val builder = CoreConfigurationBuilder(application)
|
||||
.setBuildConfigClass(BuildConfig::class.java)
|
||||
.setReportFormat(StringFormat.JSON)
|
||||
.setReportContent(
|
||||
ReportField.ANDROID_VERSION,
|
||||
ReportField.APP_VERSION_CODE,
|
||||
ReportField.APP_VERSION_NAME,
|
||||
ReportField.APPLICATION_LOG,
|
||||
ReportField.AVAILABLE_MEM_SIZE,
|
||||
ReportField.BRAND,
|
||||
ReportField.BUILD_CONFIG,
|
||||
ReportField.CRASH_CONFIGURATION,
|
||||
ReportField.CUSTOM_DATA, // Not currently used, but might be useful in the future
|
||||
ReportField.INITIAL_CONFIGURATION,
|
||||
ReportField.PACKAGE_NAME,
|
||||
ReportField.PHONE_MODEL,
|
||||
ReportField.SHARED_PREFERENCES,
|
||||
ReportField.STACK_TRACE,
|
||||
ReportField.STACK_TRACE_HASH,
|
||||
ReportField.THREAD_DETAILS,
|
||||
ReportField.TOTAL_MEM_SIZE,
|
||||
ReportField.USER_APP_START_DATE,
|
||||
ReportField.USER_CRASH_DATE
|
||||
)
|
||||
builder.getPluginConfigurationBuilder(HttpSenderConfigurationBuilder::class.java)
|
||||
.setUri(BuildConfig.ACRA_URL)
|
||||
.setHttpMethod(HttpSender.Method.POST)
|
||||
.setBasicAuthLogin(BuildConfig.ACRA_USER)
|
||||
.setBasicAuthPassword(BuildConfig.ACRA_PASS)
|
||||
.setEnabled(true)
|
||||
builder.getPluginConfigurationBuilder(SchedulerConfigurationBuilder::class.java)
|
||||
.setRequiresNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setEnabled(true)
|
||||
ACRA.init(application, builder)
|
||||
}
|
||||
|
||||
override fun reportException(t: Throwable, message: String?) {
|
||||
@Suppress("ConstantConditionIf")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e("AcraErrorHandler", "Caught exception: $message", t)
|
||||
}
|
||||
ACRA.getErrorReporter().handleException(t)
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package com.wbrawner.twigs.android
|
||||
|
||||
import android.util.Log
|
||||
import com.wbrawner.twigs.shared.ErrorHandler
|
||||
import com.wbrawner.twigs.shared.Store
|
||||
import com.wbrawner.twigs.shared.create
|
||||
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 reportException(t: Throwable, message: String?) {
|
||||
Log.e("ErrorHandler", "Report exception: $message", t)
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providesStore() = Store.create()
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package com.wbrawner.twigs.android
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class TwigsApplication : Application()
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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, {}, {}, { })
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
) {}
|
||||
}
|
||||
}
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
) {}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
""
|
||||
}
|
|
@ -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())
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
BIN
android/src/main/res/drawable-hdpi/ic_shortcut_attach_money.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
android/src/main/res/drawable-hdpi/ic_shortcut_money_off.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
android/src/main/res/drawable-mdpi/ic_shortcut_attach_money.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
android/src/main/res/drawable-mdpi/ic_shortcut_money_off.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
android/src/main/res/drawable-xhdpi/ic_shortcut_attach_money.png
Normal file
After Width: | Height: | Size: 3 KiB |
BIN
android/src/main/res/drawable-xhdpi/ic_shortcut_money_off.png
Normal file
After Width: | Height: | Size: 3 KiB |
After Width: | Height: | Size: 4.9 KiB |
BIN
android/src/main/res/drawable-xxhdpi/ic_shortcut_money_off.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 6.3 KiB |
BIN
android/src/main/res/drawable-xxxhdpi/ic_shortcut_money_off.png
Normal file
After Width: | Height: | Size: 6.2 KiB |
|
@ -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>
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
|
||||
</vector>
|
|
@ -1,10 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z" />
|
||||
</vector>
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true" android:drawable="@drawable/ic_folder" />
|
||||
<item android:drawable="@drawable/ic_folder_open" />
|
||||
</selector>
|
|
@ -1,10 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z"/>
|
||||
</vector>
|
|
@ -1,10 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
|
||||
</vector>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<font-family xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<font
|
||||
app:font="@font/ubuntu_light"
|
||||
app:fontStyle="normal"
|
||||
app:fontWeight="300" />
|
||||
<font
|
||||
app:font="@font/ubuntu_lightitalic"
|
||||
app:fontStyle="italic"
|
||||
app:fontWeight="300" />
|
||||
<font
|
||||
app:font="@font/ubuntu_regular"
|
||||
app:fontStyle="normal"
|
||||
app:fontWeight="400" />
|
||||
<font
|
||||
app:font="@font/ubuntu_italic"
|
||||
app:fontStyle="italic"
|
||||
app:fontWeight="400" />
|
||||
<font
|
||||
app:font="@font/ubuntu_medium"
|
||||
app:fontStyle="normal"
|
||||
app:fontWeight="500" />
|
||||
<font
|
||||
app:font="@font/ubuntu_mediumitalic"
|
||||
app:fontStyle="italic"
|
||||
app:fontWeight="500" />
|
||||
<font
|
||||
app:font="@font/ubuntu_bold"
|
||||
app:fontStyle="normal"
|
||||
app:fontWeight="700" />
|
||||
<font
|
||||
app:font="@font/ubuntu_bolditalic"
|
||||
app:fontStyle="italic"
|
||||
app:fontWeight="700" />
|
||||
</font-family>
|
101
android/src/main/res/layout/activity_add_edit_category.xml
Normal 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>
|
170
android/src/main/res/layout/activity_add_edit_transaction.xml
Normal 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>
|
11
android/src/main/res/layout/activity_splash.xml
Normal 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" />
|
39
android/src/main/res/layout/activity_transaction_list.xml
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?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="match_parent"
|
||||
tools:context=".ui.MainActivity">
|
||||
|
||||
<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/main_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>
|
14
android/src/main/res/layout/fragment_accounts.xml
Normal 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>
|
71
android/src/main/res/layout/fragment_add_edit_budget.xml
Normal 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>
|
117
android/src/main/res/layout/fragment_category_details.xml
Normal 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>
|
|
@ -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>
|
119
android/src/main/res/layout/fragment_login.xml
Normal file
|
@ -0,0 +1,119 @@
|
|||
<?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/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>
|
50
android/src/main/res/layout/fragment_overview.xml
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?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">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/overviewContent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<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" />
|
||||
|
||||
</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>
|
14
android/src/main/res/layout/fragment_profile.xml
Normal 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>
|