Compare commits
2 commits
Author | SHA1 | Date | |
---|---|---|---|
91f2ef3680 | |||
c7cdf2fe9c |
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
|
/build
|
||||||
acra.properties
|
acra.properties
|
||||||
release/
|
|
||||||
|
|
77
android/build.gradle
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlin-android-extensions'
|
||||||
|
apply plugin: 'kotlin-kapt'
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion 30
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.wbrawner.budget"
|
||||||
|
minSdkVersion 23
|
||||||
|
targetSdkVersion 30
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
buildConfigField "String", "API_URL", "\"https://budget-api.intra.wbrawner.com/\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':common')
|
||||||
|
implementation project(':budgetlib')
|
||||||
|
implementation project(':storage')
|
||||||
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||||
|
implementation 'androidx.core:core-ktx:1.3.2'
|
||||||
|
implementation 'androidx.media:media:1.2.1'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||||
|
implementation 'com.google.android.material:material:1.2.1'
|
||||||
|
implementation 'androidx.emoji:emoji-bundled:1.1.0'
|
||||||
|
implementation 'com.github.BlacKCaT27:CurrencyEditText:2.0.2'
|
||||||
|
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
|
||||||
|
// Dagger
|
||||||
|
implementation "com.google.dagger:dagger:$dagger"
|
||||||
|
kapt "com.google.dagger:dagger-compiler:$dagger"
|
||||||
|
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi"
|
||||||
|
|
||||||
|
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
|
||||||
|
|
||||||
|
testImplementation 'junit:junit:4.12'
|
||||||
|
androidTestImplementation 'androidx.test:runner:1.3.0'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'
|
||||||
|
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.2'
|
||||||
|
implementation 'androidx.navigation:navigation-ui-ktx:2.3.2'
|
||||||
|
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-core:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-attr:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-build-config:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-crash:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-disk:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-geiger-counter:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-measurement:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-phoenix:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-recorder:$hyperion"
|
||||||
|
debugImplementation "com.willowtreeapps.hyperion:hyperion-shared-preferences:$hyperion"
|
||||||
|
}
|
|
@ -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.
|
# Add project specific ProGuard rules here.
|
||||||
# You can control the set of applied configuration files using the
|
# 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
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# 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.InstrumentationRegistry
|
||||||
import androidx.test.runner.AndroidJUnit4
|
import androidx.test.runner.AndroidJUnit4
|
|
@ -1,8 +1,4 @@
|
||||||
package com.wbrawner.twigs;
|
package com.wbrawner.budget;
|
||||||
|
|
||||||
import static junit.framework.Assert.assertNull;
|
|
||||||
import static junit.framework.TestCase.assertEquals;
|
|
||||||
import static junit.framework.TestCase.assertTrue;
|
|
||||||
|
|
||||||
import android.arch.persistence.db.SupportSQLiteDatabase;
|
import android.arch.persistence.db.SupportSQLiteDatabase;
|
||||||
import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory;
|
import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory;
|
||||||
|
@ -12,9 +8,9 @@ import android.database.Cursor;
|
||||||
import androidx.test.InstrumentationRegistry;
|
import androidx.test.InstrumentationRegistry;
|
||||||
import androidx.test.runner.AndroidJUnit4;
|
import androidx.test.runner.AndroidJUnit4;
|
||||||
|
|
||||||
import com.wbrawner.twigs.data.BudgetDatabase;
|
import com.wbrawner.budget.data.BudgetDatabase;
|
||||||
import com.wbrawner.twigs.data.migrations.MIGRATION_1_2;
|
import com.wbrawner.budget.data.migrations.MIGRATION_1_2;
|
||||||
import com.wbrawner.twigs.data.migrations.MIGRATION_2_3;
|
import com.wbrawner.budget.data.migrations.MIGRATION_2_3;
|
||||||
|
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
@ -22,6 +18,10 @@ import org.junit.runner.RunWith;
|
||||||
|
|
||||||
import java.io.IOException;
|
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)
|
@RunWith(AndroidJUnit4.class)
|
||||||
public class MigrationTests {
|
public class MigrationTests {
|
||||||
private static final String TEST_DB = "migration-test";
|
private static final String TEST_DB = "migration-test";
|
|
@ -1,9 +1,10 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<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
|
<application
|
||||||
android:name=".TwigsApplication"
|
android:name=".AllowanceApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
@ -11,12 +12,11 @@
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:ignore="GoogleAppIndexingWarning">
|
tools:ignore="GoogleAppIndexingWarning"
|
||||||
|
tools:targetApi="n">
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.SplashActivity"
|
||||||
android:exported="true"
|
android:theme="@style/SplashTheme"
|
||||||
android:resizeableActivity="true"
|
|
||||||
android:theme="@style/Theme.App.Starting"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
@ -27,6 +27,25 @@
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.wbrawner.budget
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class AllowanceApplication : Application() {
|
||||||
|
lateinit var appComponent: AppComponent
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
appComponent = DaggerAppComponent.builder()
|
||||||
|
.context(this)
|
||||||
|
.build()
|
||||||
|
appComponent.errorHandler.init(this)
|
||||||
|
}
|
||||||
|
}
|
44
android/src/main/java/com/wbrawner/budget/AppComponent.kt
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
package com.wbrawner.budget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.wbrawner.budget.common.util.ErrorHandler
|
||||||
|
import com.wbrawner.budget.lib.network.NetworkModule
|
||||||
|
import com.wbrawner.budget.storage.StorageModule
|
||||||
|
import com.wbrawner.budget.ui.MainViewModel
|
||||||
|
import com.wbrawner.budget.ui.SplashViewModel
|
||||||
|
import com.wbrawner.budget.ui.budgets.BudgetFormViewModel
|
||||||
|
import com.wbrawner.budget.ui.budgets.BudgetListViewModel
|
||||||
|
import com.wbrawner.budget.ui.categories.CategoryDetailsViewModel
|
||||||
|
import com.wbrawner.budget.ui.categories.CategoryFormViewModel
|
||||||
|
import com.wbrawner.budget.ui.categories.CategoryListViewModel
|
||||||
|
import com.wbrawner.budget.ui.overview.OverviewViewModel
|
||||||
|
import com.wbrawner.budget.ui.transactions.TransactionFormViewModel
|
||||||
|
import com.wbrawner.budget.ui.transactions.TransactionListViewModel
|
||||||
|
import dagger.BindsInstance
|
||||||
|
import dagger.Component
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Component(modules = [AppModule::class, StorageModule::class, NetworkModule::class])
|
||||||
|
interface AppComponent {
|
||||||
|
fun inject(viewModel: OverviewViewModel)
|
||||||
|
fun inject(viewModel: SplashViewModel)
|
||||||
|
fun inject(viewModel: MainViewModel)
|
||||||
|
fun inject(viewMode: BudgetListViewModel)
|
||||||
|
fun inject(viewModel: BudgetFormViewModel)
|
||||||
|
fun inject(viewModel: CategoryListViewModel)
|
||||||
|
fun inject(viewModel: CategoryDetailsViewModel)
|
||||||
|
fun inject(viewModel: CategoryFormViewModel)
|
||||||
|
fun inject(viewModel: TransactionListViewModel)
|
||||||
|
fun inject(viewModel: TransactionFormViewModel)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
val errorHandler: ErrorHandler
|
||||||
|
|
||||||
|
@Component.Builder
|
||||||
|
interface Builder {
|
||||||
|
@BindsInstance
|
||||||
|
fun context(context: Context): Builder
|
||||||
|
fun build(): AppComponent
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,24 +1,21 @@
|
||||||
package com.wbrawner.twigs.android
|
package com.wbrawner.budget
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.wbrawner.twigs.shared.ErrorHandler
|
import com.wbrawner.budget.common.util.ErrorHandler
|
||||||
import com.wbrawner.twigs.shared.Store
|
|
||||||
import com.wbrawner.twigs.shared.create
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
class AppModule {
|
class AppModule {
|
||||||
@Provides
|
@Provides
|
||||||
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
|
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
|
||||||
|
override fun init(application: Application) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
override fun reportException(t: Throwable, message: String?) {
|
override fun reportException(t: Throwable, message: String?) {
|
||||||
Log.e("ErrorHandler", "Report exception: $message", t)
|
Log.e("ErrorHandler", "Report exception: $message", t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun providesStore() = Store.create()
|
|
||||||
}
|
}
|
35
android/src/main/java/com/wbrawner/budget/AsyncState.kt
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package com.wbrawner.budget
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class AsyncState<out T> {
|
||||||
|
object Loading : AsyncState<Nothing>()
|
||||||
|
class Success<T>(val data: T) : AsyncState<T>()
|
||||||
|
class Error(val exception: Exception) : AsyncState<Nothing>() {
|
||||||
|
constructor(message: String) : this(RuntimeException(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
object Exit : AsyncState<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsyncViewModel<T> {
|
||||||
|
val state: MutableLiveData<AsyncState<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> MutableLiveData<AsyncState<T>>.postValue(value: T) = postValue(AsyncState.Success(value))
|
||||||
|
|
||||||
|
fun <VM, T> VM.launch(block: suspend () -> T): Job where VM : ViewModel, VM : AsyncViewModel<T> = viewModelScope.launch {
|
||||||
|
state.postValue(AsyncState.Loading)
|
||||||
|
try {
|
||||||
|
state.postValue(AsyncState.Success(block()))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postValue(AsyncState.Error(e))
|
||||||
|
Log.e("AsyncViewModel", "Failed to load data", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
96
android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
package com.wbrawner.budget.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.emoji.text.EmojiCompat
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import androidx.navigation.ui.setupWithNavController
|
||||||
|
import com.google.android.material.navigation.NavigationView
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import kotlinx.android.synthetic.main.activity_main.*
|
||||||
|
|
||||||
|
private const val MENU_GROUP_BUDGETS = 50
|
||||||
|
private const val MENU_ITEM_ADD_BUDGET = 100
|
||||||
|
private const val MENU_ITEM_SETTINGS = 101
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||||
|
private lateinit var toggle: ActionBarDrawerToggle
|
||||||
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
EmojiCompat.init(androidx.emoji.bundled.BundledEmojiCompatConfig(this))
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
(application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
setSupportActionBar(action_bar)
|
||||||
|
toggle = ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close)
|
||||||
|
toggle.isDrawerIndicatorEnabled = true
|
||||||
|
toggle.isDrawerSlideAnimationEnabled = true
|
||||||
|
drawerLayout.addDrawerListener(toggle)
|
||||||
|
navigationView.setNavigationItemSelectedListener(this)
|
||||||
|
supportActionBar?.setHomeButtonEnabled(true)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
val navController = findNavController(R.id.content_container)
|
||||||
|
menu_main.setupWithNavController(navController)
|
||||||
|
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||||
|
title = destination.label
|
||||||
|
val homeAsUpIndicator = when (destination.label) {
|
||||||
|
getString(R.string.title_overview) -> R.drawable.ic_menu
|
||||||
|
getString(R.string.title_transactions) -> R.drawable.ic_menu
|
||||||
|
getString(R.string.title_profile) -> R.drawable.ic_menu
|
||||||
|
getString(R.string.title_categories) -> R.drawable.ic_menu
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
supportActionBar?.setHomeAsUpIndicator(homeAsUpIndicator)
|
||||||
|
}
|
||||||
|
viewModel.loadBudgets().observe(this, { list ->
|
||||||
|
val menu = navigationView.menu
|
||||||
|
menu.clear()
|
||||||
|
val budgetsMenu = navigationView.menu.addSubMenu(0, 0, 0, "Budgets")
|
||||||
|
list.budgets.forEachIndexed { index, budget ->
|
||||||
|
budgetsMenu.add(MENU_GROUP_BUDGETS, index, index, budget.name)
|
||||||
|
.setIcon(R.drawable.ic_folder_selectable)
|
||||||
|
}
|
||||||
|
budgetsMenu.setGroupCheckable(MENU_GROUP_BUDGETS, true, true)
|
||||||
|
list.selectedIndex?.let {
|
||||||
|
budgetsMenu.getItem(it).isChecked = true
|
||||||
|
}
|
||||||
|
menu.add(0, MENU_ITEM_ADD_BUDGET, list.budgets.size, R.string.title_add_budget)
|
||||||
|
.setIcon(R.drawable.ic_add_white_24dp)
|
||||||
|
menu.add(1, MENU_ITEM_SETTINGS, list.budgets.size + 1, "Settings")
|
||||||
|
.setIcon(R.drawable.ic_settings)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
if (item.itemId != android.R.id.home) return super.onOptionsItemSelected(item)
|
||||||
|
with(findNavController(R.id.content_container)) {
|
||||||
|
when (currentDestination?.label) {
|
||||||
|
getString(R.string.title_overview) -> drawerLayout.open()
|
||||||
|
getString(R.string.title_transactions) -> drawerLayout.open()
|
||||||
|
getString(R.string.title_profile) -> drawerLayout.open()
|
||||||
|
getString(R.string.title_categories) -> drawerLayout.open()
|
||||||
|
else -> navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_OPEN_FRAGMENT = "com.wbrawner.budget.MainActivity.EXTRA_OPEN_FRAGMENT"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
MENU_ITEM_ADD_BUDGET -> findNavController(R.id.content_container).navigate(R.id.addEditBudget)
|
||||||
|
MENU_ITEM_SETTINGS -> findNavController(R.id.content_container).navigate(R.id.addEditBudget)
|
||||||
|
else -> viewModel.loadBudget(item.itemId)
|
||||||
|
}
|
||||||
|
drawerLayout.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package com.wbrawner.budget.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.wbrawner.budget.common.budget.Budget
|
||||||
|
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||||
|
import com.wbrawner.budget.common.user.UserRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MainViewModel : ViewModel() {
|
||||||
|
@Inject
|
||||||
|
lateinit var budgetRepository: BudgetRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var userRepository: UserRepository
|
||||||
|
|
||||||
|
private val budgets = MutableLiveData<BudgetList>()
|
||||||
|
|
||||||
|
fun loadBudgets(): LiveData<BudgetList> {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val list = budgetRepository.findAll().sortedBy { it.name }
|
||||||
|
budgets.postValue(BudgetList(
|
||||||
|
list,
|
||||||
|
budgetRepository.currentBudget.value?.let {
|
||||||
|
list.indexOf(it)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return budgets
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBudget(index: Int) {
|
||||||
|
val list = budgets.value ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
budgetRepository.findById(list.budgets[index].id!!, true)
|
||||||
|
budgets.postValue(list.copy(selectedIndex = index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetList(
|
||||||
|
val budgets: List<Budget>,
|
||||||
|
val selectedIndex: Int? = null
|
||||||
|
)
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.wbrawner.budget.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
class SplashActivity : AppCompatActivity(), CoroutineScope {
|
||||||
|
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||||
|
private val viewModel: SplashViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
(application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_splash)
|
||||||
|
window.decorView.apply {
|
||||||
|
systemUiVisibility = (
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val navController = findNavController(R.id.auth_content)
|
||||||
|
viewModel.state.observe(this, { state ->
|
||||||
|
when (state) {
|
||||||
|
is AsyncState.Success -> {
|
||||||
|
when (state.data) {
|
||||||
|
is AuthenticationState.Authenticated -> {
|
||||||
|
navController.navigate(R.id.mainActivity)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
is AuthenticationState.Unauthenticated -> {
|
||||||
|
navController.navigate(R.id.loginFragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
launch {
|
||||||
|
viewModel.checkForExistingCredentials()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.wbrawner.budget.ui
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.AsyncViewModel
|
||||||
|
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||||
|
import com.wbrawner.budget.common.user.UserRepository
|
||||||
|
import com.wbrawner.budget.launch
|
||||||
|
import com.wbrawner.budget.lib.network.BaseUrlHelper
|
||||||
|
import com.wbrawner.budget.lib.network.DEFAULT_URL
|
||||||
|
import com.wbrawner.budget.lib.network.PREF_KEY_BASE_URL
|
||||||
|
import com.wbrawner.budget.postValue
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SplashViewModel : ViewModel(), AsyncViewModel<AuthenticationState> {
|
||||||
|
override val state: MutableLiveData<AsyncState<AuthenticationState>> = MutableLiveData(AsyncState.Loading)
|
||||||
|
@Inject
|
||||||
|
lateinit var baseUrlHelper: BaseUrlHelper
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var budgetRepository: BudgetRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var sharedPreferences: SharedPreferences
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var userRepository: UserRepository
|
||||||
|
|
||||||
|
suspend fun checkForExistingCredentials() {
|
||||||
|
if (baseUrlHelper.url == DEFAULT_URL) {
|
||||||
|
state.postValue(AuthenticationState.Unauthenticated())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.postValue(AsyncState.Success(AuthenticationState.Splash))
|
||||||
|
val authState = try {
|
||||||
|
userRepository.getProfile()
|
||||||
|
AuthenticationState.Authenticated
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
AuthenticationState.Unauthenticated()
|
||||||
|
}
|
||||||
|
state.postValue(authState)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(server: String, username: String, password: String) = launch {
|
||||||
|
try {
|
||||||
|
val correctServer = if (server.startsWith("http://") || server.startsWith("https://")) server
|
||||||
|
else "https://$server"
|
||||||
|
baseUrlHelper.url = correctServer
|
||||||
|
userRepository.login(username, password).also {
|
||||||
|
loadBudgetData()
|
||||||
|
}
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString(PREF_KEY_BASE_URL, correctServer)
|
||||||
|
}
|
||||||
|
AuthenticationState.Authenticated
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// TODO: Return error message here
|
||||||
|
AuthenticationState.Unauthenticated(server, username, password, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadBudgetData() {
|
||||||
|
budgetRepository.prefetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AuthenticationState {
|
||||||
|
object Splash : AuthenticationState()
|
||||||
|
class Unauthenticated(
|
||||||
|
val server: String? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
val password: String? = null,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
) : AuthenticationState()
|
||||||
|
|
||||||
|
object Authenticated : AuthenticationState()
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package com.wbrawner.twigs.android.ui
|
package com.wbrawner.budget.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
|
@ -9,7 +9,7 @@ import android.widget.EditText
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import com.wbrawner.twigs.android.R
|
import com.wbrawner.budget.R
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.wbrawner.budget.ui.auth
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.ui.AuthenticationState
|
||||||
|
import com.wbrawner.budget.ui.SplashViewModel
|
||||||
|
import com.wbrawner.budget.ui.ensureNotEmpty
|
||||||
|
import com.wbrawner.budget.ui.show
|
||||||
|
import kotlinx.android.synthetic.main.fragment_login.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple [Fragment] subclass.
|
||||||
|
*/
|
||||||
|
class LoginFragment : Fragment() {
|
||||||
|
private val viewModel: SplashViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? = inflater.inflate(R.layout.fragment_login, container, false)
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
|
||||||
|
when (state) {
|
||||||
|
is AsyncState.Loading -> {
|
||||||
|
handleLoading(true)
|
||||||
|
}
|
||||||
|
is AsyncState.Error -> {
|
||||||
|
handleLoading(false)
|
||||||
|
username.error = "Invalid username/password"
|
||||||
|
password.error = "Invalid username/password"
|
||||||
|
state.exception.printStackTrace()
|
||||||
|
}
|
||||||
|
is AsyncState.Success -> {
|
||||||
|
if (state.data is AuthenticationState.Unauthenticated) {
|
||||||
|
server.setText(state.data.server ?: "")
|
||||||
|
username.setText(state.data.username ?: "")
|
||||||
|
password.setText(state.data.password ?: "")
|
||||||
|
state.data.errorMessage?.let {
|
||||||
|
AlertDialog.Builder(view.context)
|
||||||
|
.setTitle("Login Failed")
|
||||||
|
.setMessage(it)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
password.setOnEditorActionListener { _, _, _ ->
|
||||||
|
submit.performClick()
|
||||||
|
}
|
||||||
|
submit.setOnClickListener {
|
||||||
|
if (!server.ensureNotEmpty() || !username.ensureNotEmpty() || !password.ensureNotEmpty()) {
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
viewModel.login(server.text.toString(), username.text.toString(), password.text.toString())
|
||||||
|
}
|
||||||
|
val serverString = arguments?.getString(EXTRA_SERVER)
|
||||||
|
val usernameString = arguments?.getString(EXTRA_USERNAME)
|
||||||
|
val passwordString = arguments?.getString(EXTRA_PASSWORD)
|
||||||
|
if (!serverString.isNullOrBlank() && !usernameString.isNullOrBlank() && !passwordString.isNullOrBlank()) {
|
||||||
|
server.setText(serverString)
|
||||||
|
username.setText(usernameString)
|
||||||
|
password.setText(passwordString)
|
||||||
|
submit.performClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleLoading(isLoading: Boolean) {
|
||||||
|
formPrompt.show(!isLoading)
|
||||||
|
serverContainer.show(!isLoading)
|
||||||
|
usernameContainer.show(!isLoading)
|
||||||
|
passwordContainer.show(!isLoading)
|
||||||
|
submit.show(!isLoading)
|
||||||
|
registerButton.show(!isLoading)
|
||||||
|
forgotPasswordLink.show(!isLoading)
|
||||||
|
progressBar.show(isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_SERVER = "server"
|
||||||
|
const val EXTRA_USERNAME = "username"
|
||||||
|
const val EXTRA_PASSWORD = "password"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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: String? = null
|
||||||
|
var menu: Menu? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
(requireActivity().application as AllowanceApplication)
|
||||||
|
.appComponent
|
||||||
|
.inject(viewModel)
|
||||||
|
super.onAttach(context)
|
||||||
|
viewModel.getBudget(arguments?.getString(EXTRA_BUDGET_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? = inflater.inflate(R.layout.fragment_add_edit_budget, container, false)
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
inflater.inflate(R.menu.menu_add_edit, menu)
|
||||||
|
if (id != null) {
|
||||||
|
menu.findItem(R.id.action_delete)?.isVisible = true
|
||||||
|
}
|
||||||
|
this.menu = menu
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val suggestionsAdapter = ArrayAdapter<User>(
|
||||||
|
requireContext(),
|
||||||
|
android.R.layout.simple_spinner_item,
|
||||||
|
mutableListOf()
|
||||||
|
)
|
||||||
|
usersSearch.setAdapter(suggestionsAdapter)
|
||||||
|
viewModel.userSuggestions.observe(viewLifecycleOwner, Observer {
|
||||||
|
suggestionsAdapter.clear()
|
||||||
|
suggestionsAdapter.addAll(it)
|
||||||
|
})
|
||||||
|
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
|
||||||
|
when (state) {
|
||||||
|
is BudgetFormState.Loading -> {
|
||||||
|
progressBar.visibility = View.VISIBLE
|
||||||
|
budgetForm.visibility = View.GONE
|
||||||
|
}
|
||||||
|
is BudgetFormState.Success -> {
|
||||||
|
budgetForm.visibility = View.VISIBLE
|
||||||
|
progressBar.visibility = View.GONE
|
||||||
|
activity?.setTitle(state.titleRes)
|
||||||
|
menu?.findItem(R.id.action_delete)?.isVisible = state.showDeleteButton
|
||||||
|
id = state.budget.id
|
||||||
|
name.setText(state.budget.name)
|
||||||
|
description.setText(state.budget.description)
|
||||||
|
}
|
||||||
|
is BudgetFormState.Exit -> {
|
||||||
|
findNavController().navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> findNavController().navigateUp()
|
||||||
|
R.id.action_save -> {
|
||||||
|
viewModel.saveBudget(Budget(
|
||||||
|
id = id,
|
||||||
|
name = name.text.toString(),
|
||||||
|
description = description.text.toString(),
|
||||||
|
users = viewModel.users.value ?: emptyList()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
R.id.action_delete -> {
|
||||||
|
viewModel.deleteBudget(this@AddEditBudgetFragment.id!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
package com.wbrawner.budget.ui.budgets
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.common.budget.Budget
|
||||||
|
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||||
|
import com.wbrawner.budget.common.user.User
|
||||||
|
import com.wbrawner.budget.common.user.UserRepository
|
||||||
|
import com.wbrawner.budget.common.util.randomId
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class BudgetFormViewModel : ViewModel() {
|
||||||
|
|
||||||
|
val state = MutableLiveData<BudgetFormState>(BudgetFormState.Loading)
|
||||||
|
val users = MutableLiveData<List<User>>()
|
||||||
|
val userSuggestions = MutableLiveData<List<User>>()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var budgetRepository: BudgetRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var userRepository: UserRepository
|
||||||
|
|
||||||
|
fun getBudget(id: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.postValue(BudgetFormState.Loading)
|
||||||
|
try {
|
||||||
|
val budget = id?.let {
|
||||||
|
budgetRepository.findById(it)
|
||||||
|
} ?: Budget(name = "")
|
||||||
|
state.postValue(BudgetFormState.Success(budget))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postValue(BudgetFormState.Failed(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBudget(budget: Budget) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.postValue(BudgetFormState.Loading)
|
||||||
|
try {
|
||||||
|
if (budget.id != null) {
|
||||||
|
budgetRepository.update(budget)
|
||||||
|
} else {
|
||||||
|
budgetRepository.create(budget.copy(id = randomId()))
|
||||||
|
}
|
||||||
|
state.postValue(BudgetFormState.Exit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postValue(BudgetFormState.Failed(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteBudget(budgetId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.postValue(BudgetFormState.Loading)
|
||||||
|
try {
|
||||||
|
budgetRepository.delete(budgetId)
|
||||||
|
state.postValue(BudgetFormState.Exit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postValue(BudgetFormState.Failed(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchUsers(query: String) {
|
||||||
|
if (query.isBlank()) {
|
||||||
|
userSuggestions.value = emptyList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
userSuggestions.value = userRepository.findAllByNameLike(query).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addUser(user: User) {
|
||||||
|
users.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class BudgetFormState {
|
||||||
|
object Loading: BudgetFormState()
|
||||||
|
class Success(
|
||||||
|
@StringRes val titleRes: Int,
|
||||||
|
val showDeleteButton: Boolean,
|
||||||
|
val budget: Budget
|
||||||
|
): BudgetFormState() {
|
||||||
|
constructor(budget: Budget): this(
|
||||||
|
budget.id?.let { R.string.title_edit_budget }?: R.string.title_add_budget,
|
||||||
|
budget.id != null,
|
||||||
|
budget
|
||||||
|
)
|
||||||
|
}
|
||||||
|
class Failed(val exception: Exception): BudgetFormState()
|
||||||
|
object Exit: BudgetFormState()
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.wbrawner.budget.ui.budgets
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.common.budget.Budget
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
|
||||||
|
import com.wbrawner.budget.ui.base.BindableAdapter
|
||||||
|
import com.wbrawner.budget.ui.base.BindableData
|
||||||
|
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
|
||||||
|
|
||||||
|
class BudgetListFragment : ListWithAddButtonFragment<Budget, BudgetListViewModel>() {
|
||||||
|
override val noItemsStringRes: Int = R.string.overview_no_data
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
super.onAttach(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val viewModel: BudgetListViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun reloadItems() {
|
||||||
|
viewModel.getBudgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bindData(data: Budget): BindableData<Budget> = BindableData(data, BUDGET_VIEW)
|
||||||
|
|
||||||
|
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Budget>>
|
||||||
|
get() = mapOf(BUDGET_VIEW to { v -> BudgetViewHolder(v, findNavController()) })
|
||||||
|
|
||||||
|
override fun addItem() {
|
||||||
|
findNavController().navigate(R.id.addEditBudget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val BUDGET_VIEW = R.layout.list_item_budget
|
||||||
|
|
||||||
|
class BudgetViewHolder(itemView: View, val navController: NavController) : BindableAdapter.BindableViewHolder<Budget>(itemView) {
|
||||||
|
private val name: TextView = itemView.findViewById(R.id.budgetName)
|
||||||
|
private val description: TextView = itemView.findViewById(R.id.budgetDescription)
|
||||||
|
// private val balance: TextView = itemView.findViewById(R.id.budgetBalance)
|
||||||
|
|
||||||
|
override fun onBind(item: BindableData<Budget>) {
|
||||||
|
val budget = item.data
|
||||||
|
name.text = budget.name
|
||||||
|
if (budget.description.isNullOrBlank()) {
|
||||||
|
description.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
description.visibility = View.VISIBLE
|
||||||
|
description.text = budget.description
|
||||||
|
}
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
val bundle = Bundle().apply {
|
||||||
|
putString(EXTRA_BUDGET_ID, budget.id)
|
||||||
|
}
|
||||||
|
navController.navigate(R.id.categoryListFragment, bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
putString(EXTRA_BUDGET_ID, category.budgetId)
|
||||||
|
putString(EXTRA_CATEGORY_ID, category.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
childFragmentManager.beginTransaction()
|
||||||
|
.replace(R.id.transactionsFragmentContainer, transactionsFragment)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AsyncState.Error -> {
|
||||||
|
categoryDetails.visibility = View.VISIBLE
|
||||||
|
progressBar.visibility = View.GONE
|
||||||
|
Toast.makeText(view.context, "Failed to load context", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
is AsyncState.Exit -> {
|
||||||
|
findNavController().navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
viewModel.getCategory(arguments?.getString(EXTRA_CATEGORY_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
if (item.itemId == R.id.action_edit) {
|
||||||
|
val bundle = Bundle().apply {
|
||||||
|
putLong(EXTRA_CATEGORY_ID, arguments?.getLong(EXTRA_CATEGORY_ID) ?: -1)
|
||||||
|
}
|
||||||
|
findNavController().navigate(R.id.addEditCategoryActivity, bundle)
|
||||||
|
} else if (item.itemId == android.R.id.home) {
|
||||||
|
return findNavController().navigateUp()
|
||||||
|
}
|
||||||
|
return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
inflater.inflate(R.menu.menu_editable, menu)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.wbrawner.budget.ui.categories
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.AsyncViewModel
|
||||||
|
import com.wbrawner.budget.common.category.Category
|
||||||
|
import com.wbrawner.budget.common.category.CategoryRepository
|
||||||
|
import com.wbrawner.budget.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CategoryDetailsViewModel : ViewModel(), AsyncViewModel<CategoryDetails> {
|
||||||
|
override val state: MutableLiveData<AsyncState<CategoryDetails>> = MutableLiveData(AsyncState.Loading)
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var categoryRepo: CategoryRepository
|
||||||
|
|
||||||
|
fun getCategory(id: String? = null) {
|
||||||
|
if (id == null) {
|
||||||
|
state.postValue(AsyncState.Error("Invalid category ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
val category = categoryRepo.findById(id)
|
||||||
|
val multiplier = if (category.expense) -1 else 1
|
||||||
|
val balance = categoryRepo.getBalance(id) * multiplier
|
||||||
|
CategoryDetails(
|
||||||
|
category,
|
||||||
|
balance,
|
||||||
|
category.amount - balance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CategoryDetails(
|
||||||
|
val category: Category,
|
||||||
|
val balance: Long,
|
||||||
|
val remaining: Long
|
||||||
|
)
|
|
@ -0,0 +1,129 @@
|
||||||
|
package com.wbrawner.budget.ui.categories
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.NavUtils
|
||||||
|
import androidx.core.app.TaskStackBuilder
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.common.budget.Budget
|
||||||
|
import com.wbrawner.budget.common.category.Category
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
|
||||||
|
import com.wbrawner.budget.ui.transactions.toLong
|
||||||
|
import kotlinx.android.synthetic.main.activity_add_edit_category.*
|
||||||
|
|
||||||
|
class CategoryFormActivity : AppCompatActivity() {
|
||||||
|
val viewModel: CategoryFormViewModel by viewModels()
|
||||||
|
var id: String? = null
|
||||||
|
var menu: Menu? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_add_edit_category)
|
||||||
|
setSupportActionBar(action_bar)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
(application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
viewModel.state.observe(this, Observer { state ->
|
||||||
|
when (state) {
|
||||||
|
is AsyncState.Loading -> {
|
||||||
|
categoryForm.visibility = View.GONE
|
||||||
|
progressBar.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
is AsyncState.Success -> {
|
||||||
|
categoryForm.visibility = View.VISIBLE
|
||||||
|
progressBar.visibility = View.GONE
|
||||||
|
val category = state.data.category
|
||||||
|
id = category.id
|
||||||
|
setTitle(state.data.titleRes)
|
||||||
|
menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton
|
||||||
|
edit_category_name.setText(category.title)
|
||||||
|
edit_category_amount.setText(String.format("%.02f", (category.amount.toBigDecimal() / 100.toBigDecimal()).toFloat()))
|
||||||
|
expense.isChecked = category.expense
|
||||||
|
income.isChecked = !category.expense
|
||||||
|
archived.isChecked = category.archived
|
||||||
|
budgetSpinner.adapter = ArrayAdapter<Budget>(
|
||||||
|
this@CategoryFormActivity,
|
||||||
|
android.R.layout.simple_list_item_1,
|
||||||
|
state.data.budgets
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is AsyncState.Error -> {
|
||||||
|
// TODO: Show error message
|
||||||
|
categoryForm.visibility = View.VISIBLE
|
||||||
|
progressBar.visibility = View.GONE
|
||||||
|
Toast.makeText(this, "Failed to save Category", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
is AsyncState.Exit -> finish()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
viewModel.loadCategory(intent?.extras?.getString(EXTRA_CATEGORY_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.menu_add_edit, menu)
|
||||||
|
if (id != null) {
|
||||||
|
menu?.findItem(R.id.action_delete)?.isVisible = true
|
||||||
|
}
|
||||||
|
this.menu = menu
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> {
|
||||||
|
val upIntent: Intent? = NavUtils.getParentActivityIntent(this)
|
||||||
|
|
||||||
|
when {
|
||||||
|
upIntent == null -> throw IllegalStateException("No Parent Activity Intent")
|
||||||
|
NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
|
||||||
|
TaskStackBuilder.create(this)
|
||||||
|
.addNextIntentWithParentStack(upIntent)
|
||||||
|
.startActivities()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
NavUtils.navigateUpTo(this, upIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
R.id.action_save -> {
|
||||||
|
if (!validateFields()) return true
|
||||||
|
viewModel.saveCategory(Category(
|
||||||
|
id = id,
|
||||||
|
title = edit_category_name.text.toString(),
|
||||||
|
amount = edit_category_amount.text.toLong(),
|
||||||
|
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
|
||||||
|
expense = expense.isChecked,
|
||||||
|
archived = archived.isChecked
|
||||||
|
))
|
||||||
|
}
|
||||||
|
R.id.action_delete -> {
|
||||||
|
viewModel.deleteCategoryById(this@CategoryFormActivity.id!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateFields(): Boolean {
|
||||||
|
var errors = false
|
||||||
|
if (edit_category_name.text?.isEmpty() == true) {
|
||||||
|
edit_category_name.error = getString(R.string.required_field_name)
|
||||||
|
errors = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edit_category_amount.text.toString().isEmpty()) {
|
||||||
|
edit_category_amount.error = getString(R.string.required_field_amount)
|
||||||
|
errors = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return !errors
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.wbrawner.budget.ui.categories
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.AsyncViewModel
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.common.budget.Budget
|
||||||
|
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||||
|
import com.wbrawner.budget.common.category.Category
|
||||||
|
import com.wbrawner.budget.common.category.CategoryRepository
|
||||||
|
import com.wbrawner.budget.common.util.randomId
|
||||||
|
import com.wbrawner.budget.launch
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CategoryFormViewModel : ViewModel(), AsyncViewModel<CategoryFormState> {
|
||||||
|
override val state: MutableLiveData<AsyncState<CategoryFormState>> = MutableLiveData(AsyncState.Loading)
|
||||||
|
@Inject
|
||||||
|
lateinit var categoryRepository: CategoryRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var budgetRepository: BudgetRepository
|
||||||
|
|
||||||
|
fun loadCategory(categoryId: String? = null) {
|
||||||
|
launch {
|
||||||
|
val category = categoryId?.let {
|
||||||
|
categoryRepository.findById(it)
|
||||||
|
} ?: Category("", title = "", amount = 0)
|
||||||
|
CategoryFormState(
|
||||||
|
category,
|
||||||
|
budgetRepository.findAll().toList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCategory(category: Category) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.postValue(AsyncState.Loading)
|
||||||
|
try {
|
||||||
|
if (category.id == null)
|
||||||
|
categoryRepository.create(category.copy(id = randomId()))
|
||||||
|
else
|
||||||
|
categoryRepository.update(category)
|
||||||
|
state.postValue(AsyncState.Exit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postValue(AsyncState.Error(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCategoryById(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.postValue(AsyncState.Loading)
|
||||||
|
try {
|
||||||
|
categoryRepository.delete(id)
|
||||||
|
state.postValue(AsyncState.Exit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postValue(AsyncState.Error(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CategoryFormState(
|
||||||
|
val category: Category,
|
||||||
|
val budgets: List<Budget>,
|
||||||
|
@StringRes val titleRes: Int,
|
||||||
|
val showDeleteButton: Boolean
|
||||||
|
) {
|
||||||
|
constructor(category: Category, budgets: List<Budget>) : this(
|
||||||
|
category,
|
||||||
|
budgets,
|
||||||
|
category.id?.let { R.string.title_edit_category } ?: R.string.title_add_category,
|
||||||
|
category.id != null
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.wbrawner.budget.ui.categories
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.common.category.Category
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_CATEGORY_NAME
|
||||||
|
import com.wbrawner.budget.ui.base.BindableAdapter
|
||||||
|
import com.wbrawner.budget.ui.base.BindableData
|
||||||
|
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CategoryListFragment : ListWithAddButtonFragment<Category, CategoryListViewModel>() {
|
||||||
|
override val noItemsStringRes: Int = R.string.categories_no_data
|
||||||
|
override val viewModel: CategoryListViewModel by viewModels()
|
||||||
|
override fun reloadItems() {
|
||||||
|
viewModel.getCategories(viewLifecycleOwner)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bindData(data: Category): BindableData<Category> = BindableData(data, CATEGORY_VIEW)
|
||||||
|
|
||||||
|
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Category>> = mapOf(CATEGORY_VIEW to { v -> CategoryViewHolder(v, viewModel, findNavController()) })
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addItem() {
|
||||||
|
startActivity(Intent(activity, CategoryFormActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG_FRAGMENT = "categories"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val CATEGORY_VIEW = R.layout.list_item_category
|
||||||
|
|
||||||
|
class CategoryViewHolder(
|
||||||
|
itemView: View,
|
||||||
|
private val viewModel: CategoryListViewModel,
|
||||||
|
private val navController: NavController
|
||||||
|
) : BindableAdapter.CoroutineViewHolder<Category>(itemView) {
|
||||||
|
private val name: TextView = itemView.findViewById(R.id.category_title)
|
||||||
|
private val amount: TextView = itemView.findViewById(R.id.category_amount)
|
||||||
|
private val progressBar: ProgressBar = itemView.findViewById(R.id.category_progress)
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
override fun onBind(item: BindableData<Category>) {
|
||||||
|
val category = item.data
|
||||||
|
name.text = category.title
|
||||||
|
// TODO: Format according to budget's currency
|
||||||
|
amount.text = String.format("${'$'}%.02f", category.amount / 100.0f)
|
||||||
|
val tintColor = if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
|
||||||
|
val colorStateList = with(itemView.context) {
|
||||||
|
android.content.res.ColorStateList.valueOf(getColor(tintColor))
|
||||||
|
}
|
||||||
|
progressBar.progressTintList = colorStateList
|
||||||
|
progressBar.indeterminateTintList = colorStateList
|
||||||
|
progressBar.max = category.amount.toInt()
|
||||||
|
launch {
|
||||||
|
val balance = viewModel.getBalance(category).toInt()
|
||||||
|
progressBar.isIndeterminate = false
|
||||||
|
progressBar.setProgress(
|
||||||
|
balance,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
amount.text = itemView.context.getString(
|
||||||
|
R.string.balance_remaning,
|
||||||
|
(category.amount - balance) / 100.0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
val bundle = Bundle().apply {
|
||||||
|
putString(EXTRA_CATEGORY_ID, category.id)
|
||||||
|
putString(EXTRA_CATEGORY_NAME, category.title)
|
||||||
|
}
|
||||||
|
navController.navigate(R.id.categoryFragment, bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,114 @@
|
||||||
|
package com.wbrawner.budget.ui.overview
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import com.github.mikephil.charting.components.AxisBase
|
||||||
|
import com.github.mikephil.charting.components.XAxis
|
||||||
|
import com.github.mikephil.charting.data.BarData
|
||||||
|
import com.github.mikephil.charting.data.BarDataSet
|
||||||
|
import com.github.mikephil.charting.data.BarEntry
|
||||||
|
import com.github.mikephil.charting.formatter.ValueFormatter
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.ui.toAmountSpannable
|
||||||
|
import kotlinx.android.synthetic.main.fragment_overview.*
|
||||||
|
|
||||||
|
class OverviewFragment : Fragment() {
|
||||||
|
val viewModel: OverviewViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
super.onAttach(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? = inflater.inflate(R.layout.fragment_overview, container, false)
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
expectedActualChart.apply {
|
||||||
|
setFitBars(true)
|
||||||
|
xAxis.valueFormatter = object : ValueFormatter() {
|
||||||
|
override fun getFormattedValue(value: Float): String {
|
||||||
|
return if (value == 0f) "Income" else "Expenses"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBarLabel(barEntry: BarEntry?): String {
|
||||||
|
return super.getBarLabel(barEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xAxis.position = XAxis.XAxisPosition.BOTTOM
|
||||||
|
xAxis.granularity = 1f
|
||||||
|
xAxis.isGranularityEnabled = true
|
||||||
|
setDrawGridBackground(false)
|
||||||
|
axisLeft.axisMinimum = 0f
|
||||||
|
axisRight.axisMinimum = 0f
|
||||||
|
}
|
||||||
|
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
|
||||||
|
when (state) {
|
||||||
|
is AsyncState.Loading -> {
|
||||||
|
overviewContent.visibility = View.GONE
|
||||||
|
noData.visibility = View.GONE
|
||||||
|
progressBar.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
is AsyncState.Success -> {
|
||||||
|
overviewContent.visibility = View.VISIBLE
|
||||||
|
noData.visibility = View.GONE
|
||||||
|
progressBar.visibility = View.GONE
|
||||||
|
activity?.title = state.data.budget.name
|
||||||
|
balance.text = state.data.balance.toAmountSpannable(view.context)
|
||||||
|
val expectedIncome = (state.data.expectedIncome / 100).toFloat()
|
||||||
|
val expectedExpenses = (state.data.expectedExpenses / 100).toFloat()
|
||||||
|
val actualIncome = (state.data.actualIncome / 100).toFloat()
|
||||||
|
val actualExpenses = (state.data.actualExpenses / 100).toFloat()
|
||||||
|
val max = maxOf(expectedIncome, expectedExpenses, actualIncome, actualExpenses)
|
||||||
|
expectedActualChart.axisLeft.axisMaximum = max
|
||||||
|
expectedActualChart.axisRight.axisMaximum = max
|
||||||
|
expectedActualChart.data = BarData(
|
||||||
|
BarDataSet(
|
||||||
|
listOf(BarEntry(0f, expectedIncome), BarEntry(1f, expectedExpenses)),
|
||||||
|
"Expected"
|
||||||
|
).apply {
|
||||||
|
color = ResourcesCompat.getColor(resources, R.color.colorSecondary, requireContext().theme)
|
||||||
|
},
|
||||||
|
BarDataSet(
|
||||||
|
listOf(BarEntry(0f, actualIncome), BarEntry(1f, actualExpenses)),
|
||||||
|
"Actual"
|
||||||
|
).apply {
|
||||||
|
color = ResourcesCompat.getColor(resources, R.color.colorAccent, requireContext().theme)
|
||||||
|
}
|
||||||
|
).apply {
|
||||||
|
barWidth = 0.25f
|
||||||
|
}
|
||||||
|
expectedActualChart.groupBars(0f, 0.06f, 0.01f)
|
||||||
|
}
|
||||||
|
is AsyncState.Error -> {
|
||||||
|
overviewContent.visibility = View.GONE
|
||||||
|
progressBar.visibility = View.GONE
|
||||||
|
noData.visibility = View.VISIBLE
|
||||||
|
Log.e("OverviewFragment", "Failed to load overview", state.exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
viewModel.loadOverview(viewLifecycleOwner)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_BUDGET_ID = "budgetId"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
package com.wbrawner.budget.ui.overview
|
||||||
|
|
||||||
|
import androidx.lifecycle.*
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.common.budget.Budget
|
||||||
|
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||||
|
import com.wbrawner.budget.common.category.CategoryRepository
|
||||||
|
import com.wbrawner.budget.common.transaction.TransactionRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class OverviewViewModel : ViewModel() {
|
||||||
|
val state = MutableLiveData<AsyncState<OverviewState>>(AsyncState.Loading)
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var budgetRepo: BudgetRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var categoryRepo: CategoryRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var transactionRepo: TransactionRepository
|
||||||
|
|
||||||
|
fun loadOverview(lifecycleOwner: LifecycleOwner) {
|
||||||
|
budgetRepo.currentBudget.observe(lifecycleOwner, Observer { budget ->
|
||||||
|
val budgetId = budget?.id
|
||||||
|
if (budgetId == null) {
|
||||||
|
state.postValue(AsyncState.Error("Invalid Budget ID"))
|
||||||
|
return@Observer
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.postValue(AsyncState.Loading)
|
||||||
|
try {
|
||||||
|
// TODO: Load expected and actual income/expense amounts as well
|
||||||
|
var expectedExpenses = 0L
|
||||||
|
var expectedIncome = 0L
|
||||||
|
var actualExpenses = 0L
|
||||||
|
var actualIncome = 0L
|
||||||
|
categoryRepo.findAll(arrayOf(budgetId)).forEach { category ->
|
||||||
|
val categoryId = category.id ?: return@forEach
|
||||||
|
val balance = categoryRepo.getBalance(categoryId)
|
||||||
|
if (category.expense) {
|
||||||
|
expectedExpenses += category.amount
|
||||||
|
actualExpenses += (balance * -1)
|
||||||
|
} else {
|
||||||
|
expectedIncome += category.amount
|
||||||
|
actualIncome += balance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.postValue(AsyncState.Success(OverviewState(
|
||||||
|
budget,
|
||||||
|
budgetRepo.getBalance(budgetId),
|
||||||
|
expectedIncome,
|
||||||
|
expectedExpenses,
|
||||||
|
actualIncome,
|
||||||
|
actualExpenses
|
||||||
|
)))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postValue(AsyncState.Error(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OverviewState(
|
||||||
|
val budget: Budget,
|
||||||
|
val balance: Long,
|
||||||
|
val expectedIncome: Long,
|
||||||
|
val expectedExpenses: Long,
|
||||||
|
val actualIncome: Long,
|
||||||
|
val actualExpenses: Long,
|
||||||
|
)
|
|
@ -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,238 @@
|
||||||
|
package com.wbrawner.budget.ui.transactions
|
||||||
|
|
||||||
|
import android.app.TimePickerDialog
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.NavUtils
|
||||||
|
import androidx.core.app.TaskStackBuilder
|
||||||
|
import com.google.android.material.datepicker.MaterialDatePicker
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.common.budget.Budget
|
||||||
|
import com.wbrawner.budget.common.category.Category
|
||||||
|
import com.wbrawner.budget.common.transaction.Transaction
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
|
||||||
|
import com.wbrawner.budget.ui.MainActivity
|
||||||
|
import kotlinx.android.synthetic.main.activity_add_edit_transaction.*
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
|
||||||
|
override val coroutineContext: CoroutineContext = Dispatchers.Main
|
||||||
|
|
||||||
|
private val viewModel: TransactionFormViewModel by viewModels()
|
||||||
|
var id: String? = null
|
||||||
|
var menu: Menu? = null
|
||||||
|
var transaction: Transaction? = null
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_add_edit_transaction)
|
||||||
|
setSupportActionBar(action_bar)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
setTitle(R.string.title_add_transaction)
|
||||||
|
edit_transaction_type_expense.isChecked = true
|
||||||
|
(application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
viewModel.init()
|
||||||
|
launch {
|
||||||
|
val accounts = viewModel.getAccounts().toTypedArray()
|
||||||
|
setCategories()
|
||||||
|
budgetSpinner.adapter = ArrayAdapter<Budget>(
|
||||||
|
this@TransactionFormActivity,
|
||||||
|
android.R.layout.simple_list_item_1,
|
||||||
|
accounts
|
||||||
|
)
|
||||||
|
container_edit_transaction_type.setOnCheckedChangeListener { _, _ ->
|
||||||
|
this@TransactionFormActivity.launch {
|
||||||
|
val budget = budgetSpinner.selectedItem as Budget
|
||||||
|
setCategories(viewModel.getCategories(budget.id!!, edit_transaction_type_expense.isChecked))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
budgetSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
this@TransactionFormActivity.launch {
|
||||||
|
val budget = budgetSpinner.selectedItem as Budget
|
||||||
|
setCategories(viewModel.getCategories(budget.id!!, edit_transaction_type_expense.isChecked))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTransaction()
|
||||||
|
transactionDate.setOnClickListener {
|
||||||
|
val currentDate = DateFormat.getDateFormat(this@TransactionFormActivity)
|
||||||
|
.parse(transactionDate.text.toString()) ?: Date()
|
||||||
|
MaterialDatePicker.Builder.datePicker()
|
||||||
|
.setSelection(currentDate.time)
|
||||||
|
.setTheme(R.style.DateTimePickerDialogTheme)
|
||||||
|
.build()
|
||||||
|
.also { picker ->
|
||||||
|
picker.addOnPositiveButtonClickListener {
|
||||||
|
transactionDate.text = DateFormat.getDateFormat(this@TransactionFormActivity)
|
||||||
|
.format(Date(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.show(supportFragmentManager, null)
|
||||||
|
}
|
||||||
|
transactionTime.setOnClickListener {
|
||||||
|
val currentDate = DateFormat.getTimeFormat(this@TransactionFormActivity)
|
||||||
|
.parse(transactionTime.text.toString()) ?: Date()
|
||||||
|
TimePickerDialog(
|
||||||
|
this@TransactionFormActivity,
|
||||||
|
{ _, hourOfDay, minute ->
|
||||||
|
val newTime = Date().apply {
|
||||||
|
hours = hourOfDay
|
||||||
|
minutes = minute
|
||||||
|
}
|
||||||
|
transactionTime.text = DateFormat.getTimeFormat(this@TransactionFormActivity)
|
||||||
|
.format(newTime)
|
||||||
|
},
|
||||||
|
currentDate.hours,
|
||||||
|
currentDate.minutes,
|
||||||
|
DateFormat.is24HourFormat(this@TransactionFormActivity)
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTransaction() {
|
||||||
|
transaction = try {
|
||||||
|
viewModel.getTransaction(intent!!.extras!!.getString(EXTRA_TRANSACTION_ID)!!)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
menu?.findItem(R.id.action_delete)?.isVisible = false
|
||||||
|
val date = Date()
|
||||||
|
transactionDate.text = DateFormat.getDateFormat(this).format(date)
|
||||||
|
transactionTime.text = DateFormat.getTimeFormat(this).format(date)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTitle(R.string.title_edit_transaction)
|
||||||
|
id = transaction?.id
|
||||||
|
menu?.findItem(R.id.action_delete)?.isVisible = true
|
||||||
|
edit_transaction_title.setText(transaction?.title)
|
||||||
|
edit_transaction_description.setText(transaction?.description)
|
||||||
|
edit_transaction_amount.setText(String.format("%.02f", transaction!!.amount / 100.0f))
|
||||||
|
if (transaction!!.expense) {
|
||||||
|
edit_transaction_type_expense.isChecked = true
|
||||||
|
} else {
|
||||||
|
edit_transaction_type_income.isChecked = true
|
||||||
|
}
|
||||||
|
transactionDate.text = DateFormat.getDateFormat(this).format(transaction!!.date)
|
||||||
|
transactionTime.text = DateFormat.getTimeFormat(this).format(transaction!!.date)
|
||||||
|
transaction?.categoryId?.let {
|
||||||
|
for (i in 0 until edit_transaction_category.adapter.count) {
|
||||||
|
if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
|
||||||
|
edit_transaction_category.setSelection(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCategories(categories: List<Category> = emptyList()) {
|
||||||
|
val adapter = ArrayAdapter<Category>(
|
||||||
|
this@TransactionFormActivity,
|
||||||
|
android.R.layout.simple_list_item_1
|
||||||
|
)
|
||||||
|
adapter.add(Category(title = getString(R.string.uncategorized),
|
||||||
|
amount = 0, budgetId = ""))
|
||||||
|
adapter.addAll(categories)
|
||||||
|
edit_transaction_category.adapter = adapter
|
||||||
|
transaction?.categoryId?.let {
|
||||||
|
for (i in 0 until edit_transaction_category.adapter.count) {
|
||||||
|
if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
|
||||||
|
edit_transaction_category.setSelection(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.menu_add_edit, menu)
|
||||||
|
if (id != null) {
|
||||||
|
menu?.findItem(R.id.action_delete)?.isVisible = true
|
||||||
|
}
|
||||||
|
this.menu = menu
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> onNavigateUp()
|
||||||
|
R.id.action_save -> {
|
||||||
|
val date = GregorianCalendar.getInstance().apply {
|
||||||
|
DateFormat.getDateFormat(this@TransactionFormActivity)
|
||||||
|
.parse(transactionDate.text.toString())
|
||||||
|
?.let {
|
||||||
|
time = it
|
||||||
|
}
|
||||||
|
DateFormat.getTimeFormat(this@TransactionFormActivity)
|
||||||
|
.parse(transactionTime.text.toString())
|
||||||
|
?.let { GregorianCalendar.getInstance().apply { time = it } }
|
||||||
|
?.let {
|
||||||
|
set(Calendar.HOUR_OF_DAY, it.get(Calendar.HOUR_OF_DAY))
|
||||||
|
set(Calendar.MINUTE, it.get(Calendar.MINUTE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val categoryId = (edit_transaction_category.selectedItem as? Category)?.id
|
||||||
|
launch {
|
||||||
|
viewModel.saveTransaction(Transaction(
|
||||||
|
id = id,
|
||||||
|
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
|
||||||
|
title = edit_transaction_title.text.toString(),
|
||||||
|
date = date.time,
|
||||||
|
description = edit_transaction_description.text.toString(),
|
||||||
|
amount = (BigDecimal(edit_transaction_amount.text.toString()) * 100.toBigDecimal()).toLong(),
|
||||||
|
expense = edit_transaction_type_expense.isChecked,
|
||||||
|
categoryId = categoryId,
|
||||||
|
createdBy = viewModel.currentUserId!!
|
||||||
|
))
|
||||||
|
onNavigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
R.id.action_delete -> {
|
||||||
|
launch {
|
||||||
|
viewModel.deleteTransaction(this@TransactionFormActivity.id!!)
|
||||||
|
onNavigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNavigateUp(): Boolean {
|
||||||
|
val upIntent: Intent = NavUtils.getParentActivityIntent(this)
|
||||||
|
?: throw IllegalStateException("No Parent Activity Intent")
|
||||||
|
|
||||||
|
upIntent.putExtra(MainActivity.EXTRA_OPEN_FRAGMENT, TransactionListFragment.TAG_FRAGMENT)
|
||||||
|
when {
|
||||||
|
NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
|
||||||
|
TaskStackBuilder.create(this)
|
||||||
|
.addNextIntentWithParentStack(upIntent)
|
||||||
|
.startActivities()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Editable?.toLong(): Long = toString().toDouble().toLong() * 100
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.wbrawner.budget.ui.transactions
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||||
|
import com.wbrawner.budget.common.category.CategoryRepository
|
||||||
|
import com.wbrawner.budget.common.transaction.Transaction
|
||||||
|
import com.wbrawner.budget.common.transaction.TransactionRepository
|
||||||
|
import com.wbrawner.budget.common.user.UserRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TransactionFormViewModel : ViewModel() {
|
||||||
|
@Inject
|
||||||
|
lateinit var budgetRepository: BudgetRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var categoryRepository: CategoryRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var transactionRepository: TransactionRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var userRepository: UserRepository
|
||||||
|
|
||||||
|
var currentUserId: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
//TODO: Find a better way to handle this
|
||||||
|
fun init() {
|
||||||
|
userRepository.currentUser.observeForever {
|
||||||
|
currentUserId = it?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCategories(budgetId: String, expense: Boolean) = categoryRepository.findAll(arrayOf(budgetId)).filter {
|
||||||
|
it.expense == expense
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTransaction(id: String) = transactionRepository.findById(id)
|
||||||
|
|
||||||
|
suspend fun saveTransaction(transaction: Transaction) = if (transaction.id == null)
|
||||||
|
transactionRepository.create(transaction)
|
||||||
|
else
|
||||||
|
transactionRepository.update(transaction)
|
||||||
|
|
||||||
|
suspend fun deleteTransaction(id: String) = transactionRepository.delete(id)
|
||||||
|
|
||||||
|
suspend fun getAccounts() = budgetRepository.findAll()
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
package com.wbrawner.budget.ui.transactions
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.wbrawner.budget.AllowanceApplication
|
||||||
|
import com.wbrawner.budget.R
|
||||||
|
import com.wbrawner.budget.common.transaction.Transaction
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
|
||||||
|
import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
|
||||||
|
import com.wbrawner.budget.ui.base.BindableAdapter
|
||||||
|
import com.wbrawner.budget.ui.base.BindableData
|
||||||
|
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
|
||||||
|
class TransactionListFragment : ListWithAddButtonFragment<Transaction, TransactionListViewModel>() {
|
||||||
|
override val noItemsStringRes: Int = R.string.transactions_no_data
|
||||||
|
override val viewModel: TransactionListViewModel by viewModels()
|
||||||
|
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Transaction>>
|
||||||
|
get() = mapOf(TRANSACTION_VIEW to { v -> TransactionViewHolder(v, findNavController()) })
|
||||||
|
|
||||||
|
override fun reloadItems() {
|
||||||
|
viewModel.getTransactions(categoryId = arguments?.getString(EXTRA_CATEGORY_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bindData(data: Transaction): BindableData<Transaction> = BindableData(data, TRANSACTION_VIEW)
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
|
||||||
|
super.onAttach(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
inflater.inflate(R.menu.menu_transaction_list, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
if (item.itemId != R.id.filter) {
|
||||||
|
return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
// TODO: Launch a Google Drive-style search/filter screen
|
||||||
|
AlertDialog.Builder(requireContext(), R.style.DialogTheme)
|
||||||
|
.setTitle("Filter Transactions")
|
||||||
|
.setPositiveButton(R.string.action_submit) { _, _ ->
|
||||||
|
reloadItems()
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.action_cancel) { _, _ ->
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addItem() {
|
||||||
|
startActivity(Intent(activity, TransactionFormActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG_FRAGMENT = "transactions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val TRANSACTION_VIEW = R.layout.list_item_transaction
|
||||||
|
|
||||||
|
class TransactionViewHolder(itemView: View, val navController: NavController) : BindableAdapter.CoroutineViewHolder<Transaction>(itemView) {
|
||||||
|
private val name: TextView = itemView.findViewById(R.id.transaction_title)
|
||||||
|
private val description: TextView = itemView.findViewById(R.id.transaction_description)
|
||||||
|
private val date: TextView = itemView.findViewById(R.id.transaction_date)
|
||||||
|
private val amount: TextView = itemView.findViewById(R.id.transaction_amount)
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
override fun onBind(item: BindableData<Transaction>) {
|
||||||
|
val transaction = item.data
|
||||||
|
name.text = transaction.title
|
||||||
|
if (transaction.description.isNullOrBlank()) {
|
||||||
|
description.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
description.visibility = View.VISIBLE
|
||||||
|
description.text = transaction.description
|
||||||
|
}
|
||||||
|
date.text = SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT).format(transaction.date)
|
||||||
|
amount.text = String.format("${'$'}%.02f", transaction.amount / 100.0f)
|
||||||
|
val context = itemView.context
|
||||||
|
val color = if (transaction.expense) R.color.colorTextRed else R.color.colorTextGreen
|
||||||
|
amount.setTextColor(ContextCompat.getColor(context, color))
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
val bundle = Bundle().apply {
|
||||||
|
putString(EXTRA_BUDGET_ID, transaction.budgetId)
|
||||||
|
putString(EXTRA_TRANSACTION_ID, transaction.id)
|
||||||
|
}
|
||||||
|
navController.navigate(R.id.addEditTransactionActivity, bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.wbrawner.budget.ui.transactions
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.wbrawner.budget.AsyncState
|
||||||
|
import com.wbrawner.budget.AsyncViewModel
|
||||||
|
import com.wbrawner.budget.common.budget.BudgetRepository
|
||||||
|
import com.wbrawner.budget.common.transaction.Transaction
|
||||||
|
import com.wbrawner.budget.common.transaction.TransactionRepository
|
||||||
|
import com.wbrawner.budget.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TransactionListViewModel : ViewModel(), AsyncViewModel<List<Transaction>> {
|
||||||
|
@Inject
|
||||||
|
lateinit var budgetRepository: BudgetRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var transactionRepo: TransactionRepository
|
||||||
|
override val state: MutableLiveData<AsyncState<List<Transaction>>> = MutableLiveData(AsyncState.Loading)
|
||||||
|
|
||||||
|
fun getTransactions(
|
||||||
|
categoryId: String? = null
|
||||||
|
) {
|
||||||
|
budgetRepository.currentBudget.observeForever { budget ->
|
||||||
|
val budgets = budget?.id?.let { listOf(it) }
|
||||||
|
val categories = categoryId?.let { listOf(it) }
|
||||||
|
launch {
|
||||||
|
transactionRepo.findAll(budgets, categories).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24.0"
|
android:viewportWidth="24.0"
|
||||||
android:viewportHeight="24.0"
|
android:viewportHeight="24.0">
|
||||||
android:tint="?attr/colorControlNormal">
|
|
||||||
<path
|
<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" />
|
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>
|
</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"
|
android:width="48dp"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<path
|
<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: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" />
|
android:strokeWidth="2.9615941" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|
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>
|
56
android/src/main/res/layout/activity_main.xml
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/drawerLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
|
tools:context=".ui.MainActivity">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/action_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
android:elevation="4dp"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||||
|
android:id="@+id/menu_main"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:menu="@menu/bottom_navigation" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/content_container"
|
||||||
|
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:defaultNavHost="true"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/menu_main"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/action_bar"
|
||||||
|
app:navGraph="@navigation/nav_graph" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.navigation.NavigationView
|
||||||
|
android:id="@+id/navigationView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_gravity="start"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
app:headerLayout="@layout/header_navigation_menu"
|
||||||
|
app:menu="@menu/drawer_navigation" />
|
||||||
|
|
||||||
|
</androidx.drawerlayout.widget.DrawerLayout>
|
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" />
|
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>
|
143
android/src/main/res/layout/fragment_login.xml
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/loginContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:animateLayoutChanges="true"
|
||||||
|
android:background="@color/colorBackgroundPrimary"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:fitsSystemWindows="true"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:contentDescription="@string/description_logo"
|
||||||
|
android:src="@drawable/ic_launcher_foreground" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/formPrompt"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/info_login" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/serverContainer"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="@string/prompt_server"
|
||||||
|
app:boxCornerRadiusBottomEnd="16dp"
|
||||||
|
app:boxCornerRadiusBottomStart="16dp"
|
||||||
|
app:boxCornerRadiusTopEnd="16dp"
|
||||||
|
app:boxCornerRadiusTopStart="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/server"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:imeOptions="actionNext"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:nextFocusRight="@+id/username"
|
||||||
|
android:nextFocusDown="@+id/username"
|
||||||
|
android:nextFocusForward="@+id/username" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/usernameContainer"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="@string/prompt_username"
|
||||||
|
app:boxCornerRadiusBottomEnd="16dp"
|
||||||
|
app:boxCornerRadiusBottomStart="16dp"
|
||||||
|
app:boxCornerRadiusTopEnd="16dp"
|
||||||
|
app:boxCornerRadiusTopStart="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/username"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:imeOptions="actionNext"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:nextFocusRight="@+id/password"
|
||||||
|
android:nextFocusDown="@+id/password"
|
||||||
|
android:nextFocusForward="@+id/password" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/passwordContainer"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="@string/prompt_password"
|
||||||
|
app:boxCornerRadiusBottomEnd="16dp"
|
||||||
|
app:boxCornerRadiusBottomStart="16dp"
|
||||||
|
app:boxCornerRadiusTopEnd="16dp"
|
||||||
|
app:boxCornerRadiusTopStart="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/password"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:nextFocusLeft="@+id/username"
|
||||||
|
android:nextFocusUp="@+id/username" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/submit"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/action_login"
|
||||||
|
app:cornerRadius="16dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/forgotPasswordLink"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/title_forgot_password"
|
||||||
|
app:cornerRadius="16dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/registerButton"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/title_register"
|
||||||
|
app:cornerRadius="16dp" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
64
android/src/main/res/layout/fragment_overview.xml
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/overviewContent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/balanceLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:text="@string/label_current_balance" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/balance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:textSize="36sp" />
|
||||||
|
|
||||||
|
<com.github.mikephil.charting.charts.HorizontalBarChart
|
||||||
|
android:id="@+id/expectedActualChart"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="200dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<androidx.emoji.widget.EmojiTextView
|
||||||
|
android:id="@+id/noData"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:text="@string/overview_no_data"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:textColor="@color/colorTextPrimary"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
</ScrollView>
|
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>
|
14
android/src/main/res/layout/fragment_register.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.auth.LoginFragment">
|
||||||
|
|
||||||
|
<!-- TODO: Update blank fragment layout -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:text="@string/hello_blank_fragment" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
35
android/src/main/res/layout/fragment_transaction_list.xml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/list_transactions"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="fill_vertical|center_horizontal" />
|
||||||
|
|
||||||
|
<androidx.emoji.widget.EmojiTextView
|
||||||
|
android:id="@+id/transaction_list_no_data"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:textColor="@color/colorTextPrimary"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/fab_add_transaction"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="true"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
app:srcCompat="@drawable/ic_add_white_24dp"
|
||||||
|
android:focusable="true" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
26
android/src/main/res/layout/header_navigation_menu.xml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/twigsIcon"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
android:src="@drawable/ic_twigs_color"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Headline6"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/twigsIcon"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/twigsIcon"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/twigsIcon" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
49
android/src/main/res/layout/list_item_budget.xml
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/listPreferredItemHeight"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/budgetName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/budgetDescription"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/budgetBalance"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="US Budget" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/budgetDescription"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/budgetBalance"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/budgetName"
|
||||||
|
tools:text="Monthly expenses in the US" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/budgetBalance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="$1,000" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
50
android/src/main/res/layout/list_item_category.xml
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/category_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/colorTextPrimary"
|
||||||
|
android:textSize="18sp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/barrier"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/category_amount"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/category_amount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
style="?android:attr/textAppearanceSmall"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/barrier"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/category_title"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/barrier"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:barrierDirection="bottom"
|
||||||
|
app:constraint_referenced_ids="category_title,category_amount" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/category_progress"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminate="true"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/barrier"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/barrier" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
65
android/src/main/res/layout/list_item_transaction.xml
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/transaction_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/colorTextPrimary"
|
||||||
|
android:textSize="18sp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/transaction_date"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/barrier"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
tools:text="Starbucks" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/transaction_description"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/transaction_date"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/transaction_amount"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/transaction_title"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
tools:text="Grande decaf vanilla latte with a shot of peppermint" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/transaction_date"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/transaction_amount"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/transaction_description"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
tools:text="12/12/12" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/barrier"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:barrierDirection="start"
|
||||||
|
app:constraint_referenced_ids="transaction_amount" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/transaction_amount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="18sp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="$4.68" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
15
android/src/main/res/layout/list_item_user.xml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/username"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
</LinearLayout>
|
|
@ -2,5 +2,4 @@
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background" />
|
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_twigs_outline" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
|
@ -2,5 +2,4 @@
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background" />
|
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_twigs_outline" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/colorBackgroundPrimary" />
|
|
||||||
<foreground android:drawable="@drawable/ic_shortcut_add_transaction_foreground" />
|
|
||||||
</adaptive-icon>
|
|
32
android/src/main/res/navigation/auth_graph.xml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/nav_graph"
|
||||||
|
app:startDestination="@id/placeholder">
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/loginFragment"
|
||||||
|
android:name="com.wbrawner.budget.ui.auth.LoginFragment"
|
||||||
|
android:label="LoginFragment">
|
||||||
|
<argument
|
||||||
|
android:name="username"
|
||||||
|
app:argType="string"
|
||||||
|
app:nullable="true" />
|
||||||
|
<argument
|
||||||
|
android:name="password"
|
||||||
|
app:argType="string"
|
||||||
|
app:nullable="true" />
|
||||||
|
</fragment>
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/registerFragment"
|
||||||
|
android:name="com.wbrawner.budget.ui.auth.RegisterFragment"
|
||||||
|
android:label="RegisterFragment" />
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/placeholder"
|
||||||
|
android:name="androidx.fragment.app.Fragment" />
|
||||||
|
<activity
|
||||||
|
android:id="@+id/mainActivity"
|
||||||
|
android:name="com.wbrawner.budget.ui.MainActivity"
|
||||||
|
android:label="activity_transaction_list"
|
||||||
|
tools:layout="@layout/activity_main" />
|
||||||
|
</navigation>
|
|
@ -6,7 +6,7 @@
|
||||||
app:startDestination="@id/overviewFragment">
|
app:startDestination="@id/overviewFragment">
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/categoryListFragment"
|
android:id="@+id/categoryListFragment"
|
||||||
android:name="com.wbrawner.twigs.ui.categories.CategoryListFragment"
|
android:name="com.wbrawner.budget.ui.categories.CategoryListFragment"
|
||||||
android:label="Categories"
|
android:label="Categories"
|
||||||
tools:layout="@layout/fragment_transaction_list">
|
tools:layout="@layout/fragment_transaction_list">
|
||||||
<action
|
<action
|
||||||
|
@ -18,12 +18,12 @@
|
||||||
</fragment>
|
</fragment>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/transactionListFragment"
|
android:id="@+id/transactionListFragment"
|
||||||
android:name="com.wbrawner.twigs.ui.transactions.TransactionListFragment"
|
android:name="com.wbrawner.budget.ui.transactions.TransactionListFragment"
|
||||||
android:label="@string/title_transactions"
|
android:label="@string/title_transactions"
|
||||||
tools:layout="@layout/fragment_transaction_list" />
|
tools:layout="@layout/fragment_transaction_list" />
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/budgetsFragment"
|
android:id="@+id/budgetsFragment"
|
||||||
android:name="com.wbrawner.twigs.ui.budgets.BudgetListFragment"
|
android:name="com.wbrawner.budget.ui.budgets.BudgetListFragment"
|
||||||
android:label="@string/title_budgets"
|
android:label="@string/title_budgets"
|
||||||
tools:layout="@layout/fragment_list_with_add_button">
|
tools:layout="@layout/fragment_list_with_add_button">
|
||||||
<action
|
<action
|
||||||
|
@ -32,35 +32,30 @@
|
||||||
</fragment>
|
</fragment>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/addEditBudget"
|
android:id="@+id/addEditBudget"
|
||||||
android:name="com.wbrawner.twigs.ui.budgets.AddEditBudgetFragment"
|
android:name="com.wbrawner.budget.ui.budgets.AddEditBudgetFragment"
|
||||||
android:label="@string/title_add_budget"
|
android:label="@string/title_add_budget"
|
||||||
tools:layout="@layout/fragment_add_edit_budget" />
|
tools:layout="@layout/fragment_add_edit_budget" />
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/overviewFragment"
|
android:id="@+id/overviewFragment"
|
||||||
android:name="com.wbrawner.twigs.ui.overview.OverviewFragment"
|
android:name="com.wbrawner.budget.ui.overview.OverviewFragment"
|
||||||
android:label="@string/title_overview"
|
android:label="@string/title_overview"
|
||||||
tools:layout="@layout/fragment_overview" />
|
tools:layout="@layout/fragment_overview" />
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/profileFragment"
|
android:id="@+id/profileFragment"
|
||||||
android:name="com.wbrawner.twigs.ui.profile.ProfileFragment"
|
android:name="com.wbrawner.budget.ui.profile.ProfileFragment"
|
||||||
android:label="@string/title_profile"
|
android:label="@string/title_profile"
|
||||||
tools:layout="@layout/fragment_profile" />
|
tools:layout="@layout/fragment_profile" />
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/categoryFragment"
|
android:id="@+id/categoryFragment"
|
||||||
android:name="com.wbrawner.twigs.ui.categories.CategoryDetailsFragment"
|
android:name="com.wbrawner.budget.ui.categories.CategoryDetailsFragment"
|
||||||
android:label="fragment_category"
|
android:label="fragment_category"
|
||||||
tools:layout="@layout/fragment_list_with_add_button" />
|
tools:layout="@layout/fragment_list_with_add_button" />
|
||||||
<activity
|
<activity
|
||||||
android:id="@+id/addEditTransactionActivity"
|
android:id="@+id/addEditTransactionActivity"
|
||||||
android:name="com.wbrawner.twigs.ui.transactions.TransactionFormActivity"
|
android:name="com.wbrawner.budget.ui.transactions.TransactionFormActivity"
|
||||||
android:label="AddEditTransactionActivity" />
|
android:label="AddEditTransactionActivity" />
|
||||||
<activity
|
<activity
|
||||||
android:id="@+id/addEditCategoryActivity"
|
android:id="@+id/addEditCategoryActivity"
|
||||||
android:name="com.wbrawner.twigs.ui.categories.CategoryFormActivity"
|
android:name="com.wbrawner.budget.ui.categories.CategoryFormActivity"
|
||||||
android:label="@string/title_add_category" >
|
android:label="@string/title_add_category" />
|
||||||
<argument
|
|
||||||
android:name="categoryId"
|
|
||||||
app:argType="string"
|
|
||||||
app:nullable="true" />
|
|
||||||
</activity>
|
|
||||||
</navigation>
|
</navigation>
|
|
@ -8,4 +8,5 @@
|
||||||
<color name="colorTextGreen">#388e3c</color>
|
<color name="colorTextGreen">#388e3c</color>
|
||||||
<color name="colorTextRed">#d32f2f</color>
|
<color name="colorTextRed">#d32f2f</color>
|
||||||
<color name="colorTextPrimaryInverted">#FFDADADA</color>
|
<color name="colorTextPrimaryInverted">#FFDADADA</color>
|
||||||
|
<color name="colorSecondary">#888888</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
<string name="title_edit_category">Edit Category</string>
|
<string name="title_edit_category">Edit Category</string>
|
||||||
<string name="prompt_category_amount">Amount</string>
|
<string name="prompt_category_amount">Amount</string>
|
||||||
<string name="prompt_category_name">Name</string>
|
<string name="prompt_category_name">Name</string>
|
||||||
<string name="prompt_category_description">Description</string>
|
|
||||||
<string name="title_add_category">Add Category</string>
|
<string name="title_add_category">Add Category</string>
|
||||||
<string name="uncategorized">Uncategorized</string>
|
<string name="uncategorized">Uncategorized</string>
|
||||||
<string name="prompt_transaction_category">Category</string>
|
<string name="prompt_transaction_category">Category</string>
|
||||||
|
|
|
@ -7,9 +7,10 @@
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
<item name="fontFamily">@font/ubuntu</item>
|
<item name="fontFamily">@font/ubuntu</item>
|
||||||
<item name="android:statusBarColor">#00000000</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
<item name="android:statusBarColor">?android:attr/windowBackground</item>
|
||||||
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
|
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
|
||||||
<item name="android:navigationBarColor">#00000000</item>
|
<item name="android:navigationBarColor">@color/colorBackgroundPrimary</item>
|
||||||
<item name="android:timePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
|
<item name="android:timePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
|
||||||
<item name="android:datePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
|
<item name="android:datePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
|
||||||
<item name="android:dialogTheme">@style/DialogTheme</item>
|
<item name="android:dialogTheme">@style/DialogTheme</item>
|
||||||
|
@ -29,18 +30,19 @@
|
||||||
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
|
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme" parent="BaseTheme">
|
<style name="AppTheme" parent="BaseTheme" />
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
|
||||||
<item name="windowSplashScreenBackground">@color/colorBackgroundPrimary</item>
|
|
||||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
|
||||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style name="DateTimePickerDialogTheme" parent="BaseDateTimePickerDialogTheme" />
|
<style name="DateTimePickerDialogTheme" parent="BaseDateTimePickerDialogTheme" />
|
||||||
|
|
||||||
|
<style name="SplashTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
<!-- <item name="android:windowBackground">@drawable/bg_splash</item>-->
|
||||||
|
<item name="android:windowTranslucentNavigation">true</item>
|
||||||
|
<item name="android:windowTranslucentStatus">true</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.EditText">
|
<style name="AppTheme.EditText">
|
||||||
<item name="android:layout_height">wrap_content</item>
|
<item name="android:layout_height">wrap_content</item>
|
||||||
<item name="android:layout_width">match_parent</item>
|
<item name="android:layout_width">match_parent</item>
|
||||||
|
|